From a14ac2557d2f6d64ee0ed041b46d5fdd61b2b749 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Wed, 22 Jul 2026 01:33:48 +0200 Subject: [PATCH 01/74] feat(cuda): working, byte-correct NVIDIA CUDA x16rs miner Port the OpenCL x16rs kernels to CUDA (x16rs-cuda, --features cuda) so NVIDIA GPUs can mine via nvcc-compiled kernels. Validated on a Colab T4 (sm_75): the GPU produces byte-identical hashes to the CPU reference across all 16 algorithms and at mainnet repeat=16, for both single hashing and the batch mining kernel. Seven bugs fixed to make it compile, launch, and be correct: 1. ocl_compat.cuh `#define __attribute__(x)` stripped CUDA's own __global__ (nvcc expands __global__ through __attribute__) -> kernels became plain __host__. 2. sph_u64 typedef conflict (jh.cl `ulong` vs x16rs.cl `unsigned long long`); x16rs.cl CUDA branch now uses `typedef ulong sph_u64` to match. 3. ALIGN (__attribute__((aligned))) is illegal on function parameters in nvcc; added ALIGN_PARAM (no-op under CUDA, =ALIGN on OpenCL) at the 5 param sites. 4. The rotate shim was hard-coded 64-bit, so 32-bit rotates were UB (PTX 0), silently corrupting cubehash/luffa/simd/hamsi + the AES tables. Replaced with width-correct overloaded __device__ rotate(uint,uint)/(ulong,uint). 5. cudaLaunchKernel FFI used the driver cuLaunchKernel layout -> scrambled ABI -> gridDim.y/z garbage -> cudaErrorInvalidConfiguration on every launch. Fixed with a #[repr(C)] Dim3 passed by value + correct parameter order. 6. cuda_mine_batch aggregated the MAX hash across workgroups; the kernel returns each workgroup's MIN and mining wants the min. Fixed the comparison direction. 7. x16rs_cuda_main per-thread reduction init `best_hash = 0` should be `= index` (matches x16rs_main.cl); with 0 the batch reduction missed the true minimum. All x16rs/opencl/*.cl edits are OpenCL-safe (byte-identical for OpenCL builds; the CUDA-specific changes are gated behind __CUDA__ / ALIGN_PARAM). Validated by x16rs-cuda/tests/genesis_vector.rs: cuda_genesis_block_hash_when_available, cuda_matches_cpu_across_many_inputs (all 16 algos + repeat=16), and cuda_batch_matches_cpu (self-consistent + true argmin, single/multi-workgroup, repeat=16). Co-Authored-By: Claude Opus 4.8 --- x16rs-cuda/cuda/block_miner.cu | 7 +- x16rs-cuda/cuda/ocl_compat.cuh | 33 ++++++- x16rs-cuda/src/lib.rs | 139 +++++++++++++++++++++++++---- x16rs-cuda/tests/genesis_vector.rs | 138 ++++++++++++++++++++++++++++ x16rs/opencl/sha2_512.cl | 4 +- x16rs/opencl/sha3_256.cl | 2 +- x16rs/opencl/util.cl | 11 +++ x16rs/opencl/x16rs.cl | 15 +++- 8 files changed, 320 insertions(+), 29 deletions(-) diff --git a/x16rs-cuda/cuda/block_miner.cu b/x16rs-cuda/cuda/block_miner.cu index d079556..85d5c03 100644 --- a/x16rs-cuda/cuda/block_miner.cu +++ b/x16rs-cuda/cuda/block_miner.cu @@ -68,7 +68,12 @@ extern "C" __global__ void x16rs_cuda_main( LT0, LT1, LT2, LT3, LT4, LT5, LT6, LT7, mixtab0, mixtab1, mixtab2, mixtab3); - unsigned int best_hash = 0; + // Must start at this thread's own first slot (index), NOT 0. Starting at 0 made every + // thread t>0 compare against thread 0's slot and skip its own slot index+0, so the + // per-thread minimum — and therefore the whole work-group reduction — was wrong (the + // returned hash stayed self-consistent with its nonce, but was not the batch minimum). + // Matches x16rs_main.cl:84 (`unsigned int best_hash = index;`). + unsigned int best_hash = index; #pragma unroll 8 for (unsigned int i = 1; i < unit_size; i++) { if (diff_big_hash_dev(&local_hashes[best_hash], &local_hashes[index + i]) == 1) { diff --git a/x16rs-cuda/cuda/ocl_compat.cuh b/x16rs-cuda/cuda/ocl_compat.cuh index 8078a6d..904a2f8 100644 --- a/x16rs-cuda/cuda/ocl_compat.cuh +++ b/x16rs-cuda/cuda/ocl_compat.cuh @@ -20,6 +20,10 @@ typedef int32_t sph_s32; #define __constant __constant__ #define __private #define __generic +// OpenCL `__global` address-space qualifier -> nothing in CUDA. (Distinct token from +// CUDA's `__global__`, which is a whole-token identifier and is NOT affected.) Only +// reached by currently-dead DEC64BE/DEC32LE paths; defined for robustness. +#define __global #define __inline__ inline __device__ #define CLK_LOCAL_MEM_FENCE 0 @@ -31,8 +35,24 @@ typedef int32_t sph_s32; #define get_global_id(dim) (blockIdx.x * blockDim.x + threadIdx.x) #define get_global_size(dim) (gridDim.x * blockDim.x) -#define rotate(x, n) \ - (((x) << ((n) & 63)) | ((x) >> (64 - ((n) & 63)))) +// Width-correct rotate. The OpenCL `rotate` builtin rotates within the operand's own +// bit width. A single 64-bit macro silently MISCOMPILES 32-bit rotates: shifting a +// uint right by (64 - n) with n in 0..31 shifts by 33..63 -> undefined behaviour -> +// nvcc's PTX yields 0, so the rotate degenerates into a plain left shift. That +// corrupts cubehash/luffa/simd/hamsi (via SPH_ROTL32) and the AES tables +// (rotate(AES0[i], 8U) -> shavite/echo). Provide width-specific overloads; the type +// of the first argument selects the width. Both count params are `uint` so the first +// argument alone disambiguates (no overload ambiguity when a 32-bit value is rotated +// by a `ulong` literal count, e.g. `rotate(uint_val, 8UL)`); the count is re-masked +// internally, preserving OpenCL's modulo-width semantics. +static __device__ __forceinline__ uint rotate(uint x, uint n) { + n &= 31u; + return (x << n) | (x >> ((32u - n) & 31u)); +} +static __device__ __forceinline__ ulong rotate(ulong x, uint n) { + n &= 63u; + return (x << n) | (x >> ((64u - n) & 63u)); +} #define as_ulong(x) ((ulong)(x)) #define as_uint(x) ((uint)(x)) #define as_uint2(x) (*(const uint2 *)&(x)) @@ -66,7 +86,14 @@ typedef int32_t sph_s32; #define ALIGN32 __align__(32) #define ALIGN64 __align__(64) -#define __attribute__(x) +// Do NOT redefine __attribute__ wholesale. On modern nvcc (12.x, Linux) CUDA's +// own __global__/__device__ qualifiers expand THROUGH __attribute__, so stripping +// it silently turns every kernel into a plain __host__ function (then __syncthreads +// fails to compile). Only neutralize the OpenCL-specific attributes nvcc does not +// understand; __attribute__((aligned(N))) (util.cl's ALIGN macros) is accepted by +// nvcc as-is. +#define work_group_size_hint(x, y, z) +#define reqd_work_group_size(x, y, z) inline __device__ uint atomic_inc(uint *addr) { return atomicAdd(addr, 1u); diff --git a/x16rs-cuda/src/lib.rs b/x16rs-cuda/src/lib.rs index 0fc515f..5496480 100644 --- a/x16rs-cuda/src/lib.rs +++ b/x16rs-cuda/src/lib.rs @@ -167,6 +167,78 @@ mod driver { -> CudaError_t; fn cudaDeviceSynchronize() -> CudaError_t; fn cudaGetErrorString(err: CudaError_t) -> *const i8; + fn cudaFuncGetAttributes(attr: *mut CudaFuncAttributes, func: *const c_void) + -> CudaError_t; + } + + // Mirrors CUDA's `cudaFuncAttributes` (leading fields only; trailing reserved for + // forward-compat with newer toolkits). Used to clamp the launch block size to the + // kernel's own `maxThreadsPerBlock` — a register-heavy kernel can have a per-kernel + // limit below the device's 1024, and launching above it returns + // cudaErrorInvalidConfiguration (9). + #[repr(C)] + struct CudaFuncAttributes { + shared_size_bytes: usize, + const_size_bytes: usize, + local_size_bytes: usize, + max_threads_per_block: i32, + num_regs: i32, + ptx_version: i32, + binary_version: i32, + cache_mode_ca: i32, + max_dynamic_shared_size_bytes: i32, + preferred_shmem_carveout: i32, + // Generous tail so the toolkit's (possibly newer/larger) cudaFuncAttributes + // never writes past this buffer; we only read the leading fields above. + _reserved: [i32; 48], + } + + impl CudaFuncAttributes { + fn zeroed() -> Self { + CudaFuncAttributes { + shared_size_bytes: 0, + const_size_bytes: 0, + local_size_bytes: 0, + max_threads_per_block: 0, + num_regs: 0, + ptx_version: 0, + binary_version: 0, + cache_mode_ca: 0, + max_dynamic_shared_size_bytes: 0, + preferred_shmem_carveout: 0, + _reserved: [0; 48], + } + } + } + + /// Query a kernel's resource attributes and return a block size clamped to its + /// `maxThreadsPerBlock` (never above `desired`, never zero). + unsafe fn clamped_block_size(func: *const c_void, desired: u32, label: &str) -> u32 { + let mut attrs = CudaFuncAttributes::zeroed(); + let rc = unsafe { cudaFuncGetAttributes(&mut attrs, func) }; + if rc != CUDA_SUCCESS { + eprintln!( + "[cuda] cudaFuncGetAttributes({}) failed rc={}; using {}", + label, rc, desired + ); + return desired.max(1); + } + eprintln!( + "[cuda] {}: numRegs={} staticShared={}B localPerThread={}B maxThreadsPerBlock={} ptx={} bin={}", + label, + attrs.num_regs, + attrs.shared_size_bytes, + attrs.local_size_bytes, + attrs.max_threads_per_block, + attrs.ptx_version, + attrs.binary_version, + ); + let kmax = if attrs.max_threads_per_block > 0 { + attrs.max_threads_per_block as u32 + } else { + desired + }; + desired.min(kmax).max(1) } const CUDA_MEMCPY_HOST_TO_DEVICE: i32 = 1; @@ -300,20 +372,30 @@ mod driver { block: (u32, u32, u32), args: &[*mut c_void], ) -> CudaResult<()> { + #[repr(C)] + #[derive(Clone, Copy)] + struct Dim3 { + x: u32, + y: u32, + z: u32, + } + // RUNTIME API cudaLaunchKernel — real signature: + // cudaError_t cudaLaunchKernel(const void*, dim3, dim3, void**, size_t, cudaStream_t) + // dim3 is passed BY VALUE and `args` comes BEFORE sharedMem/stream. The previous + // declaration used the DRIVER API cuLaunchKernel layout (grid/block as six u32s, + // then sharedMem, stream, args, extra) but linked against cudaLaunchKernel. That + // scrambled the ABI: gridDim.y/z read the high halves of registers holding single + // u32s -> garbage grid dims -> every launch failed with + // cudaErrorInvalidConfiguration (9), regardless of block size or shared memory. #[link(name = "cudart")] unsafe extern "C" { fn cudaLaunchKernel( func: *const c_void, - grid_dim_x: u32, - grid_dim_y: u32, - grid_dim_z: u32, - block_dim_x: u32, - block_dim_y: u32, - block_dim_z: u32, - shared_mem_bytes: usize, - stream: *mut c_void, + grid_dim: Dim3, + block_dim: Dim3, args: *mut *mut c_void, - extra: *mut c_void, + shared_mem: usize, + stream: *mut c_void, ) -> CudaError_t; } @@ -321,15 +403,18 @@ mod driver { check(unsafe { cudaLaunchKernel( func, - grid.0, - grid.1, - grid.2, - block.0, - block.1, - block.2, - 0, - ptr::null_mut(), + Dim3 { + x: grid.0, + y: grid.1, + z: grid.2, + }, + Dim3 { + x: block.0, + y: block.1, + z: block.2, + }, arg_ptrs.as_mut_ptr(), + 0, ptr::null_mut(), ) }) @@ -399,11 +484,16 @@ mod driver { ) })?; + // Each workgroup's kernel reduction returns the lexicographically SMALLEST hash + // it found (diff_big_hash keeps the smaller of each pair), because mining wants + // the hash closest to zero (hash < target). So aggregate across workgroups by + // keeping the MINIMUM too — replace the running best when the candidate is + // smaller, i.e. when best > candidate. let mut best_nonce = 0u32; let mut best_hash = [0u8; HASH_BYTES]; for i in 0..workgroups as usize { let hash = &hashes[i * HASH_BYTES..(i + 1) * HASH_BYTES]; - if i == 0 || lex_gt(hash, &best_hash) { + if i == 0 || lex_gt(&best_hash, hash) { best_hash.copy_from_slice(hash); best_nonce = nonces[i]; } @@ -429,11 +519,22 @@ mod driver { let mut stuff_ptr = miner.stuff_buf; let mut repeat_val = repeat; let mut out_ptr = miner.best_hashes_buf; + // The single-hash kernel does its work on thread 0; the rest only cooperatively + // fill the shared tables (the fill loop strides by blockDim.x, so any block size + // is correct). Clamp to the kernel's own maxThreadsPerBlock to avoid + // cudaErrorInvalidConfiguration on register-heavy builds. + let block = unsafe { + clamped_block_size( + x16rs_cuda_single as *const c_void, + miner.local_size, + "single", + ) + }; unsafe { launch_kernel( x16rs_cuda_single as *const c_void, (1, 1, 1), - (miner.local_size, 1, 1), + (block, 1, 1), &[ &mut stuff_ptr as *mut _ as *mut c_void, &mut repeat_val as *mut _ as *mut c_void, diff --git a/x16rs-cuda/tests/genesis_vector.rs b/x16rs-cuda/tests/genesis_vector.rs index ec42b8c..975e6c3 100644 --- a/x16rs-cuda/tests/genesis_vector.rs +++ b/x16rs-cuda/tests/genesis_vector.rs @@ -26,3 +26,141 @@ fn cuda_genesis_block_hash_when_available() { let gpu_hash = miner.block_hash_once(1, &intro).expect("cuda hash"); assert_eq!(hex::encode(gpu_hash), GENESIS_HASH); } + +/// The genesis vector selects only ONE of the 16 x16rs algorithms (repeat = 1). This +/// sweeps the input so every `h4[7] % 16` selection — hence every algorithm and both +/// rotate widths — is exercised, and also validates the full 16-round chain at +/// mainnet repeat = 16. Every GPU hash must equal the CPU reference byte-for-byte. +#[test] +#[cfg(feature = "cuda")] +fn cuda_matches_cpu_across_many_inputs() { + if !x16rs_cuda::CudaMiner::is_available() { + eprintln!("CUDA kernels not compiled; skipping cross-check"); + return; + } + let base = hex::decode(GENESIS_INTRO).unwrap(); + assert_eq!(base.len(), 89); + let miner = x16rs_cuda::CudaMiner::new(0, 1, 1).expect("cuda miner"); + + // repeat = 1: one algorithm selection per input. 4096 inputs cover all 16 well. + let mut mismatches = 0usize; + for nonce in 0u32..4096 { + let mut intro = base.clone(); + intro[79..83].copy_from_slice(&nonce.to_le_bytes()); + let cpu = block_hash(1, &intro); + let gpu = miner.block_hash_once(1, &intro).expect("cuda hash"); + if gpu != cpu { + if mismatches < 8 { + eprintln!( + "repeat1 nonce {}: cpu={} gpu={}", + nonce, + hex::encode(cpu), + hex::encode(gpu) + ); + } + mismatches += 1; + } + } + assert_eq!( + mismatches, 0, + "{}/4096 GPU hashes disagreed with CPU at repeat=1", + mismatches + ); + + // repeat = 16 (mainnet height >= 750k): validates the full repeat chain, which + // applies many algorithms per hash. + let h16 = 800_000u64; + assert_eq!(x16rs::block_hash_repeat(h16), 16); + let mut mismatches16 = 0usize; + for nonce in 0u32..512 { + let mut intro = base.clone(); + intro[79..83].copy_from_slice(&nonce.to_le_bytes()); + let cpu = block_hash(h16, &intro); + let gpu = miner.block_hash_once(h16, &intro).expect("cuda hash"); + if gpu != cpu { + if mismatches16 < 8 { + eprintln!( + "repeat16 nonce {}: cpu={} gpu={}", + nonce, + hex::encode(cpu), + hex::encode(gpu) + ); + } + mismatches16 += 1; + } + } + assert_eq!( + mismatches16, 0, + "{}/512 GPU hashes disagreed with CPU at repeat=16", + mismatches16 + ); +} + +/// Validates the batch mining kernel (`x16rs_cuda_main`) + the host-side cross-workgroup +/// aggregation end to end: for each configuration the returned (nonce, hash) must be +/// self-consistent (hash == block_hash of that nonce) AND must be the true lexicographic +/// minimum over the whole covered nonce span. Covers single- and multi-workgroup and +/// mainnet repeat=16. +#[test] +#[cfg(feature = "cuda")] +fn cuda_batch_matches_cpu() { + if !x16rs_cuda::CudaMiner::is_available() { + eprintln!("CUDA kernels not compiled; skipping batch test"); + return; + } + let base = hex::decode(GENESIS_INTRO).unwrap(); + const LOCAL_SIZE: u32 = 256; + + // The kernel writes the nonce big-endian at offset 79 (write_nonce_to_bytes under + // __ENDIAN_LITTLE__). Replicate that so the CPU reference hashes identical bytes. + let cpu_hash = |height: u64, nonce: u32| -> [u8; 32] { + let mut intro = base.clone(); + intro[79..83].copy_from_slice(&nonce.to_be_bytes()); + block_hash(height, &intro) + }; + let cpu_argmin = |height: u64, start: u32, span: u32| -> (u32, [u8; 32]) { + let mut best_nonce = start; + let mut best_hash = cpu_hash(height, start); + for nonce in start..start.wrapping_add(span) { + let h = cpu_hash(height, nonce); + if h < best_hash { + best_hash = h; + best_nonce = nonce; + } + } + (best_nonce, best_hash) + }; + + // (height, workgroups, unit_size, nonce_start) + let cases: [(u64, u32, u32, u32); 3] = [ + (1, 1, 4, 10_000), // single workgroup: exercises the in-kernel reduction + (1, 4, 2, 500_000), // multi-workgroup: exercises host aggregation (min across wg) + (800_000, 2, 2, 77), // repeat=16 mainnet, multi-workgroup + ]; + for (height, wg, unit, start) in cases { + let miner = x16rs_cuda::CudaMiner::new(0, wg, unit).expect("cuda miner"); + let (gpu_nonce, gpu_hash) = miner + .mine_block_batch(height, &base, start, wg) + .expect("mine batch"); + assert_eq!( + cpu_hash(height, gpu_nonce), + gpu_hash, + "h={} wg={}: GPU (nonce {}, hash) is not self-consistent", + height, + wg, + gpu_nonce + ); + let span = wg * LOCAL_SIZE * unit; + let (cpu_nonce, cpu_min) = cpu_argmin(height, start, span); + assert_eq!( + gpu_hash, cpu_min, + "h={} wg={}: GPU best hash != CPU argmin over {} nonces", + height, wg, span + ); + assert_eq!( + gpu_nonce, cpu_nonce, + "h={} wg={}: GPU best nonce {} != CPU argmin nonce {}", + height, wg, gpu_nonce, cpu_nonce + ); + } +} diff --git a/x16rs/opencl/sha2_512.cl b/x16rs/opencl/sha2_512.cl index 587a5aa..0e14765 100644 --- a/x16rs/opencl/sha2_512.cl +++ b/x16rs/opencl/sha2_512.cl @@ -172,7 +172,7 @@ __inline__ void sha512_init(sha512_ctx_t *sha512_ctx, uint8_t *payload_addr) * @param sha512_ctx context of the sha384/512 * @param data hash block data, 1024 bits. */ -__inline__ void sha512_hash_factory(sha512_ctx_t *ctx, __generic uint8_t ALIGN data[128]) +__inline__ void sha512_hash_factory(sha512_ctx_t *ctx, __generic uint8_t ALIGN_PARAM data[128]) { uint32_t i = 0; uint64_t W[80]; @@ -272,7 +272,7 @@ __inline__ void easy_sha512_impl(uint8_t *payload, * @param payload address of the hash payload * @param hash output of hash value */ -__inline__ void easy_sha512(uint8_t *payload, uint8_t ALIGN hash[32]) +__inline__ void easy_sha512(uint8_t *payload, uint8_t ALIGN_PARAM hash[32]) { return easy_sha512_impl(payload, hash); } diff --git a/x16rs/opencl/sha3_256.cl b/x16rs/opencl/sha3_256.cl index 776a7c2..080b87a 100644 --- a/x16rs/opencl/sha3_256.cl +++ b/x16rs/opencl/sha3_256.cl @@ -104,7 +104,7 @@ __inline__ void keccak_theta(__generic ulong A[25]) THETA_STEP(4); } -__inline__ void rhash_sha3_permutation(__generic ulong ALIGN state[25]) +__inline__ void rhash_sha3_permutation(__generic ulong ALIGN_PARAM state[25]) { ulong temp_state[25]; for (unsigned round = 0; round < NumberOfRounds; round++) diff --git a/x16rs/opencl/util.cl b/x16rs/opencl/util.cl index 310cba7..62a66df 100644 --- a/x16rs/opencl/util.cl +++ b/x16rs/opencl/util.cl @@ -14,6 +14,17 @@ #define ALIGN32 __attribute__((aligned(32))) #define ALIGN64 __attribute__((aligned(64))) +// Alignment qualifier for *function parameters*. C++/nvcc forbids an alignment +// specifier on a parameter (an array param decays to a pointer), whereas OpenCL C +// allows it. So ALIGN_PARAM keeps the OpenCL hint on OpenCL builds and is a no-op +// under CUDA. Use ALIGN_PARAM (not ALIGN) on any parameter declaration; keep ALIGN +// on struct/union/local/shared/constant declarations (nvcc accepts those). +#ifdef __CUDA__ + #define ALIGN_PARAM +#else + #define ALIGN_PARAM ALIGN +#endif + typedef union ALIGN8 { unsigned char h1[88]; ulong h8[11]; diff --git a/x16rs/opencl/x16rs.cl b/x16rs/opencl/x16rs.cl index 9f4276c..da7eb25 100644 --- a/x16rs/opencl/x16rs.cl +++ b/x16rs/opencl/x16rs.cl @@ -25,7 +25,16 @@ typedef unsigned int sph_u32; typedef int sph_s32; #ifndef __OPENCL_VERSION__ - typedef unsigned long long sph_u64; + #ifdef __CUDA__ + // CUDA build: match the algorithm kernels (jh.cl etc.) which do + // `typedef ulong sph_u64`. ulong is supplied by ocl_compat.cuh. Using the same + // spelling keeps sph_u64 a single consistent type across the whole CUDA + // translation unit — nvcc rejects a conflicting `unsigned long long` here vs + // `ulong` in jh.cl as an "invalid redeclaration". OpenCL is unaffected (#else). + typedef ulong sph_u64; + #else + typedef unsigned long long sph_u64; + #endif typedef long long sph_s64; #else typedef unsigned long sph_u64; @@ -542,7 +551,7 @@ X16RS_HASH_FN void hash_x16rs_func_1(hash_32* hash) } // groestl -X16RS_HASH_FN void hash_x16rs_func_2(hash_32* ALIGN hash, OCL_LOCAL_PTR const ulong* ALIGN T0, OCL_LOCAL_PTR const ulong* ALIGN T1, OCL_LOCAL_PTR const ulong* ALIGN T2, OCL_LOCAL_PTR const ulong* ALIGN T3) +X16RS_HASH_FN void hash_x16rs_func_2(hash_32* ALIGN_PARAM hash, OCL_LOCAL_PTR const ulong* ALIGN_PARAM T0, OCL_LOCAL_PTR const ulong* ALIGN_PARAM T1, OCL_LOCAL_PTR const ulong* ALIGN_PARAM T2, OCL_LOCAL_PTR const ulong* ALIGN_PARAM T3) { ulong ALIGN M[16]; ulong ALIGN G[16]; @@ -1301,7 +1310,7 @@ X16RS_HASH_FN void hash_x16rs_func_13(hash_32* hash) } // whirlpool -X16RS_HASH_FN void hash_x16rs_func_14(hash_32* hash, OCL_LOCAL_PTR const sph_u64* ALIGN LT0, OCL_LOCAL_PTR const sph_u64* ALIGN LT1, OCL_LOCAL_PTR const sph_u64* ALIGN LT2, OCL_LOCAL_PTR const sph_u64* ALIGN LT3, OCL_LOCAL_PTR const sph_u64* ALIGN LT4, OCL_LOCAL_PTR const sph_u64* ALIGN LT5, OCL_LOCAL_PTR const sph_u64* ALIGN LT6, OCL_LOCAL_PTR const sph_u64* ALIGN LT7) +X16RS_HASH_FN void hash_x16rs_func_14(hash_32* hash, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT0, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT1, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT2, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT3, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT4, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT5, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT6, OCL_LOCAL_PTR const sph_u64* ALIGN_PARAM LT7) { sph_u64 n0 = hash->h8[0]; sph_u64 n1 = hash->h8[1]; From 8e965e88fd2b857464933d5fb670bb2b0cbb6d1a Mon Sep 17 00:00:00 2001 From: Moskyera Date: Wed, 22 Jul 2026 02:08:55 +0200 Subject: [PATCH 02/74] feat(cuda): NVIDIA CUDA miner-panel integration + Windows-nvcc portability Wire the validated CUDA backend into the app and miner panel, and make the CUDA kernels compile on Windows nvcc (the release build platform), not just Linux. Windows-nvcc portability (kernels compiled clean on Linux nvcc but failed on Windows nvcc v13.3 / MSVC host): - The ALIGN macros used __attribute__((aligned(N))), which MSVC-hosted nvcc rejects in union/local positions. Alignment here is a perf hint only (the kernels use element-wise access, never vector/uint4 loads), so it is now a no-op under CUDA (ocl_compat.cuh), with the __attribute__ versions kept for OpenCL builds only (util.cl, gated on __CUDA__). Re-validated on the Colab T4: all four correctness tests still pass byte-for-byte, confirming the change is value-neutral. app crate (the cuda feature path had never been compiled and had accumulated bugs): - mining_batch.rs referenced crate::CudaMiningResources / crate::do_group_block_mining_cuda, but those live in crate::poworker (cuda_pow.rs is include!d there) -> fixed the paths. - The MiningRuntimeState import was gated #[cfg(feature = "ocl")] but CudaBlockBackend needs it too -> widened to any(ocl, cuda). Verified with `cargo check -p app --features cuda` (full Windows nvcc + MSVC build). miner-panel CUDA toggle: - PanelSettings.use_cuda + LoadedPanelIni.use_cuda (persisted, round-trips through the ini loader). write_poworker_config now emits use_cuda/cuda_device and turns use_opencl off, GATED on the selected GPU being NVIDIA (a stale flag never enables CUDA for AMD/Intel). - An NVIDIA-only "Use CUDA (NVIDIA)" checkbox (presets::profile_is_nvidia) in the hardware section, with label_use_cuda added to all 9 locales. - Two unit tests: NVIDIA+use_cuda writes the CUDA backend and round-trips; a stale use_cuda on an AMD GPU is ignored. `cargo test -p miner-panel`: 52 passed. Also bundles an earlier panel change (HAC bid/amount fields shown as plain decimals instead of mei:fin, e.g. "1" not "1:0") in hacash_config.rs / help_options.rs / i18n.rs / ui_settings_tab.rs. Co-Authored-By: Claude Opus 4.8 --- app/src/mining_batch.rs | 6 +-- miner-panel/src/config.rs | 69 ++++++++++++++++++++++++++++-- miner-panel/src/hacash_config.rs | 23 +++++----- miner-panel/src/help_options.rs | 8 ++-- miner-panel/src/i18n.rs | 46 ++++++++++++-------- miner-panel/src/main.rs | 5 +++ miner-panel/src/presets.rs | 5 +++ miner-panel/src/ui_settings.rs | 7 +++ miner-panel/src/ui_settings_tab.rs | 4 +- x16rs-cuda/cuda/ocl_compat.cuh | 15 +++++-- x16rs/opencl/util.cl | 5 +++ 11 files changed, 149 insertions(+), 44 deletions(-) diff --git a/app/src/mining_batch.rs b/app/src/mining_batch.rs index 5a97ee5..6f042af 100644 --- a/app/src/mining_batch.rs +++ b/app/src/mining_batch.rs @@ -7,7 +7,7 @@ use crate::hash_util::hash_more_power; #[cfg(feature = "ocl")] use crate::gpu_oom::GpuBatchError; -#[cfg(feature = "ocl")] +#[cfg(any(feature = "ocl", feature = "cuda"))] use crate::mining_runtime::MiningRuntimeState; #[cfg(feature = "ocl")] use crate::opencl_gpu::OpenclGpuHandle; @@ -322,7 +322,7 @@ impl BlockMinerBackend for OpenclBlockBackend { #[cfg(feature = "cuda")] pub struct CudaBlockBackend { - pub cuda: Arc, + pub cuda: Arc, pub configured_wg: u32, pub runtime: Arc, } @@ -351,7 +351,7 @@ impl BlockMinerBackend for CudaBlockBackend { ); }; - match crate::do_group_block_mining_cuda( + match crate::poworker::do_group_block_mining_cuda( &self.cuda, ctx.height, ctx.block_intro.clone(), diff --git a/miner-panel/src/config.rs b/miner-panel/src/config.rs index c11fe9f..854d937 100644 --- a/miner-panel/src/config.rs +++ b/miner-panel/src/config.rs @@ -3,7 +3,7 @@ use std::io; use std::path::{Path, PathBuf}; use app::efficiency::{EfficiencyMode, min_profile_tier_for_mode, profile_tier}; -use app::gpu_arch::{ArchLimits, normalize_profile, profile_vendor}; +use app::gpu_arch::{ArchLimits, GpuVendor, normalize_profile, profile_vendor}; use crate::currency::Currency; @@ -22,6 +22,9 @@ pub struct PanelSettings { pub hac_price: f64, pub platform_id: u32, pub device_id: u32, + /// Use the CUDA backend (NVIDIA) instead of OpenCL. Requires a miner built with + /// `--features cuda`; only honored for NVIDIA GPUs (write_poworker_config gates it). + pub use_cuda: bool, pub connect: String, pub stats_file: String, pub opencl_dir: String, @@ -139,6 +142,7 @@ pub struct LoadedPanelIni { pub max_temp_c: Option, pub pause_if_unprofitable: Option, pub benchmark_seconds: Option, + pub use_cuda: Option, } fn parse_u32(s: &str) -> Option { @@ -218,6 +222,9 @@ pub fn load_panel_ini(path: &Path) -> LoadedPanelIni { .get("pause_if_unprofitable") .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes")), benchmark_seconds: eff.get("benchmark_seconds").and_then(|v| parse_u32(v)), + use_cuda: gpu + .get("use_cuda") + .map(|v| matches!(v.to_lowercase().as_str(), "true" | "1" | "yes")), } } @@ -237,6 +244,7 @@ pub fn apply_loaded_ini( hac_price: &mut f32, max_temp_c: &mut u32, pause_unprofitable: &mut bool, + use_cuda: &mut bool, currency: Currency, ) { if let Some(sv) = loaded.supervene { @@ -281,6 +289,9 @@ pub fn apply_loaded_ini( if let Some(p) = loaded.pause_if_unprofitable { *pause_unprofitable = p; } + if let Some(c) = loaded.use_cuda { + *use_cuda = c; + } } fn safe_profile_for_gpu(gpu: &GpuPreset, requested: &str, mode: EfficiencyMode) -> String { @@ -421,6 +432,9 @@ stats_file = {stats_file} pub fn write_poworker_config(path: &Path, s: &PanelSettings) -> std::io::Result<()> { let cpu_only = s.gpu.slug == "none"; + // CUDA is only a valid backend for NVIDIA GPUs; gate here so a stale checkbox never + // writes use_cuda=true for an AMD/Intel selection. + let cuda_on = s.use_cuda && !cpu_only && profile_vendor(s.gpu.profile) == GpuVendor::Nvidia; let cpu_assist = !cpu_only && s.cpu.supervene > 0; let (wg, us, profile) = resolve_ini_tuning(s); let body = format!( @@ -433,12 +447,13 @@ notice_wait = 45 {efficiency} [gpu] use_opencl = {use_ocl} -use_cuda = false +use_cuda = {use_cuda} cpu_assist = {cpu_assist} gpu_slug = {gpu_slug} gpu_profile = {profile} platform_id = {platform_id} device_ids = {device_id} +cuda_device = {device_id} opencl_dir = {opencl_dir} work_groups = {wg} local_size = 256 @@ -455,7 +470,8 @@ debug = 0 s.max_temp_c, s.cpu.supervene, ), - use_ocl = if cpu_only { "false" } else { "true" }, + use_ocl = if cpu_only || cuda_on { "false" } else { "true" }, + use_cuda = if cuda_on { "true" } else { "false" }, cpu_assist = if cpu_assist { "true" } else { "false" }, gpu_slug = s.gpu.slug, profile = profile, @@ -549,6 +565,7 @@ mod write_tuning_tests { hac_price: 0.01, platform_id: 0, device_id: 0, + use_cuda: false, connect: "127.0.0.1:8080".into(), stats_file: String::new(), opencl_dir: String::new(), @@ -652,6 +669,51 @@ mod write_tuning_tests { ); } + #[test] + fn cuda_enabled_for_nvidia_writes_cuda_backend() { + let gpu = gpu_presets() + .into_iter() + .find(|g| g.slug == "rtx4090") + .unwrap(); + let mut s = panel_with_wg(&gpu, 64, 64); + s.use_cuda = true; + s.device_id = 2; + let path = + std::env::temp_dir().join(format!("hacash-panel-cuda-{}.ini", std::process::id())); + write_poworker_config(&path, &s).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + // CUDA selected on an NVIDIA GPU: OpenCL off, CUDA on, cuda_device carried through. + assert!(raw.contains("use_cuda = true"), "{raw}"); + assert!(raw.contains("use_opencl = false"), "{raw}"); + assert!(raw.contains("cuda_device = 2"), "{raw}"); + // The written config round-trips back through the loader. + let tmp = + std::env::temp_dir().join(format!("hacash-panel-cuda2-{}.ini", std::process::id())); + std::fs::write(&tmp, &raw).unwrap(); + let loaded = load_panel_ini(&tmp); + let _ = std::fs::remove_file(&tmp); + assert_eq!(loaded.use_cuda, Some(true)); + } + + #[test] + fn cuda_flag_ignored_for_non_nvidia_gpu() { + // A stale use_cuda=true must never enable CUDA for a non-NVIDIA GPU. + let gpu = gpu_presets() + .into_iter() + .find(|g| g.slug == "rx9070xt") + .unwrap(); + let mut s = panel_with_wg(&gpu, 64, 64); + s.use_cuda = true; + let path = + std::env::temp_dir().join(format!("hacash-panel-amdcuda-{}.ini", std::process::id())); + write_poworker_config(&path, &s).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(raw.contains("use_cuda = false"), "{raw}"); + assert!(raw.contains("use_opencl = true"), "{raw}"); + } + #[test] fn hacd_config_is_strictly_cpu_only() { let gpu = gpu_presets() @@ -745,6 +807,7 @@ impl PanelSettings { hac_price: self.hac_price, platform_id: self.platform_id, device_id: self.device_id, + use_cuda: self.use_cuda, connect: self.connect.clone(), stats_file: self.stats_file.clone(), opencl_dir: self.opencl_dir.clone(), diff --git a/miner-panel/src/hacash_config.rs b/miner-panel/src/hacash_config.rs index 590f989..e73dfd4 100644 --- a/miner-panel/src/hacash_config.rs +++ b/miner-panel/src/hacash_config.rs @@ -104,9 +104,12 @@ pub fn validate_diamond_settings(d: &DiamondMinerSettings) -> Result<(), 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(), + // Bid amounts are plain HAC (mei/decimal): "1" = 1 HAC, "0.5" = half a + // HAC. The colon form "X:Y" is coin(mantissa X, unit Y), so "1:0" is + // 10^-248 HAC (dust), NOT 1 HAC — do not use it here. + bid_min: "1".to_string(), + bid_max: "31".to_string(), + bid_step: "0.5".to_string(), ..Default::default() }; }; @@ -115,15 +118,15 @@ pub fn read_diamond_miner(path: &Path) -> DiamondMinerSettings { 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 } + if v.is_empty() { "1".into() } else { v } }, bid_max: { let v = read_section_key(&content, "diamondminer", "bid_max"); - if v.is_empty() { "31:0".into() } else { v } + if v.is_empty() { "31".into() } else { v } }, bid_step: { let v = read_section_key(&content, "diamondminer", "bid_step"); - if v.is_empty() { "0:5".into() } else { v } + if v.is_empty() { "0.5".into() } else { v } }, } } @@ -458,15 +461,15 @@ mod tests { #[test] fn validates_diamond_bid_range() { let valid = DiamondMinerSettings { - bid_min: "1:0".into(), - bid_max: "31:0".into(), - bid_step: "0:5".into(), + bid_min: "1".into(), + bid_max: "31".into(), + bid_step: "0.5".into(), ..Default::default() }; assert!(validate_diamond_settings(&valid).is_ok()); let mut invalid = valid; - invalid.bid_min = "40:0".into(); + invalid.bid_min = "40".into(); assert!(validate_diamond_settings(&invalid).is_err()); } diff --git a/miner-panel/src/help_options.rs b/miner-panel/src/help_options.rs index 0a6242a..25fce82 100644 --- a/miner-panel/src/help_options.rs +++ b/miner-panel/src/help_options.rs @@ -28,7 +28,7 @@ static EN: &[HelpSection] = &[ "Connect — HAC: local fullnode or pool RPC. HACD: local or shared LAN fullnode.", "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).", + "HACD bids — bid_password, bid_min / bid_max / bid_step (mei format, e.g. 1 = 1 HAC).", ], }, HelpSection { @@ -90,7 +90,7 @@ static EN: &[HelpSection] = &[ "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).", + "bid_min / bid_max / bid_step — bid range in mei (1 = 1 HAC).", "Fullnode also needs diamond_form = true in node config.", ], }, @@ -122,7 +122,7 @@ static EL: &[HelpSection] = &[ "Connect — HAC: solo fullnode ή pool RPC. HACD: τοπικό ή κοινό LAN fullnode.", "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).", + "HACD bids — bid_password, bid_min / bid_max / bid_step (μορφή mei, π.χ. 1 = 1 HAC).", ], }, HelpSection { @@ -184,7 +184,7 @@ static EL: &[HelpSection] = &[ "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).", + "bid_min / bid_max / bid_step — εύρος bid σε mei (1 = 1 HAC).", "Το fullnode χρειάζεται επίσης diamond_form = true.", ], }, diff --git a/miner-panel/src/i18n.rs b/miner-panel/src/i18n.rs index f385f5e..851c0a2 100644 --- a/miner-panel/src/i18n.rs +++ b/miner-panel/src/i18n.rs @@ -90,6 +90,7 @@ pub struct Strings { pub settings_intro: &'static str, pub label_cpu: &'static str, pub label_gpu: &'static str, + pub label_use_cuda: &'static str, pub label_mode: &'static str, pub mode_eco: &'static str, pub mode_profit: &'static str, @@ -251,6 +252,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "Pick your CPU and GPU — the app configures everything automatically.", label_cpu: "Processor (CPU):", label_gpu: "Graphics card (GPU):", + label_use_cuda: "Use CUDA (NVIDIA)", label_mode: "Mode:", mode_eco: "Eco (less power)", mode_profit: "Profit balance (recommended)", @@ -264,7 +266,7 @@ pub fn strings(lang: Lang) -> Strings { 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].", + bid_hint: "Format: mei e.g. 1 = 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.", @@ -331,7 +333,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. Settings → HACD → wallet, bid password, min/max/step (format 1 = 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: "HAC GPU mining uses OpenCL only (AMD/NVIDIA/Intel) — no CUDA. HACD is CPU/full-node only.", @@ -364,6 +366,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "Διάλεξε τον επεξεργαστή και την κάρτα σου — το πρόγραμμα φτιάχνει τα πάντα αυτόματα.", label_cpu: "Επεξεργαστής (CPU):", label_gpu: "Κάρτα γραφικών (GPU):", + label_use_cuda: "Χρήση CUDA (NVIDIA)", label_mode: "Λειτουργία:", mode_eco: "Οικονομικό (λιγότερο ρεύμα)", mode_profit: "Ισορροπία κέρδους (προτείνεται)", @@ -377,7 +380,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_min: "Ελάχ. bid (HAC):", label_bid_max: "Μέγ. bid (HAC):", label_bid_step: "Βήμα bid (HAC):", - bid_hint: "Μορφή: mei π.χ. 1:0 = 1 HAC. Το fullnode χρειάζεται [diamondminer].", + bid_hint: "Μορφή: mei π.χ. 1 = 1 HAC. Το fullnode χρειάζεται [diamondminer].", hacd_wallet_hint: "Διεύθυνση PRIVAKEY (3x...) για ανταμοιβές diamond.", diaworker_not_found: "Δεν βρέθηκε diaworker.exe — κάνε build πρώτα.\nΑναζήτηση:", bid_password_required: "Βάλε κωδικό bid account για HACD mining.", @@ -444,7 +447,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. Ρυθμίσεις → HACD → wallet, κωδικός bid, min/max/step (1 = 1 HAC).", help_hacd_step4: "4. Αποθήκευση & Έναρξη — diaworker κάνει mine· fullnode κάνει bid στο [diamondminer].", help_hacd_step5: "5. Κάνε restart το fullnode μετά από αλλαγή wallet ή bid.", help_hardware_note: "Το HAC GPU mining χρησιμοποιεί μόνο OpenCL (AMD/NVIDIA/Intel) — ποτέ CUDA. Το HACD είναι αποκλειστικά CPU/full-node.", @@ -477,6 +480,7 @@ pub fn strings(lang: Lang) -> Strings { 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_use_cuda: "CUDA kullan (NVIDIA)", label_mode: "Mod:", mode_eco: "Ekonomik (daha az güç)", mode_profit: "Kâr dengesi (önerilen)", @@ -490,7 +494,7 @@ pub fn strings(lang: Lang) -> Strings { 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.", + bid_hint: "Format: mei örn. 1 = 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.", @@ -557,7 +561,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. Ayarlar → HACD → cüzdan, teklif şifresi, min/max/step (1 = 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: "HAC GPU madenciliği yalnızca OpenCL kullanır (AMD/NVIDIA/Intel) — CUDA yok. HACD yalnızca CPU/full-node kullanır.", @@ -590,6 +594,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "选择您的 CPU 和 GPU — 程序将自动完成所有配置。", label_cpu: "处理器 (CPU):", label_gpu: "显卡 (GPU):", + label_use_cuda: "使用 CUDA (NVIDIA)", label_mode: "模式:", mode_eco: "节能(低功耗)", mode_profit: "利润平衡(推荐)", @@ -603,7 +608,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_min: "最低竞价 (HAC):", label_bid_max: "最高竞价 (HAC):", label_bid_step: "竞价步长 (HAC):", - bid_hint: "格式: mei 如 1:0 = 1 HAC。全节点需启用 [diamondminer]。", + bid_hint: "格式: mei 如 1 = 1 HAC。全节点需启用 [diamondminer]。", hacd_wallet_hint: "钻石奖励用 PRIVAKEY 地址 (3x...)。", diaworker_not_found: "未找到 diaworker.exe — 请先编译。\n搜索路径:", bid_password_required: "请输入 HACD 竞价账户密码。", @@ -670,7 +675,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. 设置 → HACD → 钱包、竞价密码、min/max/step(1 = 1 HAC)。", help_hacd_step4: "4. 保存并启动 — diaworker 挖矿;全节点通过 [diamondminer] 自动竞价。", help_hacd_step5: "5. 更改钱包或竞价后请重启全节点。", help_hardware_note: "HAC GPU 挖矿仅使用 OpenCL(AMD/NVIDIA/Intel),不使用 CUDA。HACD 仅支持 CPU/全节点挖矿。", @@ -703,6 +708,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "CPU と GPU を選ぶだけ — 自動ですべて設定します。", label_cpu: "プロセッサ (CPU):", label_gpu: "グラフィックカード (GPU):", + label_use_cuda: "CUDA を使用 (NVIDIA)", label_mode: "モード:", mode_eco: "エコ(低消費電力)", mode_profit: "利益バランス(推奨)", @@ -716,7 +722,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_min: "最小入札 (HAC):", label_bid_max: "最大入札 (HAC):", label_bid_step: "入札ステップ (HAC):", - bid_hint: "形式: mei 例 1:0 = 1 HAC。フルノードに [diamondminer] が必要。", + bid_hint: "形式: mei 例 1 = 1 HAC。フルノードに [diamondminer] が必要。", hacd_wallet_hint: "ダイヤ報酬用 PRIVAKEY アドレス (3x...)。", diaworker_not_found: "diaworker.exe が見つかりません — 先にビルド。\n検索:", bid_password_required: "HACD 用の入札パスワードを入力してください。", @@ -783,7 +789,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. 設定 → HACD → ウォレット、入札パスワード、min/max/step(1 = 1 HAC)。", help_hacd_step4: "4. 保存して開始 — diaworker がマイニング、fullnode が [diamondminer] で入札。", help_hacd_step5: "5. ウォレットまたは入札変更後は fullnode を再起動。", help_hardware_note: "HAC の GPU マイニングは OpenCL のみ(AMD/NVIDIA/Intel)、CUDA は不使用。HACD は CPU/フルノード専用です。", @@ -816,6 +822,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "Elige tu CPU y GPU — la app configura todo automáticamente.", label_cpu: "Procesador (CPU):", label_gpu: "Tarjeta gráfica (GPU):", + label_use_cuda: "Usar CUDA (NVIDIA)", label_mode: "Modo:", mode_eco: "Eco (menos consumo)", mode_profit: "Equilibrio de beneficio (recomendado)", @@ -829,7 +836,7 @@ pub fn strings(lang: Lang) -> Strings { 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].", + bid_hint: "Formato: mei ej. 1 = 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.", @@ -896,7 +903,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. Ajustes → HACD → wallet, contraseña puja, min/max/step (1 = 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: "La minería GPU de HAC usa solo OpenCL (AMD/NVIDIA/Intel), nunca CUDA. HACD es solo CPU/full-node.", @@ -929,6 +936,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "Choisissez votre CPU et GPU — l'app configure tout automatiquement.", label_cpu: "Processeur (CPU) :", label_gpu: "Carte graphique (GPU) :", + label_use_cuda: "Utiliser CUDA (NVIDIA)", label_mode: "Mode :", mode_eco: "Éco (moins de consommation)", mode_profit: "Équilibre profit (recommandé)", @@ -942,7 +950,7 @@ pub fn strings(lang: Lang) -> Strings { 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].", + bid_hint: "Format : mei ex. 1 = 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.", @@ -1009,7 +1017,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. Réglages → HACD → wallet, mot de passe enchère, min/max/step (1 = 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: "Le minage GPU HAC utilise uniquement OpenCL (AMD/NVIDIA/Intel), jamais CUDA. HACD est uniquement CPU/full-node.", @@ -1042,6 +1050,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "เลือก CPU และ GPU ของคุณ — โปรแกรมตั้งค่าทุกอย่างให้อัตโนมัติ", label_cpu: "ซีพียู (CPU):", label_gpu: "การ์ดจอ (GPU):", + label_use_cuda: "ใช้ CUDA (NVIDIA)", label_mode: "โหมด:", mode_eco: "ประหยัด (ใช้ไฟน้อย)", mode_profit: "สมดุลกำไร (แนะนำ)", @@ -1055,7 +1064,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_min: "ประมูลขั้นต่ำ (HAC):", label_bid_max: "ประมูลสูงสุด (HAC):", label_bid_step: "ขั้นประมูล (HAC):", - bid_hint: "รูปแบบ: mei เช่น 1:0 = 1 HAC ต้องมี [diamondminer] ใน fullnode", + bid_hint: "รูปแบบ: mei เช่น 1 = 1 HAC ต้องมี [diamondminer] ใน fullnode", hacd_wallet_hint: "ที่อยู่ PRIVAKEY (3x...) สำหรับรางวัลเพชร", diaworker_not_found: "ไม่พบ diaworker.exe — กรุณา build ก่อน\nค้นหา:", bid_password_required: "กรอกรหัสบัญชีประมูลสำหรับ HACD", @@ -1122,7 +1131,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. ตั้งค่า → HACD → กระเป๋า รหัสประมูล min/max/step (1 = 1 HAC)", help_hacd_step4: "4. บันทึกและเริ่ม — diaworker ขุด fullnode ประมูลผ่าน [diamondminer]", help_hacd_step5: "5. รีสตาร์ท fullnode หลังเปลี่ยนกระเป๋าหรือการประมูล", help_hardware_note: "การขุด HAC ด้วย GPU ใช้ OpenCL เท่านั้น (AMD/NVIDIA/Intel) ไม่ใช้ CUDA ส่วน HACD ใช้ CPU/full-node เท่านั้น", @@ -1155,6 +1164,7 @@ pub fn strings(lang: Lang) -> Strings { settings_intro: "Выберите CPU и GPU — программа настроит всё автоматически.", label_cpu: "Процессор (CPU):", label_gpu: "Видеокарта (GPU):", + label_use_cuda: "Использовать CUDA (NVIDIA)", label_mode: "Режим:", mode_eco: "Эко (меньше энергии)", mode_profit: "Баланс прибыли (рекомендуется)", @@ -1168,7 +1178,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_min: "Мин. ставка (HAC):", label_bid_max: "Макс. ставка (HAC):", label_bid_step: "Шаг ставки (HAC):", - bid_hint: "Формат: mei напр. 1:0 = 1 HAC. Нужен [diamondminer] в fullnode.", + bid_hint: "Формат: mei напр. 1 = 1 HAC. Нужен [diamondminer] в fullnode.", hacd_wallet_hint: "PRIVAKEY адрес (3x...) для наград за алмазы.", diaworker_not_found: "diaworker.exe не найден — сначала соберите.\nПоиск:", bid_password_required: "Введите пароль bid-аккаунта для HACD.", @@ -1235,7 +1245,7 @@ pub fn strings(lang: Lang) -> Strings { 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_step3: "3. Настройки → HACD → кошелёк, пароль ставок, min/max/step (1 = 1 HAC).", help_hacd_step4: "4. Сохранить и Старт — diaworker майнит; fullnode ставит через [diamondminer].", help_hacd_step5: "5. Перезапустите fullnode после смены кошелька или ставок.", help_hardware_note: "GPU-майнинг HAC использует только OpenCL (AMD/NVIDIA/Intel), без CUDA. HACD работает только на CPU/full-node.", diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs index 3ab7bf4..21652a0 100644 --- a/miner-panel/src/main.rs +++ b/miner-panel/src/main.rs @@ -101,6 +101,7 @@ struct MinerApp { hac_price: f32, platform_id: u32, device_id: u32, + use_cuda: bool, connect: String, connect_mode: ConnectMode, pool_preset_idx: usize, @@ -178,6 +179,7 @@ impl MinerApp { let mut hac_price = 0.0f32; let mut platform_id = 0u32; let mut device_id = 0u32; + let mut use_cuda = false; let mut connect = SOLO_DEFAULT.to_string(); let mut max_temp_c = 0u32; let mut pause_unprofitable = false; @@ -199,6 +201,7 @@ impl MinerApp { &mut hac_price, &mut max_temp_c, &mut pause_unprofitable, + &mut use_cuda, currency, ); if mining_kind == MiningKind::Hacd && cpus[cpu_idx].supervene == 0 { @@ -298,6 +301,7 @@ impl MinerApp { hac_price, platform_id, device_id, + use_cuda, connect, connect_mode, pool_preset_idx: 0, @@ -489,6 +493,7 @@ impl MinerApp { hac_price: Currency::convert(self.hac_price as f64, Currency::Usd, Currency::Eur), platform_id: self.platform_id, device_id: self.device_id, + use_cuda: self.use_cuda, connect: self.connect.clone(), stats_file: self.stats_path.to_string_lossy().to_string(), opencl_dir: opencl_dir_for(&self.work_dir), diff --git a/miner-panel/src/presets.rs b/miner-panel/src/presets.rs index ba87b3a..e6c1af4 100644 --- a/miner-panel/src/presets.rs +++ b/miner-panel/src/presets.rs @@ -301,6 +301,11 @@ pub fn is_rdna4_experimental(slug: &str) -> bool { gpu_arch::ArchLimits::for_panel_slug(slug).is_experimental() } +/// True when the preset's profile is an NVIDIA GPU (where the optional CUDA backend applies). +pub fn profile_is_nvidia(profile: &str) -> bool { + gpu_arch::profile_vendor(profile) == gpu_arch::GpuVendor::Nvidia +} + /// Resolve profile + work_groups + unit_size for a GPU preset and efficiency mode. pub fn resolve_panel_tuning(gpu: &GpuPreset, mode: EfficiencyMode) -> ResolvedTuning { panel_tuning::resolve_panel_tuning(gpu.slug, gpu.profile, gpu.vram_gb, mode) diff --git a/miner-panel/src/ui_settings.rs b/miner-panel/src/ui_settings.rs index 3c4c2de..c103795 100644 --- a/miner-panel/src/ui_settings.rs +++ b/miner-panel/src/ui_settings.rs @@ -98,6 +98,13 @@ impl MinerApp { } ui.end_row(); + // CUDA backend is NVIDIA-only and needs a `--features cuda` miner build. + if presets::profile_is_nvidia(self.gpu_presets[self.gpu_idx].profile) { + theme::field_label(ui, ""); + ui.checkbox(&mut self.use_cuda, t.label_use_cuda); + ui.end_row(); + } + if presets::is_rdna4_experimental(&self.gpu_presets[self.gpu_idx].slug) { ui.label(""); ui.vertical(|ui| { diff --git a/miner-panel/src/ui_settings_tab.rs b/miner-panel/src/ui_settings_tab.rs index 0b40b46..9cd8f94 100644 --- a/miner-panel/src/ui_settings_tab.rs +++ b/miner-panel/src/ui_settings_tab.rs @@ -221,7 +221,7 @@ impl MinerApp { ui.add( egui::TextEdit::singleline(&mut self.bid_min) .desired_width(160.0) - .hint_text("1:0"), + .hint_text("1"), ); ui.end_row(); @@ -229,7 +229,7 @@ impl MinerApp { ui.add( egui::TextEdit::singleline(&mut self.bid_max) .desired_width(160.0) - .hint_text("31:0"), + .hint_text("31"), ); ui.end_row(); diff --git a/x16rs-cuda/cuda/ocl_compat.cuh b/x16rs-cuda/cuda/ocl_compat.cuh index 904a2f8..d766759 100644 --- a/x16rs-cuda/cuda/ocl_compat.cuh +++ b/x16rs-cuda/cuda/ocl_compat.cuh @@ -81,10 +81,17 @@ static __device__ __forceinline__ ulong rotate(ulong x, uint n) { #define X16RS_PRAGMA_UNROLL_8 _Pragma("unroll 8") #define X16RS_PRAGMA_UNROLL_4 _Pragma("unroll 4") -#define ALIGN8 __align__(8) -#define ALIGN __align__(16) -#define ALIGN32 __align__(32) -#define ALIGN64 __align__(64) +// Alignment here is a performance hint only: the kernels access these buffers +// element-wise (h1/h4/h8, table[i]) and never do vector/uint4 loads that would require +// greater-than-natural alignment. The spelling nvcc accepts for an alignment attribute +// differs by host compiler — Linux uses __attribute__((aligned(N))), but Windows MSVC +// rejects it in these positions and wants __declspec(align(N)) with different placement. +// To compile on BOTH toolchains, make ALIGN a no-op under CUDA; natural alignment keeps +// results byte-identical. (util.cl keeps the __attribute__ versions for OpenCL builds.) +#define ALIGN8 +#define ALIGN +#define ALIGN32 +#define ALIGN64 // Do NOT redefine __attribute__ wholesale. On modern nvcc (12.x, Linux) CUDA's // own __global__/__device__ qualifiers expand THROUGH __attribute__, so stripping diff --git a/x16rs/opencl/util.cl b/x16rs/opencl/util.cl index 62a66df..0ad3744 100644 --- a/x16rs/opencl/util.cl +++ b/x16rs/opencl/util.cl @@ -9,10 +9,15 @@ #define X16RS_PRAGMA_UNROLL_4 #endif +// Under CUDA these are made no-ops by ocl_compat.cuh (Windows nvcc/MSVC rejects the +// __attribute__((aligned)) spelling). Only (re)define the OpenCL attribute versions when +// not building for CUDA, so OpenCL builds are byte-identical to before. +#ifndef __CUDA__ #define ALIGN8 __attribute__((aligned(8))) #define ALIGN __attribute__((aligned(16))) #define ALIGN32 __attribute__((aligned(32))) #define ALIGN64 __attribute__((aligned(64))) +#endif // Alignment qualifier for *function parameters*. C++/nvcc forbids an alignment // specifier on a parameter (an array param decays to a pointer), whereas OpenCL C From fe2a7f656d7c40b47bb60d8cd7b51dea5a064a0d Mon Sep 17 00:00:00 2001 From: Moskyera Date: Wed, 22 Jul 2026 02:29:50 +0200 Subject: [PATCH 03/74] feat(panel): backend dropdown, drop RDNA4 notes, de-em-dash UI text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the NVIDIA "Use CUDA" checkbox with an explicit "Backend: OpenCL / CUDA" dropdown so it is clear the default is OpenCL (label_use_cuda -> label_backend across all 9 locales). - Remove the RX 9070 XT / RDNA4 "how it works" note from the settings and dashboard panels, plus the now-dead gpu_rdna4_badge/gpu_rdna4_hint strings, the unused is_rdna4_experimental helper, and the unused DashboardDetails.gpu_slug field. - Replace em-dash separators (" — ") with ": " throughout the panel's user-facing text (274 occurrences across 9 files). cargo test -p miner-panel: 52 + 2 passed, no warnings. Co-Authored-By: Claude Opus 4.8 --- miner-panel/src/config.rs | 6 +- miner-panel/src/dashboard.rs | 16 -- miner-panel/src/hacash_config.rs | 2 +- miner-panel/src/help_options.rs | 232 ++++++++++---------- miner-panel/src/i18n.rs | 314 +++++++++++++--------------- miner-panel/src/main.rs | 4 +- miner-panel/src/platform.rs | 2 +- miner-panel/src/presets.rs | 8 +- miner-panel/src/stats_poll.rs | 2 +- miner-panel/src/ui_dashboard_tab.rs | 1 - miner-panel/src/ui_settings.rs | 35 ++-- 11 files changed, 285 insertions(+), 337 deletions(-) diff --git a/miner-panel/src/config.rs b/miner-panel/src/config.rs index 854d937..2a35975 100644 --- a/miner-panel/src/config.rs +++ b/miner-panel/src/config.rs @@ -228,7 +228,7 @@ pub fn load_panel_ini(path: &Path) -> LoadedPanelIni { } } -/// Load user preferences from ini. Does not load work_groups / unit_size — those come from +/// Load user preferences from ini. Does not load work_groups / unit_size: those come from /// `resolve_panel_tuning` unless benchmark results are applied separately. pub fn apply_loaded_ini( loaded: &LoadedPanelIni, @@ -438,7 +438,7 @@ pub fn write_poworker_config(path: &Path, s: &PanelSettings) -> std::io::Result< let cpu_assist = !cpu_only && s.cpu.supervene > 0; let (wg, us, profile) = resolve_ini_tuning(s); let body = format!( - r"; Generated by miner-panel — do not edit by hand; use the panel UI. + r"; Generated by miner-panel: do not edit by hand; use the panel UI. connect = {connect} supervene = {sv} nonce_max = 4294967295 @@ -489,7 +489,7 @@ pub fn write_diaworker_config(path: &Path, s: &PanelSettings) -> std::io::Result // selecting a GPU for HAC can never leak an experimental GPU path into HACD. let supervene = s.cpu.supervene.max(1); let body = format!( - r"; Generated by miner-panel (HACD / diamond mining) — CPU/full-node only. + r"; Generated by miner-panel (HACD / diamond mining): CPU/full-node only. connect = {connect} supervene = {sv} diff --git a/miner-panel/src/dashboard.rs b/miner-panel/src/dashboard.rs index 9b5c6ff..212a904 100644 --- a/miner-panel/src/dashboard.rs +++ b/miner-panel/src/dashboard.rs @@ -3,7 +3,6 @@ use eframe::egui; use crate::i18n::Strings; use crate::mining_kind::MiningKind; -use crate::presets; use crate::theme; pub struct DashboardDetails<'a> { @@ -11,7 +10,6 @@ pub struct DashboardDetails<'a> { pub stats: &'a MiningStatsSnapshot, pub cpu_label: &'a str, pub gpu_label: &'a str, - pub gpu_slug: &'a str, pub connect_display: String, pub wallet_display: String, pub opencl_display: String, @@ -92,20 +90,6 @@ fn show_hac_details(ui: &mut egui::Ui, d: &DashboardDetails<'_>) { theme::show_detail_row(ui, d.t.dash_detail_gpu, d.gpu_label); ui.end_row(); - if presets::is_rdna4_experimental(d.gpu_slug) { - ui.label( - egui::RichText::new(d.t.gpu_rdna4_badge) - .color(theme::colors::GOLD) - .size(12.0), - ); - ui.label( - egui::RichText::new(d.t.gpu_rdna4_hint) - .color(theme::colors::TEXT_MUTED) - .size(11.0), - ); - ui.end_row(); - } - theme::show_detail_row(ui, d.t.dash_detail_connect, &d.connect_display); theme::show_detail_row(ui, d.t.dash_detail_wallet, &d.wallet_display); ui.end_row(); diff --git a/miner-panel/src/hacash_config.rs b/miner-panel/src/hacash_config.rs index e73dfd4..fbb067e 100644 --- a/miner-panel/src/hacash_config.rs +++ b/miner-panel/src/hacash_config.rs @@ -106,7 +106,7 @@ pub fn read_diamond_miner(path: &Path) -> DiamondMinerSettings { return DiamondMinerSettings { // Bid amounts are plain HAC (mei/decimal): "1" = 1 HAC, "0.5" = half a // HAC. The colon form "X:Y" is coin(mantissa X, unit Y), so "1:0" is - // 10^-248 HAC (dust), NOT 1 HAC — do not use it here. + // 10^-248 HAC (dust), NOT 1 HAC: do not use it here. bid_min: "1".to_string(), bid_max: "31".to_string(), bid_step: "0.5".to_string(), diff --git a/miner-panel/src/help_options.rs b/miner-panel/src/help_options.rs index 25fce82..ee2abed 100644 --- a/miner-panel/src/help_options.rs +++ b/miner-panel/src/help_options.rs @@ -16,92 +16,92 @@ 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 — HAC: local fullnode or pool RPC. HACD: local or shared LAN fullnode.", - "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 = 1 HAC).", + "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: HAC: local fullnode or pool RPC. HACD: local or shared LAN fullnode.", + "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 = 1 HAC).", ], }, HelpSection { - title: "poworker.config.ini / diaworker.config.ini — [default]", + 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).", + "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 / HAC only)", lines: &[ - "use_opencl — true = HAC GPU mining with OpenCL (AMD/NVIDIA/Intel; no CUDA). HACD remains false.", - "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).", + "use_opencl: true = HAC GPU mining with OpenCL (AMD/NVIDIA/Intel; no CUDA). HACD remains false.", + "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 — thermal throttle above this temp (0 = off).", - "throttle_work_groups — target work_groups when hot; must be below full load (panel writes half of WG).", - "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).", + "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: thermal throttle above this temp (0 = off).", + "throttle_work_groups: target work_groups when hot; must be below full load (panel writes half of WG).", + "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)", + title: "hacash.config.ini: [miner] (HAC)", lines: &[ - "enable — true for block mining reward.", - "reward — wallet address receiving block rewards.", + "enable: true for block mining reward.", + "reward: wallet address receiving block rewards.", ], }, HelpSection { - title: "hacash.config.ini — [diamondminer] (HACD)", + 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 = 1 HAC).", + "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 = 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).", + "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).", ], }, ]; @@ -110,92 +110,92 @@ 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 — HAC: solo fullnode ή pool RPC. HACD: τοπικό ή κοινό LAN fullnode.", - "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 = 1 HAC).", + "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: HAC: solo fullnode ή pool RPC. HACD: τοπικό ή κοινό LAN fullnode.", + "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 = 1 HAC).", ], }, HelpSection { - title: "poworker.config.ini / diaworker.config.ini — [default]", + 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).", + "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 / μόνο HAC)", lines: &[ - "use_opencl — true = HAC GPU mining με OpenCL (AMD/NVIDIA/Intel· όχι CUDA). Στο HACD μένει false.", - "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).", + "use_opencl: true = HAC GPU mining με OpenCL (AMD/NVIDIA/Intel· όχι CUDA). Στο HACD μένει false.", + "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 — thermal throttle πάνω από αυτή τη θερμοκρ. (0 = off).", - "throttle_work_groups — στόχος work_groups όταν ζεσταίνεται· κάτω από full load (panel = μισό WG).", - "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).", + "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: thermal throttle πάνω από αυτή τη θερμοκρ. (0 = off).", + "throttle_work_groups: στόχος work_groups όταν ζεσταίνεται· κάτω από full load (panel = μισό WG).", + "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)", + title: "hacash.config.ini: [miner] (HAC)", lines: &[ - "enable — true για block mining reward.", - "reward — διεύθυνση wallet για block rewards.", + "enable: true για block mining reward.", + "reward: διεύθυνση wallet για block rewards.", ], }, HelpSection { - title: "hacash.config.ini — [diamondminer] (HACD)", + 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 = 1 HAC).", + "enable: true για αυτόματα diamond bids.", + "reward: PRIVAKEY (3x...) για diamond rewards.", + "bid_password: κωδικός wallet για bids.", + "bid_min / bid_max / bid_step: εύρος bid σε mei (1 = 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).", + "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).", ], }, ]; diff --git a/miner-panel/src/i18n.rs b/miner-panel/src/i18n.rs index 851c0a2..728d732 100644 --- a/miner-panel/src/i18n.rs +++ b/miner-panel/src/i18n.rs @@ -90,7 +90,7 @@ pub struct Strings { pub settings_intro: &'static str, pub label_cpu: &'static str, pub label_gpu: &'static str, - pub label_use_cuda: &'static str, + pub label_backend: &'static str, pub label_mode: &'static str, pub mode_eco: &'static str, pub mode_profit: &'static str, @@ -177,8 +177,6 @@ pub struct Strings { pub help_hardware_note: &'static str, pub help_options_title: &'static str, pub no_gpu: &'static str, - pub gpu_rdna4_badge: &'static str, - pub gpu_rdna4_hint: &'static str, pub label_language: &'static str, pub label_currency: &'static str, } @@ -236,23 +234,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "Settings", tab_dashboard: "Dashboard", tab_help: "Help", - ready_status: "Ready — pick CPU/GPU and press Start.", + 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:", + 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:", + 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.", + settings_intro: "Pick your CPU and GPU: the app configures everything automatically.", label_cpu: "Processor (CPU):", label_gpu: "Graphics card (GPU):", - label_use_cuda: "Use CUDA (NVIDIA)", + label_backend: "Backend:", label_mode: "Mode:", mode_eco: "Eco (less power)", mode_profit: "Profit balance (recommended)", @@ -268,7 +266,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "Bid step (HAC):", bid_hint: "Format: mei e.g. 1 = 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:", + 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)", @@ -288,7 +286,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "Stop", mining_status: "MINING", stopped_status: "STOPPED", - paused_unprofitable: "Paused — not profitable", + paused_unprofitable: "Paused: not profitable", stat_hashrate: "Hashrate", stat_hac_day: "HAC / day", stat_power: "Power (estimate)", @@ -315,11 +313,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "Stats updated", dash_detail_stats_status: "Miner report", dash_detail_diamond: "Diamond #", - dash_no_data: "—", + dash_no_data: "-", 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.", + benchmark_done: "Benchmark done: best profile applied.", btn_start: "▶ Start", btn_stop_icon: "■ Stop", help_title: "3 steps for beginners:", @@ -328,19 +326,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_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 = 1 HAC).", - help_hacd_step4: "4. Save & Start — diaworker mines; fullnode auto-bids via [diamondminer].", + 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: "HAC GPU mining uses OpenCL only (AMD/NVIDIA/Intel) — no CUDA. HACD is CPU/full-node only.", + help_hardware_note: "HAC GPU mining uses OpenCL only (AMD/NVIDIA/Intel): no CUDA. HACD is CPU/full-node only.", help_options_title: "Options reference (panel + .ini + executables)", no_gpu: "No GPU", - gpu_rdna4_badge: "RDNA4 — validated safe auto-tuning", - gpu_rdna4_hint: "RX 9070 XT uses validated safe ranges (work_groups 32–64, unit_size 32–64). Auto Tune measures the best stable point for the selected mode; no manual INI edits are needed.", label_language: "Language:", label_currency: "Currency:", }, @@ -350,23 +346,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "Ρυθμίσεις", tab_dashboard: "Dashboard", tab_help: "Βοήθεια", - ready_status: "Έτοιμο — διάλεξε CPU/GPU και πάτα Έναρξη.", + ready_status: "Έτοιμο: διάλεξε CPU/GPU και πάτα Έναρξη.", saved_prefix: "Αποθηκεύτηκε:", save_error_prefix: "Σφάλμα αποθήκευσης:", - poworker_not_found: "Δεν βρέθηκε poworker.exe — κάνε build πρώτα.\nΑναζήτηση:", + 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:", + 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: "Διάλεξε τον επεξεργαστή και την κάρτα σου — το πρόγραμμα φτιάχνει τα πάντα αυτόματα.", + settings_intro: "Διάλεξε τον επεξεργαστή και την κάρτα σου: το πρόγραμμα φτιάχνει τα πάντα αυτόματα.", label_cpu: "Επεξεργαστής (CPU):", label_gpu: "Κάρτα γραφικών (GPU):", - label_use_cuda: "Χρήση CUDA (NVIDIA)", + label_backend: "Backend:", label_mode: "Λειτουργία:", mode_eco: "Οικονομικό (λιγότερο ρεύμα)", mode_profit: "Ισορροπία κέρδους (προτείνεται)", @@ -382,7 +378,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "Βήμα bid (HAC):", bid_hint: "Μορφή: mei π.χ. 1 = 1 HAC. Το fullnode χρειάζεται [diamondminer].", hacd_wallet_hint: "Διεύθυνση PRIVAKEY (3x...) για ανταμοιβές diamond.", - diaworker_not_found: "Δεν βρέθηκε diaworker.exe — κάνε build πρώτα.\nΑναζήτηση:", + diaworker_not_found: "Δεν βρέθηκε diaworker.exe: κάνε build πρώτα.\nΑναζήτηση:", bid_password_required: "Βάλε κωδικό bid account για HACD mining.", label_connect_mode: "Σύνδεση:", connect_solo: "Solo (fullnode)", @@ -402,7 +398,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "Διακοπή", mining_status: "MINING", stopped_status: "ΣΤΑΜΑΤΗΜΕΝΟ", - paused_unprofitable: "Παύση — δεν αξίζει οικονομικά", + paused_unprofitable: "Παύση: δεν αξίζει οικονομικά", stat_hashrate: "Hashrate", stat_hac_day: "HAC / μέρα", stat_power: "Ρεύμα (εκτίμηση)", @@ -429,11 +425,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "Ενημέρωση stats", dash_detail_stats_status: "Αναφορά miner", dash_detail_diamond: "Diamond #", - dash_no_data: "—", + dash_no_data: "-", label_max_temp: "Μέγ. θερμ. GPU:", label_pause_unprofitable: "Παύση αν δεν αξίζει", benchmark_running: "Benchmark GPU profiles (~90s, fine sweep)...", - benchmark_done: "Benchmark έτοιμο — εφαρμόστηκε το καλύτερο profile.", + benchmark_done: "Benchmark έτοιμο: εφαρμόστηκε το καλύτερο profile.", btn_start: "▶ Έναρξη", btn_stop_icon: "■ Διακοπή", help_title: "3 βήματα για αρχάριους:", @@ -442,19 +438,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_step2: "2. Το reward wallet πρέπει να είναι PRIVAKEY (3x...): όχι legacy 1x.", help_hacd_step3: "3. Ρυθμίσεις → HACD → wallet, κωδικός bid, min/max/step (1 = 1 HAC).", - help_hacd_step4: "4. Αποθήκευση & Έναρξη — diaworker κάνει mine· fullnode κάνει bid στο [diamondminer].", + help_hacd_step4: "4. Αποθήκευση & Έναρξη: diaworker κάνει mine· fullnode κάνει bid στο [diamondminer].", help_hacd_step5: "5. Κάνε restart το fullnode μετά από αλλαγή wallet ή bid.", - help_hardware_note: "Το HAC GPU mining χρησιμοποιεί μόνο OpenCL (AMD/NVIDIA/Intel) — ποτέ CUDA. Το HACD είναι αποκλειστικά CPU/full-node.", + help_hardware_note: "Το HAC GPU mining χρησιμοποιεί μόνο OpenCL (AMD/NVIDIA/Intel): ποτέ CUDA. Το HACD είναι αποκλειστικά CPU/full-node.", help_options_title: "Αναφορά επιλογών (panel + .ini + executables)", no_gpu: "Χωρίς GPU", - gpu_rdna4_badge: "RDNA4 — επιβεβαιωμένο ασφαλές Auto Tune", - gpu_rdna4_hint: "Η RX 9070 XT χρησιμοποιεί επιβεβαιωμένα ασφαλή όρια (work_groups 32–64, unit_size 32–64). Το Auto Tune μετρά το καλύτερο σταθερό σημείο για το επιλεγμένο mode, χωρίς χειροκίνητο INI.", label_language: "Γλώσσα:", label_currency: "Νόμισμα:", }, @@ -464,23 +458,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "Ayarlar", tab_dashboard: "Panel", tab_help: "Yardım", - ready_status: "Hazır — CPU/GPU seçin ve Başlat'a basın.", + 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:", + 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:", + 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.", + 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_use_cuda: "CUDA kullan (NVIDIA)", + label_backend: "Backend:", label_mode: "Mod:", mode_eco: "Ekonomik (daha az güç)", mode_profit: "Kâr dengesi (önerilen)", @@ -496,7 +490,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "Teklif adımı (HAC):", bid_hint: "Format: mei örn. 1 = 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:", + 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)", @@ -516,7 +510,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "Durdur", mining_status: "MADENCİLİK", stopped_status: "DURDU", - paused_unprofitable: "Duraklatıldı — kârlı değil", + paused_unprofitable: "Duraklatıldı: kârlı değil", stat_hashrate: "Hashrate", stat_hac_day: "HAC / gün", stat_power: "Güç (tahmini)", @@ -543,11 +537,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "İstatistik güncellemesi", dash_detail_stats_status: "Miner raporu", dash_detail_diamond: "Elmas #", - dash_no_data: "—", + dash_no_data: "-", 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ı.", + 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:", @@ -556,19 +550,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_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 = 1 HAC).", - help_hacd_step4: "4. Kaydet & Başlat — diaworker madencilik; fullnode [diamondminer] ile teklif verir.", + 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: "HAC GPU madenciliği yalnızca OpenCL kullanır (AMD/NVIDIA/Intel) — CUDA yok. HACD yalnızca CPU/full-node kullanır.", + help_hardware_note: "HAC GPU madenciliği yalnızca OpenCL kullanır (AMD/NVIDIA/Intel): CUDA yok. HACD yalnızca CPU/full-node kullanır.", help_options_title: "Seçenekler referansı (panel + .ini + exe)", no_gpu: "GPU yok", - gpu_rdna4_badge: "RDNA4 — doğrulanmış güvenli otomatik ayar", - gpu_rdna4_hint: "RX 9070 XT doğrulanmış güvenli aralıkları kullanır (work_groups 32–64, unit_size 32–64). Auto Tune seçilen mod için en iyi kararlı noktayı ölçer; elle INI düzenlemek gerekmez.", label_language: "Dil:", label_currency: "Para birimi:", }, @@ -578,23 +570,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "设置", tab_dashboard: "仪表盘", tab_help: "帮助", - ready_status: "就绪 — 选择 CPU/GPU 后点击开始。", + ready_status: "就绪: 选择 CPU/GPU 后点击开始。", saved_prefix: "已保存:", save_error_prefix: "保存错误:", - poworker_not_found: "未找到 poworker.exe — 请先编译。\n搜索路径:", + 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 旁边:", + 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 — 程序将自动完成所有配置。", + settings_intro: "选择您的 CPU 和 GPU: 程序将自动完成所有配置。", label_cpu: "处理器 (CPU):", label_gpu: "显卡 (GPU):", - label_use_cuda: "使用 CUDA (NVIDIA)", + label_backend: "后端:", label_mode: "模式:", mode_eco: "节能(低功耗)", mode_profit: "利润平衡(推荐)", @@ -610,7 +602,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "竞价步长 (HAC):", bid_hint: "格式: mei 如 1 = 1 HAC。全节点需启用 [diamondminer]。", hacd_wallet_hint: "钻石奖励用 PRIVAKEY 地址 (3x...)。", - diaworker_not_found: "未找到 diaworker.exe — 请先编译。\n搜索路径:", + diaworker_not_found: "未找到 diaworker.exe: 请先编译。\n搜索路径:", bid_password_required: "请输入 HACD 竞价账户密码。", label_connect_mode: "连接:", connect_solo: "Solo (全节点)", @@ -630,7 +622,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "停止", mining_status: "挖矿中", stopped_status: "已停止", - paused_unprofitable: "已暂停 — 无利可图", + paused_unprofitable: "已暂停: 无利可图", stat_hashrate: "算力", stat_hac_day: "HAC / 天", stat_power: "功耗(估算)", @@ -657,11 +649,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "统计更新", dash_detail_stats_status: "矿工报告", dash_detail_diamond: "钻石 #", - dash_no_data: "—", + dash_no_data: "-", label_max_temp: "GPU 最高温度:", label_pause_unprofitable: "无利润时暂停", benchmark_running: "正在测试 GPU 配置 (~45秒)...", - benchmark_done: "调优完成 — 已应用最佳配置。", + benchmark_done: "调优完成: 已应用最佳配置。", btn_start: "▶ 开始", btn_stop_icon: "■ 停止", help_title: "新手三步:", @@ -670,19 +662,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_step2: "2. 奖励钱包必须是 PRIVAKEY (3x...): 不是 legacy 1x。", help_hacd_step3: "3. 设置 → HACD → 钱包、竞价密码、min/max/step(1 = 1 HAC)。", - help_hacd_step4: "4. 保存并启动 — diaworker 挖矿;全节点通过 [diamondminer] 自动竞价。", + help_hacd_step4: "4. 保存并启动: diaworker 挖矿;全节点通过 [diamondminer] 自动竞价。", help_hacd_step5: "5. 更改钱包或竞价后请重启全节点。", help_hardware_note: "HAC GPU 挖矿仅使用 OpenCL(AMD/NVIDIA/Intel),不使用 CUDA。HACD 仅支持 CPU/全节点挖矿。", help_options_title: "选项参考(面板 + .ini + 可执行文件)", no_gpu: "无 GPU", - gpu_rdna4_badge: "RDNA4 — 已验证的安全自动调优", - gpu_rdna4_hint: "RX 9070 XT 使用已验证的安全范围(work_groups 32–64,unit_size 32–64)。Auto Tune 会为所选模式实测最佳稳定点,无需手动修改 INI。", label_language: "语言:", label_currency: "货币:", }, @@ -692,23 +682,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "設定", tab_dashboard: "ダッシュボード", tab_help: "ヘルプ", - ready_status: "準備完了 — CPU/GPU を選んで開始を押してください。", + ready_status: "準備完了: CPU/GPU を選んで開始を押してください。", saved_prefix: "保存しました:", save_error_prefix: "保存エラー:", - poworker_not_found: "poworker.exe が見つかりません — 先にビルドしてください。\n検索:", + 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 の横に配置:", + 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 を選ぶだけ — 自動ですべて設定します。", + settings_intro: "CPU と GPU を選ぶだけ: 自動ですべて設定します。", label_cpu: "プロセッサ (CPU):", label_gpu: "グラフィックカード (GPU):", - label_use_cuda: "CUDA を使用 (NVIDIA)", + label_backend: "バックエンド:", label_mode: "モード:", mode_eco: "エコ(低消費電力)", mode_profit: "利益バランス(推奨)", @@ -724,7 +714,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "入札ステップ (HAC):", bid_hint: "形式: mei 例 1 = 1 HAC。フルノードに [diamondminer] が必要。", hacd_wallet_hint: "ダイヤ報酬用 PRIVAKEY アドレス (3x...)。", - diaworker_not_found: "diaworker.exe が見つかりません — 先にビルド。\n検索:", + diaworker_not_found: "diaworker.exe が見つかりません: 先にビルド。\n検索:", bid_password_required: "HACD 用の入札パスワードを入力してください。", label_connect_mode: "接続:", connect_solo: "Solo (フルノード)", @@ -744,7 +734,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "停止", mining_status: "マイニング中", stopped_status: "停止中", - paused_unprofitable: "一時停止 — 採算が合いません", + paused_unprofitable: "一時停止: 採算が合いません", stat_hashrate: "ハッシュレート", stat_hac_day: "HAC / 日", stat_power: "消費電力(推定)", @@ -771,11 +761,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "統計更新", dash_detail_stats_status: "マイナー報告", dash_detail_diamond: "ダイヤ #", - dash_no_data: "—", + dash_no_data: "-", label_max_temp: "GPU 最高温度:", label_pause_unprofitable: "非採算時は一時停止", benchmark_running: "GPU プロファイルをテスト中 (~45秒)...", - benchmark_done: "調整完了 — 最適プロファイルを適用。", + benchmark_done: "調整完了: 最適プロファイルを適用。", btn_start: "▶ 開始", btn_stop_icon: "■ 停止", help_title: "初心者向け 3 ステップ:", @@ -784,19 +774,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_step2: "2. 報酬ウォレットは PRIVAKEY (3x...) 必須: legacy 1x は不可。", help_hacd_step3: "3. 設定 → HACD → ウォレット、入札パスワード、min/max/step(1 = 1 HAC)。", - help_hacd_step4: "4. 保存して開始 — diaworker がマイニング、fullnode が [diamondminer] で入札。", + help_hacd_step4: "4. 保存して開始: diaworker がマイニング、fullnode が [diamondminer] で入札。", help_hacd_step5: "5. ウォレットまたは入札変更後は fullnode を再起動。", help_hardware_note: "HAC の GPU マイニングは OpenCL のみ(AMD/NVIDIA/Intel)、CUDA は不使用。HACD は CPU/フルノード専用です。", help_options_title: "オプション一覧(パネル + .ini + 実行ファイル)", no_gpu: "GPU なし", - gpu_rdna4_badge: "RDNA4 — 検証済みの安全な自動調整", - gpu_rdna4_hint: "RX 9070 XT は検証済みの安全範囲(work_groups 32–64、unit_size 32–64)を使用します。Auto Tune が選択モードの最良で安定した点を実測し、INI の手動編集は不要です。", label_language: "言語:", label_currency: "通貨:", }, @@ -806,23 +794,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "Ajustes", tab_dashboard: "Panel", tab_help: "Ayuda", - ready_status: "Listo — elige CPU/GPU y pulsa Iniciar.", + 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:", + 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:", + 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.", + settings_intro: "Elige tu CPU y GPU: la app configura todo automáticamente.", label_cpu: "Procesador (CPU):", label_gpu: "Tarjeta gráfica (GPU):", - label_use_cuda: "Usar CUDA (NVIDIA)", + label_backend: "Backend:", label_mode: "Modo:", mode_eco: "Eco (menos consumo)", mode_profit: "Equilibrio de beneficio (recomendado)", @@ -838,7 +826,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "Paso de puja (HAC):", bid_hint: "Formato: mei ej. 1 = 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:", + 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)", @@ -858,7 +846,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "Detener", mining_status: "MINANDO", stopped_status: "DETENIDO", - paused_unprofitable: "Pausado — no rentable", + paused_unprofitable: "Pausado: no rentable", stat_hashrate: "Hashrate", stat_hac_day: "HAC / día", stat_power: "Consumo (estimado)", @@ -885,11 +873,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "Stats actualizados", dash_detail_stats_status: "Informe del miner", dash_detail_diamond: "Diamante #", - dash_no_data: "—", + dash_no_data: "-", 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.", + benchmark_done: "Listo: mejor perfil aplicado.", btn_start: "▶ Iniciar", btn_stop_icon: "■ Detener", help_title: "3 pasos para principiantes:", @@ -898,19 +886,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_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 = 1 HAC).", - help_hacd_step4: "4. Guardar e Iniciar — diaworker mina; fullnode puja vía [diamondminer].", + 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: "La minería GPU de HAC usa solo OpenCL (AMD/NVIDIA/Intel), nunca CUDA. HACD es solo CPU/full-node.", help_options_title: "Referencia de opciones (panel + .ini + ejecutables)", no_gpu: "Sin GPU", - gpu_rdna4_badge: "RDNA4 — ajuste automático seguro y validado", - gpu_rdna4_hint: "RX 9070 XT usa rangos seguros validados (work_groups 32–64, unit_size 32–64). Auto Tune mide el mejor punto estable para el modo elegido, sin editar el INI manualmente.", label_language: "Idioma:", label_currency: "Moneda:", }, @@ -920,23 +906,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "Réglages", tab_dashboard: "Tableau de bord", tab_help: "Aide", - ready_status: "Prêt — choisissez CPU/GPU et appuyez sur Démarrer.", + 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é :", + 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:", + 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.", + settings_intro: "Choisissez votre CPU et GPU: l'app configure tout automatiquement.", label_cpu: "Processeur (CPU) :", label_gpu: "Carte graphique (GPU) :", - label_use_cuda: "Utiliser CUDA (NVIDIA)", + label_backend: "Backend :", label_mode: "Mode :", mode_eco: "Éco (moins de consommation)", mode_profit: "Équilibre profit (recommandé)", @@ -952,7 +938,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "Pas d'enchère (HAC) :", bid_hint: "Format : mei ex. 1 = 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é :", + 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)", @@ -972,7 +958,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "Arrêter", mining_status: "MINAGE", stopped_status: "ARRÊTÉ", - paused_unprofitable: "Pause — non rentable", + paused_unprofitable: "Pause: non rentable", stat_hashrate: "Hashrate", stat_hac_day: "HAC / jour", stat_power: "Puissance (estimation)", @@ -999,11 +985,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "Stats mises à jour", dash_detail_stats_status: "Rapport miner", dash_detail_diamond: "Diamant #", - dash_no_data: "—", + dash_no_data: "-", 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é.", + benchmark_done: "Terminé: meilleur profil appliqué.", btn_start: "▶ Démarrer", btn_stop_icon: "■ Arrêter", help_title: "3 étapes pour débutants :", @@ -1012,19 +998,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_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 = 1 HAC).", - help_hacd_step4: "4. Enregistrer & Démarrer — diaworker mine ; fullnode enchérit via [diamondminer].", + 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: "Le minage GPU HAC utilise uniquement OpenCL (AMD/NVIDIA/Intel), jamais CUDA. HACD est uniquement CPU/full-node.", help_options_title: "Référence des options (panel + .ini + exécutables)", no_gpu: "Sans GPU", - gpu_rdna4_badge: "RDNA4 — réglage automatique sûr et validé", - gpu_rdna4_hint: "La RX 9070 XT utilise des plages sûres validées (work_groups 32–64, unit_size 32–64). Auto Tune mesure le meilleur point stable pour le mode choisi, sans modification manuelle du fichier INI.", label_language: "Langue :", label_currency: "Devise :", }, @@ -1034,23 +1018,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "ตั้งค่า", tab_dashboard: "แดชบอร์ด", tab_help: "ช่วยเหลือ", - ready_status: "พร้อม — เลือก CPU/GPU แล้วกดเริ่ม", + ready_status: "พร้อม: เลือก CPU/GPU แล้วกดเริ่ม", saved_prefix: "บันทึกแล้ว:", save_error_prefix: "ข้อผิดพลาดการบันทึก:", - poworker_not_found: "ไม่พบ poworker.exe — กรุณา build ก่อน\nค้นหา:", + 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:", + 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 ของคุณ — โปรแกรมตั้งค่าทุกอย่างให้อัตโนมัติ", + settings_intro: "เลือก CPU และ GPU ของคุณ: โปรแกรมตั้งค่าทุกอย่างให้อัตโนมัติ", label_cpu: "ซีพียู (CPU):", label_gpu: "การ์ดจอ (GPU):", - label_use_cuda: "ใช้ CUDA (NVIDIA)", + label_backend: "แบ็กเอนด์:", label_mode: "โหมด:", mode_eco: "ประหยัด (ใช้ไฟน้อย)", mode_profit: "สมดุลกำไร (แนะนำ)", @@ -1066,7 +1050,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "ขั้นประมูล (HAC):", bid_hint: "รูปแบบ: mei เช่น 1 = 1 HAC ต้องมี [diamondminer] ใน fullnode", hacd_wallet_hint: "ที่อยู่ PRIVAKEY (3x...) สำหรับรางวัลเพชร", - diaworker_not_found: "ไม่พบ diaworker.exe — กรุณา build ก่อน\nค้นหา:", + diaworker_not_found: "ไม่พบ diaworker.exe: กรุณา build ก่อน\nค้นหา:", bid_password_required: "กรอกรหัสบัญชีประมูลสำหรับ HACD", label_connect_mode: "การเชื่อมต่อ:", connect_solo: "Solo (fullnode)", @@ -1086,7 +1070,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "หยุด", mining_status: "กำลังขุด", stopped_status: "หยุดแล้ว", - paused_unprofitable: "หยุดชั่วคราว — ไม่คุ้มค่า", + paused_unprofitable: "หยุดชั่วคราว: ไม่คุ้มค่า", stat_hashrate: "แฮชเรท", stat_hac_day: "HAC / วัน", stat_power: "กำลังไฟ (ประมาณ)", @@ -1113,11 +1097,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "อัปเดตสถิติ", dash_detail_stats_status: "รายงาน miner", dash_detail_diamond: "ไดมอนด์ #", - dash_no_data: "—", + dash_no_data: "-", label_max_temp: "อุณหภูมิ GPU สูงสุด:", label_pause_unprofitable: "หยุดถ้าไม่คุ้ม", benchmark_running: "กำลังทดสอบโปรไฟล์ GPU (~45 วิ)...", - benchmark_done: "เสร็จแล้ว — ใช้โปรไฟล์ที่ดีที่สุด", + benchmark_done: "เสร็จแล้ว: ใช้โปรไฟล์ที่ดีที่สุด", btn_start: "▶ เริ่ม", btn_stop_icon: "■ หยุด", help_title: "3 ขั้นตอนสำหรับมือใหม่:", @@ -1126,19 +1110,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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 = 1 HAC)", - help_hacd_step4: "4. บันทึกและเริ่ม — diaworker ขุด fullnode ประมูลผ่าน [diamondminer]", + help_hacd_step4: "4. บันทึกและเริ่ม: diaworker ขุด fullnode ประมูลผ่าน [diamondminer]", help_hacd_step5: "5. รีสตาร์ท fullnode หลังเปลี่ยนกระเป๋าหรือการประมูล", help_hardware_note: "การขุด HAC ด้วย GPU ใช้ OpenCL เท่านั้น (AMD/NVIDIA/Intel) ไม่ใช้ CUDA ส่วน HACD ใช้ CPU/full-node เท่านั้น", help_options_title: "คู่มือตัวเลือก (แผง + .ini + โปรแกรม)", no_gpu: "ไม่มี GPU", - gpu_rdna4_badge: "RDNA4 — ปรับอัตโนมัติอย่างปลอดภัยและผ่านการตรวจสอบ", - gpu_rdna4_hint: "RX 9070 XT ใช้ช่วงที่ปลอดภัยและผ่านการตรวจสอบแล้ว (work_groups 32–64, unit_size 32–64) Auto Tune จะวัดจุดที่เสถียรที่สุดสำหรับโหมดที่เลือก โดยไม่ต้องแก้ INI เอง", label_language: "ภาษา:", label_currency: "สกุลเงิน:", }, @@ -1148,23 +1130,23 @@ pub fn strings(lang: Lang) -> Strings { tab_settings: "Настройки", tab_dashboard: "Панель", tab_help: "Справка", - ready_status: "Готово — выберите CPU/GPU и нажмите Старт.", + ready_status: "Готово: выберите CPU/GPU и нажмите Старт.", saved_prefix: "Сохранено:", save_error_prefix: "Ошибка сохранения:", - poworker_not_found: "poworker.exe не найден — сначала соберите проект.\nПоиск:", + 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:", + 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 — программа настроит всё автоматически.", + settings_intro: "Выберите CPU и GPU: программа настроит всё автоматически.", label_cpu: "Процессор (CPU):", label_gpu: "Видеокарта (GPU):", - label_use_cuda: "Использовать CUDA (NVIDIA)", + label_backend: "Бэкенд:", label_mode: "Режим:", mode_eco: "Эко (меньше энергии)", mode_profit: "Баланс прибыли (рекомендуется)", @@ -1180,7 +1162,7 @@ pub fn strings(lang: Lang) -> Strings { label_bid_step: "Шаг ставки (HAC):", bid_hint: "Формат: mei напр. 1 = 1 HAC. Нужен [diamondminer] в fullnode.", hacd_wallet_hint: "PRIVAKEY адрес (3x...) для наград за алмазы.", - diaworker_not_found: "diaworker.exe не найден — сначала соберите.\nПоиск:", + diaworker_not_found: "diaworker.exe не найден: сначала соберите.\nПоиск:", bid_password_required: "Введите пароль bid-аккаунта для HACD.", label_connect_mode: "Подключение:", connect_solo: "Solo (fullnode)", @@ -1200,7 +1182,7 @@ pub fn strings(lang: Lang) -> Strings { btn_stop: "Остановить", mining_status: "МАЙНИНГ", stopped_status: "ОСТАНОВЛЕН", - paused_unprofitable: "Пауза — невыгодно", + paused_unprofitable: "Пауза: невыгодно", stat_hashrate: "Хешрейт", stat_hac_day: "HAC / день", stat_power: "Мощность (оценка)", @@ -1227,11 +1209,11 @@ pub fn strings(lang: Lang) -> Strings { dash_detail_last_update: "Обновление статистики", dash_detail_stats_status: "Отчёт майнера", dash_detail_diamond: "Алмаз #", - dash_no_data: "—", + dash_no_data: "-", label_max_temp: "Макс. темп. GPU:", label_pause_unprofitable: "Пауза если невыгодно", benchmark_running: "Тест профилей GPU (~45с)...", - benchmark_done: "Готово — применён лучший профиль.", + benchmark_done: "Готово: применён лучший профиль.", btn_start: "▶ Старт", btn_stop_icon: "■ Стоп", help_title: "3 шага для новичков:", @@ -1240,19 +1222,17 @@ pub fn strings(lang: Lang) -> Strings { 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_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_step2: "2. Кошелёк наград: PRIVAKEY (3x...), не legacy 1x.", help_hacd_step3: "3. Настройки → HACD → кошелёк, пароль ставок, min/max/step (1 = 1 HAC).", - help_hacd_step4: "4. Сохранить и Старт — diaworker майнит; fullnode ставит через [diamondminer].", + help_hacd_step4: "4. Сохранить и Старт: diaworker майнит; fullnode ставит через [diamondminer].", help_hacd_step5: "5. Перезапустите fullnode после смены кошелька или ставок.", help_hardware_note: "GPU-майнинг HAC использует только OpenCL (AMD/NVIDIA/Intel), без CUDA. HACD работает только на CPU/full-node.", help_options_title: "Справочник опций (панель + .ini + exe)", no_gpu: "Без GPU", - gpu_rdna4_badge: "RDNA4 — проверенная безопасная автонастройка", - gpu_rdna4_hint: "RX 9070 XT использует проверенные безопасные диапазоны (work_groups 32–64, unit_size 32–64). Auto Tune измеряет лучшую стабильную точку для выбранного режима; ручная правка INI не нужна.", label_language: "Язык:", label_currency: "Валюта:", }, diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs index 21652a0..788a8eb 100644 --- a/miner-panel/src/main.rs +++ b/miner-panel/src/main.rs @@ -252,7 +252,7 @@ impl MinerApp { } } let mut status_msg = if mining_kind == MiningKind::Hacd { - "HACD CPU miner ready — OpenCL is not used.".to_string() + "HACD CPU miner ready: OpenCL is not used.".to_string() } else if opencl_status.has_usable_device() { format!("OpenCL: {}", opencl_status.device_summary()) } else { @@ -529,7 +529,7 @@ impl MinerApp { } self.status_msg = match kind { MiningKind::Hac => "HAC OpenCL miner ready.".to_string(), - MiningKind::Hacd => "HACD CPU miner ready — OpenCL is not used.".to_string(), + MiningKind::Hacd => "HACD CPU miner ready: OpenCL is not used.".to_string(), }; save_mining_kind(&self.work_dir, kind); } diff --git a/miner-panel/src/platform.rs b/miner-panel/src/platform.rs index 2647e7a..aae24c1 100644 --- a/miner-panel/src/platform.rs +++ b/miner-panel/src/platform.rs @@ -1,4 +1,4 @@ -//! Cross-platform binary names — Windows keeps `.exe` first (unchanged behavior). +//! Cross-platform binary names: Windows keeps `.exe` first (unchanged behavior). use std::path::{Path, PathBuf}; use std::process::Command; diff --git a/miner-panel/src/presets.rs b/miner-panel/src/presets.rs index e6c1af4..0127550 100644 --- a/miner-panel/src/presets.rs +++ b/miner-panel/src/presets.rs @@ -13,7 +13,7 @@ pub struct GpuPreset { pub label: &'static str, pub slug: &'static str, pub profile: &'static str, - /// VRAM in GB — used for safe work_groups caps. + /// VRAM in GB: used for safe work_groups caps. pub vram_gb: u8, /// Typical board power (W) for kH/J and profit estimates. pub watts: f64, @@ -297,10 +297,6 @@ pub fn gpu_idx_for_opencl( gpu_idx_for_slug(gpus, preset) } -pub fn is_rdna4_experimental(slug: &str) -> bool { - gpu_arch::ArchLimits::for_panel_slug(slug).is_experimental() -} - /// True when the preset's profile is an NVIDIA GPU (where the optional CUDA backend applies). pub fn profile_is_nvidia(profile: &str) -> bool { gpu_arch::profile_vendor(profile) == gpu_arch::GpuVendor::Nvidia @@ -315,7 +311,7 @@ pub fn min_work_groups_for_gpu(slug: &str) -> u32 { gpu_arch::panel_min_work_groups(slug) } -/// Legacy helper — prefer `resolve_panel_tuning`. +/// Legacy helper: prefer `resolve_panel_tuning`. pub fn tuning_for_profile(profile: &str) -> (u32, u32) { profile_tuning(profile) } diff --git a/miner-panel/src/stats_poll.rs b/miner-panel/src/stats_poll.rs index 39853a3..439201c 100644 --- a/miner-panel/src/stats_poll.rs +++ b/miner-panel/src/stats_poll.rs @@ -176,7 +176,7 @@ impl MinerApp { pub(super) fn format_stats_age(ms: u64) -> String { if ms == 0 { - return "—".to_string(); + return "-".to_string(); } let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/miner-panel/src/ui_dashboard_tab.rs b/miner-panel/src/ui_dashboard_tab.rs index 05d34c2..5b12227 100644 --- a/miner-panel/src/ui_dashboard_tab.rs +++ b/miner-panel/src/ui_dashboard_tab.rs @@ -240,7 +240,6 @@ impl MinerApp { stats: s, cpu_label: self.cpu_label(self.cpu_idx), gpu_label: self.gpu_label(self.gpu_idx), - gpu_slug: &self.gpu_presets[self.gpu_idx].slug, connect_display, wallet_display, opencl_display, diff --git a/miner-panel/src/ui_settings.rs b/miner-panel/src/ui_settings.rs index c103795..2e8ae36 100644 --- a/miner-panel/src/ui_settings.rs +++ b/miner-panel/src/ui_settings.rs @@ -38,7 +38,7 @@ impl MinerApp { let selected = &self.cpu_presets[self.cpu_idx]; egui::ComboBox::from_id_salt("hacd_cpu") .selected_text(format!( - "{} — {} threads", + "{}: {} threads", selected.label, selected.supervene )) .width(400.0) @@ -49,7 +49,7 @@ impl MinerApp { &mut self.cpu_idx, i, format!( - "{} — {} threads", + "{}: {} threads", preset.label, preset.supervene ), ); @@ -98,28 +98,17 @@ impl MinerApp { } ui.end_row(); - // CUDA backend is NVIDIA-only and needs a `--features cuda` miner build. + // Mining backend selector, shown only for NVIDIA. CUDA needs a miner + // built with `--features cuda`; OpenCL is the default for everyone else. if presets::profile_is_nvidia(self.gpu_presets[self.gpu_idx].profile) { - theme::field_label(ui, ""); - ui.checkbox(&mut self.use_cuda, t.label_use_cuda); - ui.end_row(); - } - - if presets::is_rdna4_experimental(&self.gpu_presets[self.gpu_idx].slug) { - ui.label(""); - ui.vertical(|ui| { - ui.label( - egui::RichText::new(t.gpu_rdna4_badge) - .color(theme::colors::GOLD) - .strong() - .size(12.5), - ); - ui.label( - egui::RichText::new(t.gpu_rdna4_hint) - .color(theme::colors::TEXT_MUTED) - .size(11.5), - ); - }); + theme::field_label(ui, t.label_backend); + egui::ComboBox::from_id_salt("backend") + .selected_text(if self.use_cuda { "CUDA" } else { "OpenCL" }) + .width(400.0) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.use_cuda, false, "OpenCL"); + ui.selectable_value(&mut self.use_cuda, true, "CUDA"); + }); ui.end_row(); } From 037bde3b816b3198ee594c280850cdfecb62ef64 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Wed, 22 Jul 2026 03:45:53 +0200 Subject: [PATCH 04/74] fix(panel): always write the mainnet [node] section into hacash.config.ini The panel wrote hacash.config.ini with [miner]/[server]/[diamondminer] but no [node] section. A config created via the panel before START-MAINNET.bat had run therefore had no boot nodes and not_find_nodes defaulted off-network, so hacash started an ISOLATED LOCAL chain (height stayed near 0 -> x16rs repeat=1 -> an inflated ~280 MH/s that is not real mining). write_hac_miner_only and write_diamond_miner now ensure the mainnet [node] block (boots + not_find_nodes=false + fast_sync) when it is absent; an existing [node] is kept. Co-Authored-By: Claude Opus 4.8 --- miner-panel/src/hacash_config.rs | 67 +++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/miner-panel/src/hacash_config.rs b/miner-panel/src/hacash_config.rs index fbb067e..739a5c7 100644 --- a/miner-panel/src/hacash_config.rs +++ b/miner-panel/src/hacash_config.rs @@ -158,7 +158,7 @@ pub fn write_diamond_miner( d: &DiamondMinerSettings, rpc_port: Option, ) -> std::io::Result<()> { - let content = read_or_empty(path); + let content = ensure_mainnet_node_section(&read_or_empty(path)); let mut updated = upsert_section_fields( &content, "diamondminer", @@ -193,7 +193,7 @@ pub fn write_hac_miner_only( wallet: &str, rpc_port: Option, ) -> std::io::Result<()> { - let content = read_or_empty(path); + let content = ensure_mainnet_node_section(&read_or_empty(path)); let mut updated = upsert_miner_reward(&content, wallet); updated = upsert_section_fields(&updated, "diamondminer", &[("enable", "false")]); if let Some(port) = rpc_port { @@ -216,6 +216,23 @@ fn read_or_empty(path: &Path) -> String { std::fs::read_to_string(path).unwrap_or_default() } +/// Guarantee the fullnode joins MAINNET. Without a `[node]` section (boot nodes + +/// `not_find_nodes = false`) hacash starts an ISOLATED LOCAL chain: the height stays +/// near 0, so x16rs runs at repeat=1 (an inflated MH/s that is NOT real mining). The +/// panel writes hacash.config.ini for the reward wallet, so it must ensure `[node]` is +/// present — otherwise a config created before START-MAINNET.bat has run is off-network. +/// An existing `[node]` section is left untouched (respects a user/launcher setup). +fn ensure_mainnet_node_section(content: &str) -> String { + let has_node = content + .lines() + .any(|line| line.trim().eq_ignore_ascii_case("[node]")); + if has_node { + return content.to_string(); + } + let node = "[node]\nname = rust_node\nlisten = 3337\nboots = 54.193.49.59:3337, 182.92.163.225:3337, 54.219.80.127:3337\nnot_find_nodes = false\nfast_sync = true\n\n"; + format!("{node}{content}") +} + fn write_config(path: &Path, content: &str) -> std::io::Result<()> { atomic_write_private(path, content) } @@ -486,6 +503,52 @@ mod tests { assert!(raw.contains("diamond_form = true")); } + #[test] + fn fresh_hac_and_hacd_configs_get_mainnet_node_section() { + // A panel-written config on an empty file must join MAINNET. Without [node] + // (boots + not_find_nodes=false) hacash starts an isolated LOCAL chain + // (height ~0 -> x16rs repeat=1 -> inflated MH/s, not real mining). + let path = std::env::temp_dir().join(format!("hacash-node-cfg-{}.ini", std::process::id())); + + let _ = std::fs::remove_file(&path); + write_hac_miner_only(&path, "1AhGNNrHUNaiwS2GWBPR4UuDXjEiDwoE3v", Some(8080)).unwrap(); + let hac = std::fs::read_to_string(&path).unwrap(); + assert!(hac.contains("[node]"), "HAC config missing [node]:\n{hac}"); + assert!(hac.contains("not_find_nodes = false"), "{hac}"); + assert!(hac.contains("boots = 54.193.49.59:3337"), "{hac}"); + + let _ = std::fs::remove_file(&path); + write_diamond_miner( + &path, + "1AhGNNrHUNaiwS2GWBPR4UuDXjEiDwoE3v", + &DiamondMinerSettings::default(), + Some(8080), + ) + .unwrap(); + let hacd = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(hacd.contains("[node]"), "HACD config missing [node]:\n{hacd}"); + assert!(hacd.contains("not_find_nodes = false"), "{hacd}"); + } + + #[test] + fn existing_node_section_is_preserved() { + // An existing [node] (from START-MAINNET.bat or a custom user setup) must not + // be clobbered or duplicated when the panel rewrites the miner fields. + let path = + std::env::temp_dir().join(format!("hacash-keepnode-cfg-{}.ini", std::process::id())); + std::fs::write( + &path, + "[node]\nlisten = 9999\nnot_find_nodes = false\n\n[miner]\nreward = old\n", + ) + .unwrap(); + write_hac_miner_only(&path, "1NewWallet", Some(8080)).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(&path); + assert!(raw.contains("listen = 9999"), "custom [node] listen lost:\n{raw}"); + assert_eq!(raw.matches("[node]").count(), 1, "duplicate [node]:\n{raw}"); + } + #[test] fn config_write_replaces_existing_file() { let path = std::env::temp_dir().join(format!( From ab167a35bdb90917a066cbb598120c3eed5464d0 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Wed, 22 Jul 2026 22:15:07 +0200 Subject: [PATCH 05/74] feat(pool): all-in-one public free-IP pool (hac-pool) + panel hosting Add a community-requested Stratum + free-IP mining pool and wire it into the panel as all-in-one. - New `miner-pool` crate (bin `hac-pool`): an HTTP miner-RPC proxy (poworker-compatible, default :3333) plus a minimal Hacash-oriented Stratum TCP server (:3334) in front of a fullnode's miner API. Anyone can run it on 0.0.0.0 as a public pool; an optional --pool-token gates access. - Panel integration (miner-panel/src/public_pool.rs + ui_settings_tab.rs): a "PUBLIC FREE-IP POOL (ALL-IN-ONE)" settings section to host/start/stop hac-pool, set the upstream/ports/token, and optionally mine through the local pool. Settings persist to public-pool.json. - Docs: COMMUNITY-REQUIREMENTS.md (status matrix for the 6 community asks), PUBLIC-POOL.md (usage), JOJOIN-REBUILD.md (reproducible-rebuild recipe for requirement 6). Deployment modes, all supported: solo (local fullnode via START-MAINNET.bat); connect to a remote node/pool (panel Connect mode = Pool -> their IP:port, no fullnode needed, use the miner-only package); or host a public pool (this feature). v1 limits: the pool is a work proxy (no share accounting / PPS / payouts yet); Stratum is Hacash-native (job carries block_intro + height); poworker uses the HTTP pool port for zero worker-side change. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 311 +++++++++++++++++++++++++---- Cargo.toml | 1 + docs/COMMUNITY-REQUIREMENTS.md | 57 ++++++ docs/JOJOIN-REBUILD.md | 43 ++++ docs/PUBLIC-POOL.md | 73 +++++++ miner-panel/src/main.rs | 70 +++++++ miner-panel/src/public_pool.rs | 146 ++++++++++++++ miner-panel/src/ui_settings_tab.rs | 168 ++++++++++++++-- miner-pool/Cargo.toml | 24 +++ miner-pool/src/config.rs | 45 +++++ miner-pool/src/job.rs | 57 ++++++ miner-pool/src/main.rs | 110 ++++++++++ miner-pool/src/rpc_proxy.rs | 175 ++++++++++++++++ miner-pool/src/stratum.rs | 230 +++++++++++++++++++++ miner-pool/src/upstream.rs | 110 ++++++++++ 15 files changed, 1571 insertions(+), 49 deletions(-) create mode 100644 docs/COMMUNITY-REQUIREMENTS.md create mode 100644 docs/JOJOIN-REBUILD.md create mode 100644 docs/PUBLIC-POOL.md create mode 100644 miner-panel/src/public_pool.rs create mode 100644 miner-pool/Cargo.toml create mode 100644 miner-pool/src/config.rs create mode 100644 miner-pool/src/job.rs create mode 100644 miner-pool/src/main.rs create mode 100644 miner-pool/src/rpc_proxy.rs create mode 100644 miner-pool/src/stratum.rs create mode 100644 miner-pool/src/upstream.rs diff --git a/Cargo.lock b/Cargo.lock index 61e63bb..0a5a468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures 0.2.17", + "zeroize", ] [[package]] @@ -149,6 +150,7 @@ dependencies = [ "ctr", "ghash", "subtle", + "zeroize", ] [[package]] @@ -207,6 +209,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "app" version = "0.1.0" @@ -390,7 +442,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -425,7 +477,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -601,7 +653,7 @@ dependencies = [ "regex", "rustc-hash 2.1.2", "shlex 1.3.0", - "syn", + "syn 2.0.118", ] [[package]] @@ -745,7 +797,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -941,6 +993,46 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -966,6 +1058,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -983,7 +1081,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f76990911f2267d837d9d0ad060aa63aaad170af40904b29461734c339030d4d" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1315,7 +1413,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1520,7 +1618,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1686,7 +1784,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -1763,7 +1861,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2065,6 +2163,12 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2157,6 +2261,7 @@ checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "ctutils", "typenum", + "zeroize", ] [[package]] @@ -2417,6 +2522,12 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -2459,7 +2570,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.118", ] [[package]] @@ -2487,7 +2598,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -2546,6 +2657,12 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leveldb-sys" version = "2.0.4" @@ -2721,6 +2838,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -2788,6 +2914,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "miner-pool" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "hex", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2808,8 +2949,6 @@ dependencies = [ name = "mint" version = "0.1.0" dependencies = [ - "aes-gcm", - "argon2", "basis", "concat-idents", "field", @@ -2818,10 +2957,12 @@ dependencies = [ "num-bigint", "num-traits 0.2.19", "protocol", + "sdk", "serde_json", "sys", "tokio", "x16rs", + "zeroize", ] [[package]] @@ -2849,6 +2990,7 @@ dependencies = [ "pkcs8", "shake", "signature", + "zeroize", ] [[package]] @@ -2860,6 +3002,7 @@ dependencies = [ "ctutils", "hybrid-array", "num-traits 0.2.19", + "zeroize", ] [[package]] @@ -2993,6 +3136,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -3058,7 +3210,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -3386,6 +3538,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "opaque-debug" version = "0.2.3" @@ -3521,7 +3679,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4120,6 +4278,7 @@ dependencies = [ name = "sdk" version = "0.1.1" dependencies = [ + "aes", "aes-gcm", "argon2", "basis", @@ -4132,6 +4291,7 @@ dependencies = [ "serde_json", "sys", "wasm-bindgen", + "zeroize", ] [[package]] @@ -4167,7 +4327,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4202,7 +4362,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4301,6 +4461,15 @@ dependencies = [ "sponge-cursor", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4523,6 +4692,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -4540,6 +4715,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4557,7 +4743,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4577,6 +4763,7 @@ dependencies = [ "ripemd", "sha2 0.10.9", "sha3", + "zeroize", ] [[package]] @@ -4645,7 +4832,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4656,7 +4843,16 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] @@ -4719,6 +4915,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -4732,7 +4929,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4841,7 +5038,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -4851,6 +5048,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4949,6 +5176,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -5037,7 +5276,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-shared", ] @@ -5072,7 +5311,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5441,7 +5680,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5452,7 +5691,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5463,7 +5702,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5474,7 +5713,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5806,7 +6045,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -5866,7 +6105,7 @@ checksum = "709ab20fc57cb22af85be7b360239563209258430bccf38d8b979c5a2ae3ecce" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "zbus-lockstep", "zbus_xml", "zvariant", @@ -5881,7 +6120,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "zvariant_utils", ] @@ -5926,7 +6165,7 @@ checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -5946,7 +6185,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", "synstructure", ] @@ -5986,7 +6225,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] [[package]] @@ -6027,7 +6266,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.118", "zvariant_utils", ] @@ -6039,5 +6278,5 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.118", ] diff --git a/Cargo.toml b/Cargo.toml index 9ccd542..09883d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "sys", "x16rs", "x16rs-cuda", + "miner-pool", ] exclude = [ "chainv1", diff --git a/docs/COMMUNITY-REQUIREMENTS.md b/docs/COMMUNITY-REQUIREMENTS.md new file mode 100644 index 0000000..5de5962 --- /dev/null +++ b/docs/COMMUNITY-REQUIREMENTS.md @@ -0,0 +1,57 @@ +# Community miner requirements — status + +Target list from community / jojoin discussion: + +1. Stratum and free IP pool +2. Including new versions of CUDA +3. Integration with open source and official libraries +4. Diaworker + Poworker +5. Anyone can broadcast a public pool of content +6. JoJoin rebuilds miner when fullnode updates + +## Status matrix + +| # | Requirement | Status | How | +|---|-------------|--------|-----| +| 1 | Stratum + free IP pool | **Implemented (v1)** | Binary `hac-pool`: HTTP free-IP bind + Stratum TCP | +| 2 | New CUDA versions | **Implemented (validated)** | CUDA 12/13, sm_75/86/89; T4 Colab PASS | +| 3 | Official open-source libs | **Yes (community fork)** | Based on `hacash/fullnodedev`; integration request open | +| 4 | Diaworker + Poworker | **Yes** | Both in packages and builds | +| 5 | Public pool broadcast | **Implemented (v1)** | Anyone runs `hac-pool` on 0.0.0.0; workers connect | +| 6 | JoJoin rebuild | **Process ready** | See [JOJOIN-REBUILD.md](JOJOIN-REBUILD.md); needs org ownership | + +## Quick start public pool (1 + 5) + +```bash +# 1) fullnode with miner API (loopback OK) +# 2) free-IP pool in front of it +cargo build --release -p miner-pool +./target/release/hac-pool \ + --upstream 127.0.0.1:8080 \ + --http-bind 0.0.0.0:3333 \ + --stratum-bind 0.0.0.0:3334 + +# 3) workers (existing poworker) point at the pool IP +# poworker.config.ini: +# connect = YOUR_PUBLIC_IP:3333 +``` + +Optional: `--pool-token SECRET` then workers send `api_token` / Stratum password. + +## CUDA (2) + +- Docs: [MINING-NVIDIA-CUDA.md](MINING-NVIDIA-CUDA.md) +- Colab smoke: `scripts/mining-nvidia/colab_cuda_smoke.sh` +- Evidence: Tesla T4, `cargo test -p x16rs-cuda --features cuda` → 4 passed + +## Official integration (3 + 6) + +- Issues: https://github.com/hacash/fullnodedev/issues/9 +- Rebuild recipe: [JOJOIN-REBUILD.md](JOJOIN-REBUILD.md) + +## Honest limits (v1) + +- Pool is a **work proxy** (official miner RPC + minimal Stratum). +- No share accounting / PPS / wallet payouts yet (can be added later). +- Stratum is **Hacash-oriented** (job carries `block_intro` + height); not a drop-in for every third-party closed miner. +- Existing **poworker** uses HTTP pool port (not Stratum) for zero worker code change. diff --git a/docs/JOJOIN-REBUILD.md b/docs/JOJOIN-REBUILD.md new file mode 100644 index 0000000..d5a508a --- /dev/null +++ b/docs/JOJOIN-REBUILD.md @@ -0,0 +1,43 @@ +# JoJoin / official rebuild recipe + +Requirement 6: when a new fullnode version ships, the miner should be rebuildable by JoJoin (or any official maintainer) for compatibility. + +## Reproducible builds + +```bash +git clone https://github.com/hacash/fullnodedev.git # or Moskyera fork until merged +cd fullnodedev +git checkout + +# lockfile required +cargo build --locked --release --features ocl \ + --bin poworker --bin list_opencl --bin diagnose_opencl +cargo build --locked --release --bin hacash --bin diaworker +cargo build --locked --release -p miner-panel +cargo build --locked --release -p miner-pool # public pool (hac-pool) + +# optional NVIDIA CUDA worker +cargo build --locked --release --features cuda --bin poworker +``` + +## Version alignment + +| Component | Must match | +|-----------|------------| +| `protocol` / Istanbul gates | same commit as fullnode | +| `poworker` / `diaworker` | same workspace revision as `hacash` fullnode | +| `hac-pool` | same miner RPC paths as mint API | + +## CI + +`.github/workflows/release.yml` builds OpenCL workers + panel with `--locked`. +CUDA package builds need a GPU runner or offline kernel artifact (see mining-nvidia scripts). + +## Suggested official process + +1. Tag fullnode release `vX.Y.Z`. +2. Rebuild miner bins from the **same tag**. +3. Attach miner artifacts to the same GitHub Release (or linked release notes). +4. List community GPU tools on https://hacash.org/miner when accepted. + +Contact: integration request https://github.com/hacash/fullnodedev/issues/9 diff --git a/docs/PUBLIC-POOL.md b/docs/PUBLIC-POOL.md new file mode 100644 index 0000000..6126b9c --- /dev/null +++ b/docs/PUBLIC-POOL.md @@ -0,0 +1,73 @@ +# Public free-IP pool (`hac-pool`) + +## What it is + +`hac-pool` lets **anyone** run a public mining pool on a free IP: + +1. **HTTP miner RPC** (port default `3333`) — compatible with existing `poworker` +2. **Stratum TCP** (port default `3334`) — minimal JSON-RPC for multi-worker clients +3. **Upstream** = your fullnode `host:port` miner API + +## All-in-one (miner-panel) + +1. Build: `cargo build --release -p miner-pool -p miner-panel` (needs `hac-pool` next to the panel). +2. Open **Settings**. +3. Section **PUBLIC FREE-IP POOL (ALL-IN-ONE)**: + - Enable public pool controls + - Upstream fullnode (default `127.0.0.1:8080`) + - HTTP / Stratum ports + - Optional token + - **Start public pool** +4. With “mine through it” checked, Connect becomes `127.0.0.1:HTTP`. +5. **Start Mining** — if pool hosting is enabled and pool is stopped, the panel auto-starts the pool first. + +## Run (CLI) + +```bash +# fullnode must expose miner API (e.g. listen 8080) +cargo run --release -p miner-pool -- \ + --upstream 127.0.0.1:8080 \ + --http-bind 0.0.0.0:3333 \ + --stratum-bind 0.0.0.0:3334 +``` + +Open free pool (no password): + +```bash +# default: empty --pool-token +``` + +Token-protected: + +```bash +cargo run --release -p miner-pool -- \ + --upstream 127.0.0.1:8080 \ + --pool-token "shared-secret" +``` + +## Workers (poworker) + +```ini +; poworker.config.ini +connect = POOL_PUBLIC_IP:3333 +; if pool token set: +api_token = shared-secret +``` + +Firewall: open TCP 3333 (and 3334 for Stratum). + +## Stratum (minimal) + +Line-delimited JSON-RPC: + +- `mining.subscribe` +- `mining.authorize` with password = pool token (or any if open) +- `mining.notify` push: `[job_id, height, block_intro_hex, target]` +- `mining.submit`: `[worker, job_id, block_nonce, coinbase_nonce]` +- `mining.get_job`: full pending JSON (Hacash-native helper) + +## Security notes + +- Public bind without token is intentional for “free IP pool” but risks abuse. +- Prefer `--pool-token` on the internet. +- Upstream fullnode should stay on localhost; only `hac-pool` is public. diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs index 788a8eb..4cb3e60 100644 --- a/miner-panel/src/main.rs +++ b/miner-panel/src/main.rs @@ -13,6 +13,7 @@ mod mining_kind; mod opencl_status; mod platform; mod presets; +mod public_pool; mod stats_poll; mod theme; mod ui_dashboard_tab; @@ -138,6 +139,11 @@ struct MinerApp { pending_opencl_action: Option, auto_select_detected_gpu: bool, fleet: fleet::FleetState, + /// Host public free-IP pool (hac-pool) from this panel. + public_pool: public_pool::PublicPoolSettings, + public_pool_child: Option, + public_pool_running: bool, + public_pool_status: String, } impl MinerApp { @@ -165,6 +171,7 @@ impl MinerApp { }; let stats_path = work_dir.join("miner-stats.json"); let fleet = fleet::FleetState::load(&work_dir, &stats_path); + let public_pool = public_pool::load_settings(&work_dir); let poworker_path = platform::find_worker(&work_dir, "poworker"); let diaworker_path = platform::find_worker(&work_dir, "diaworker"); let cpus = cpu_presets(); @@ -338,6 +345,10 @@ impl MinerApp { pending_opencl_action: None, auto_select_detected_gpu: !gpu_configured_in_ini, fleet, + public_pool, + public_pool_child: None, + public_pool_running: false, + public_pool_status: String::new(), }; if mining_kind == MiningKind::Hac { app.request_opencl_probe(OpenClAction::InitialScan { @@ -1093,6 +1104,7 @@ impl eframe::App for MinerApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_opencl_probe(); self.poll_stats(); + self.poll_public_pool(); self.fleet.poll(); ctx.request_repaint_after(Duration::from_millis(500)); @@ -1226,6 +1238,8 @@ impl eframe::App for MinerApp { // The app is already closing, so signal termination directly and do // not wait. Auto Tune's durable sidecar is recovered next launch. self.stop_mining_on_exit(); + public_pool::stop_pool(&mut self.public_pool_child); + self.public_pool_running = false; if let Some(mut child) = self.benchmark_child.take() { let _ = child.kill(); } @@ -1235,6 +1249,62 @@ impl eframe::App for MinerApp { } } +impl MinerApp { + pub(crate) fn save_public_pool_settings(&mut self) { + if let Err(e) = public_pool::save_settings(&self.work_dir, &self.public_pool) { + self.public_pool_status = format!("Could not save pool settings: {e}"); + } + } + + pub(crate) fn start_public_pool(&mut self) { + if self.public_pool_running { + self.public_pool_status = "Public pool already running.".into(); + return; + } + self.save_public_pool_settings(); + match public_pool::start_pool(&self.work_dir, &self.public_pool) { + Ok(child) => { + self.public_pool_child = Some(child); + self.public_pool_running = true; + if self.public_pool.mine_through_pool { + self.connect_mode = ConnectMode::Pool; + self.connect = public_pool::local_pool_connect(self.public_pool.http_port); + } + self.public_pool_status = format!( + "Public pool running — HTTP 0.0.0.0:{} · Stratum 0.0.0.0:{} · upstream {}", + self.public_pool.http_port, + self.public_pool.stratum_port, + self.public_pool.upstream + ); + self.status_msg = self.public_pool_status.clone(); + } + Err(e) => { + self.public_pool_running = false; + self.public_pool_status = e.clone(); + self.status_msg = e; + } + } + } + + pub(crate) fn stop_public_pool(&mut self) { + public_pool::stop_pool(&mut self.public_pool_child); + self.public_pool_running = false; + self.public_pool_status = "Public pool stopped.".into(); + self.status_msg = self.public_pool_status.clone(); + } + + pub(crate) fn poll_public_pool(&mut self) { + if !self.public_pool_running { + return; + } + if !public_pool::poll_pool(&mut self.public_pool_child) { + self.public_pool_running = false; + self.public_pool_status = + "Public pool process exited. Start it again from Settings.".into(); + } + } +} + fn exe_dir() -> PathBuf { std::env::current_exe() .ok() diff --git a/miner-panel/src/public_pool.rs b/miner-panel/src/public_pool.rs new file mode 100644 index 0000000..b711375 --- /dev/null +++ b/miner-panel/src/public_pool.rs @@ -0,0 +1,146 @@ +//! Host a public free-IP pool (hac-pool) from the panel — all-in-one. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use serde::{Deserialize, Serialize}; + +use crate::platform; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PublicPoolSettings { + /// User wants the panel to manage a local public pool process. + #[serde(default)] + pub host_enabled: bool, + /// When pool is running, point local mining at 127.0.0.1:http_port. + #[serde(default = "default_true")] + pub mine_through_pool: bool, + #[serde(default = "default_http_port")] + pub http_port: u16, + #[serde(default = "default_stratum_port")] + pub stratum_port: u16, + /// Upstream fullnode miner RPC (usually local solo fullnode). + #[serde(default = "default_upstream")] + pub upstream: String, + /// Empty = open free pool. + #[serde(default)] + pub token: String, +} + +fn default_true() -> bool { + true +} +fn default_http_port() -> u16 { + 3333 +} +fn default_stratum_port() -> u16 { + 3334 +} +fn default_upstream() -> String { + "127.0.0.1:8080".into() +} + +impl Default for PublicPoolSettings { + fn default() -> Self { + Self { + host_enabled: false, + mine_through_pool: true, + http_port: default_http_port(), + stratum_port: default_stratum_port(), + upstream: default_upstream(), + token: String::new(), + } + } +} + +pub fn settings_path(work_dir: &Path) -> PathBuf { + work_dir.join("public-pool.json") +} + +pub fn load_settings(work_dir: &Path) -> PublicPoolSettings { + let path = settings_path(work_dir); + fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() +} + +pub fn save_settings(work_dir: &Path, s: &PublicPoolSettings) -> Result<(), String> { + let path = settings_path(work_dir); + let raw = serde_json::to_string_pretty(s).map_err(|e| e.to_string())?; + fs::write(path, raw).map_err(|e| e.to_string()) +} + +pub fn find_hac_pool(work_dir: &Path) -> PathBuf { + platform::find_worker(work_dir, "hac-pool") +} + +pub fn local_pool_connect(http_port: u16) -> String { + format!("127.0.0.1:{http_port}") +} + +/// Spawn hac-pool; returns child on success. +pub fn start_pool(work_dir: &Path, s: &PublicPoolSettings) -> Result { + let bin = find_hac_pool(work_dir); + if !bin.is_file() { + return Err(format!( + "hac-pool not found at {}. Build: cargo build --release -p miner-pool", + bin.display() + )); + } + if s.http_port == 0 || s.stratum_port == 0 { + return Err("pool ports must be non-zero".into()); + } + if s.http_port == s.stratum_port { + return Err("HTTP and Stratum ports must differ".into()); + } + let upstream = s.upstream.trim(); + if upstream.is_empty() { + return Err("upstream fullnode host:port is required".into()); + } + + let mut cmd = Command::new(&bin); + cmd.current_dir(work_dir); + cmd.arg("--upstream").arg(upstream); + cmd.arg("--http-bind").arg(format!("0.0.0.0:{}", s.http_port)); + cmd.arg("--stratum-bind") + .arg(format!("0.0.0.0:{}", s.stratum_port)); + if !s.token.trim().is_empty() { + cmd.arg("--pool-token").arg(s.token.trim()); + } + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + platform::configure_background_command(&mut cmd); + cmd.spawn() + .map_err(|e| format!("failed to start hac-pool: {e}")) +} + +pub fn stop_pool(child: &mut Option) { + if let Some(mut c) = child.take() { + let _ = c.kill(); + let _ = c.wait(); + } +} + +/// Returns true if process still running. +pub fn poll_pool(child: &mut Option) -> bool { + let Some(c) = child.as_mut() else { + return false; + }; + match c.try_wait() { + Ok(None) => true, + Ok(Some(_)) | Err(_) => { + *child = None; + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_connect_format() { + assert_eq!(local_pool_connect(3333), "127.0.0.1:3333"); + } +} diff --git a/miner-panel/src/ui_settings_tab.rs b/miner-panel/src/ui_settings_tab.rs index 9cd8f94..8bddab4 100644 --- a/miner-panel/src/ui_settings_tab.rs +++ b/miner-panel/src/ui_settings_tab.rs @@ -319,7 +319,152 @@ impl MinerApp { } }); ui.end_row(); + }); + }); + + // All-in-one public free-IP pool (hac-pool) + if self.mining_kind == MiningKind::Hac { + ui.add_space(12.0); + theme::section_card().show(ui, |ui| { + ui.label( + egui::RichText::new("PUBLIC FREE-IP POOL (ALL-IN-ONE)") + .strong() + .size(12.0) + .color(theme::colors::ACCENT), + ); + ui.label( + egui::RichText::new( + "Host a public pool from this PC. Others connect with your IP:HTTP port. \ +Local mining can use 127.0.0.1 via the pool. Requires hac-pool.exe next to the panel.", + ) + .size(11.5) + .color(theme::colors::TEXT_MUTED), + ); + ui.add_space(8.0); + + let mut host = self.public_pool.host_enabled; + if ui + .checkbox(&mut host, "Enable public pool controls") + .changed() + { + self.public_pool.host_enabled = host; + self.save_public_pool_settings(); + } + + ui.add_enabled_ui(self.public_pool.host_enabled, |ui| { + egui::Grid::new("public_pool_grid") + .num_columns(2) + .spacing([20.0, 10.0]) + .show(ui, |ui| { + theme::field_label(ui, "Upstream fullnode"); + if ui + .add( + egui::TextEdit::singleline(&mut self.public_pool.upstream) + .desired_width(280.0) + .hint_text("127.0.0.1:8080"), + ) + .changed() + { + self.save_public_pool_settings(); + } + ui.end_row(); + + theme::field_label(ui, "HTTP port (workers)"); + if ui + .add( + egui::DragValue::new(&mut self.public_pool.http_port) + .range(1024..=65535), + ) + .changed() + { + self.save_public_pool_settings(); + } + ui.end_row(); + + theme::field_label(ui, "Stratum port"); + if ui + .add( + egui::DragValue::new(&mut self.public_pool.stratum_port) + .range(1024..=65535), + ) + .changed() + { + self.save_public_pool_settings(); + } + ui.end_row(); + + theme::field_label(ui, "Pool token (optional)"); + if ui + .add( + egui::TextEdit::singleline(&mut self.public_pool.token) + .desired_width(280.0) + .hint_text("empty = open free pool"), + ) + .changed() + { + self.save_public_pool_settings(); + } + ui.end_row(); + }); + + if ui + .checkbox( + &mut self.public_pool.mine_through_pool, + "When pool starts, mine through it (set Connect to 127.0.0.1:HTTP)", + ) + .changed() + { + self.save_public_pool_settings(); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + let can_start = !self.public_pool_running; + if ui + .add_enabled(can_start, egui::Button::new("Start public pool")) + .clicked() + { + self.start_public_pool(); + } + if ui + .add_enabled(self.public_pool_running, egui::Button::new("Stop public pool")) + .clicked() + { + self.stop_public_pool(); + } + let badge = if self.public_pool_running { + ("RUNNING", theme::colors::GREEN) + } else { + ("STOPPED", theme::colors::TEXT_MUTED) + }; + ui.label(egui::RichText::new(badge.0).color(badge.1).strong()); + }); + + if !self.public_pool_status.is_empty() { + ui.label( + egui::RichText::new(&self.public_pool_status) + .size(11.5) + .color(theme::colors::TEXT_MUTED), + ); + } + ui.label( + egui::RichText::new(format!( + "External workers: connect = YOUR_PUBLIC_IP:{} (firewall must allow it)", + self.public_pool.http_port + )) + .size(11.0) + .color(theme::colors::GOLD_DIM), + ); + }); + }); + } + + ui.add_space(12.0); + theme::section_card().show(ui, |ui| { + egui::Grid::new("wallet_grid_after_pool") + .num_columns(2) + .spacing([20.0, 12.0]) + .show(ui, |ui| { theme::field_label(ui, t.label_wallet); ui.vertical(|ui| { ui.add( @@ -414,24 +559,21 @@ impl MinerApp { }); }); - }); - ui.add_space(12.0); self.fleet.show_settings(ui); ui.add_space(18.0); - ui.add_enabled_ui(!settings_locked, |ui| { - 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; - } - }); + 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; + } }); ui.add_space(8.0); + }); // end add_enabled_ui(!settings_locked) } } diff --git a/miner-pool/Cargo.toml b/miner-pool/Cargo.toml new file mode 100644 index 0000000..0be1a1e --- /dev/null +++ b/miner-pool/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "miner-pool" +version = "0.1.0" +edition = "2024" +description = "Public free-IP mining pool for Hacash: fullnode miner RPC proxy + minimal Stratum" +license = "MIT" + +[[bin]] +name = "hac-pool" +path = "src/main.rs" + +[dependencies] +axum = "0.7.9" +tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "signal"] } +reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "json"] } +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" +hex = "0.4.3" +clap = { version = "4.5", features = ["derive", "env"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } diff --git a/miner-pool/src/config.rs b/miner-pool/src/config.rs new file mode 100644 index 0000000..e9d54f2 --- /dev/null +++ b/miner-pool/src/config.rs @@ -0,0 +1,45 @@ +use clap::Parser; + +#[derive(Debug, Clone, Parser)] +#[command( + name = "hac-pool", + about = "Hacash public free-IP mining pool (HTTP miner RPC + Stratum)", + version +)] +pub struct PoolArgs { + /// Upstream fullnode miner RPC host:port (e.g. 127.0.0.1:8080) + #[arg(long, env = "HAC_POOL_UPSTREAM", default_value = "127.0.0.1:8080")] + pub upstream: String, + + /// Optional X-Api-Token for upstream fullnode + #[arg(long, env = "HAC_POOL_UPSTREAM_TOKEN", default_value = "")] + pub upstream_token: String, + + /// HTTP miner-RPC listen address (free IP: 0.0.0.0:3333) + #[arg(long, env = "HAC_POOL_HTTP_BIND", default_value = "0.0.0.0:3333")] + pub http_bind: String, + + /// Stratum TCP listen address + #[arg(long, env = "HAC_POOL_STRATUM_BIND", default_value = "0.0.0.0:3334")] + pub stratum_bind: String, + + /// Optional pool token (X-Api-Token / stratum password). Empty = open free pool. + #[arg(long, env = "HAC_POOL_TOKEN", default_value = "")] + pub pool_token: String, + + /// How often to refresh pending work from upstream (ms) + #[arg(long, env = "HAC_POOL_POLL_MS", default_value_t = 2000)] + pub poll_ms: u64, +} + +impl PoolArgs { + pub fn validate(&self) -> Result<(), String> { + if self.upstream.trim().is_empty() { + return Err("upstream must not be empty".into()); + } + if self.poll_ms < 200 { + return Err("poll_ms must be >= 200".into()); + } + Ok(()) + } +} diff --git a/miner-pool/src/job.rs b/miner-pool/src/job.rs new file mode 100644 index 0000000..b759157 --- /dev/null +++ b/miner-pool/src/job.rs @@ -0,0 +1,57 @@ +use std::sync::RwLock; + +use serde_json::Value as JV; + +/// Latest mining job mirrored from the upstream fullnode. +#[derive(Debug, Clone, Default)] +pub struct MiningJob { + pub height: u64, + pub raw: JV, + pub job_id: String, +} + +pub struct JobHub { + inner: RwLock>, +} + +impl JobHub { + pub fn new() -> Self { + Self { + inner: RwLock::new(None), + } + } + + pub fn update(&self, height: u64, raw: JV) { + let job_id = format!("h{height}"); + let mut g = self.inner.write().expect("job hub write"); + *g = Some(MiningJob { + height, + raw, + job_id, + }); + } + + pub fn current(&self) -> Option { + self.inner.read().expect("job hub read").clone() + } + + pub fn height(&self) -> u64 { + self.current().map(|j| j.height).unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn job_hub_updates_height_and_id() { + let hub = JobHub::new(); + assert_eq!(hub.height(), 0); + hub.update(100, json!({"height": 100, "block_intro": "aa"})); + let j = hub.current().unwrap(); + assert_eq!(j.height, 100); + assert_eq!(j.job_id, "h100"); + } +} diff --git a/miner-pool/src/main.rs b/miner-pool/src/main.rs new file mode 100644 index 0000000..99d0afa --- /dev/null +++ b/miner-pool/src/main.rs @@ -0,0 +1,110 @@ +//! hac-pool: public free-IP mining pool for Hacash. +//! +//! - HTTP: same miner RPC as fullnode so existing poworker/diaworker can connect. +//! - Stratum TCP: minimal JSON-RPC lines for third-party / multi-worker clients. +//! - Upstream: any fullnode with `[server]` miner API enabled. +//! +//! Requirements covered (community list): +//! 1+5 free IP pool + broadcast work, 3 official protocol, 4 workers unchanged. + +mod config; +mod job; +mod rpc_proxy; +mod stratum; +mod upstream; + +use std::net::SocketAddr; +use std::sync::Arc; + +use clap::Parser; +use tracing::{info, warn}; + +use crate::config::PoolArgs; +use crate::job::JobHub; +use crate::upstream::Upstream; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "miner_pool=info,hac_pool=info".into()), + ) + .init(); + + let args = PoolArgs::parse(); + if let Err(e) = args.validate() { + eprintln!("config error: {e}"); + std::process::exit(2); + } + + let hub = Arc::new(JobHub::new()); + let upstream = Upstream::new( + args.upstream.clone(), + args.upstream_token.clone(), + hub.clone(), + ); + + // Background job refresh from fullnode + let up = upstream.clone(); + tokio::spawn(async move { + up.run_poll_loop(args.poll_ms).await; + }); + + let http_addr: SocketAddr = args + .http_bind + .parse() + .unwrap_or_else(|_| "0.0.0.0:3333".parse().unwrap()); + let stratum_addr: SocketAddr = args + .stratum_bind + .parse() + .unwrap_or_else(|_| "0.0.0.0:3334".parse().unwrap()); + + info!( + "hac-pool starting free-IP pool upstream={} http={} stratum={} token={}", + args.upstream, + http_addr, + stratum_addr, + if args.pool_token.is_empty() { + "none (open)" + } else { + "required" + } + ); + if !http_addr.ip().is_loopback() && args.pool_token.is_empty() { + warn!( + "HTTP bind {} is public without --pool-token; anyone can use this pool", + http_addr + ); + } + + let hub_http = hub.clone(); + let token_http = args.pool_token.clone(); + let up_http = upstream.clone(); + let http = tokio::spawn(async move { + if let Err(e) = rpc_proxy::serve(http_addr, hub_http, up_http, token_http).await { + eprintln!("HTTP pool server error: {e}"); + } + }); + + let hub_st = hub.clone(); + let token_st = args.pool_token.clone(); + let up_st = upstream.clone(); + let stratum = tokio::spawn(async move { + if let Err(e) = stratum::serve(stratum_addr, hub_st, up_st, token_st).await { + eprintln!("Stratum server error: {e}"); + } + }); + + info!("poworker connect = {}", http_addr); + info!("stratum connect = {}", stratum_addr); + info!("anyone can point workers at this host:port (free IP pool broadcast)"); + + tokio::select! { + _ = http => {}, + _ = stratum => {}, + _ = tokio::signal::ctrl_c() => { + info!("shutdown signal"); + } + } +} diff --git a/miner-pool/src/rpc_proxy.rs b/miner-pool/src/rpc_proxy.rs new file mode 100644 index 0000000..2dfa52e --- /dev/null +++ b/miner-pool/src/rpc_proxy.rs @@ -0,0 +1,175 @@ +//! Fullnode-compatible miner HTTP RPC so existing poworker can use the pool. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::{Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Json, Router}; +use serde_json::{Value as JV, json}; +use tracing::info; + +use crate::job::JobHub; +use crate::upstream::Upstream; + +#[derive(Clone)] +struct AppState { + hub: Arc, + upstream: Upstream, + pool_token: String, +} + +pub async fn serve( + addr: SocketAddr, + hub: Arc, + upstream: Upstream, + pool_token: String, +) -> Result<(), String> { + let state = AppState { + hub, + upstream, + pool_token, + }; + let app = Router::new() + .route("/_server_", get(|| async { "Hacash Pool (miner RPC)" })) + .route("/query/miner/pending", get(pending)) + .route("/query/miner/notice", get(notice)) + .route("/submit/miner/success", get(submit)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|e| format!("bind {addr}: {e}"))?; + info!("HTTP miner RPC listening on http://{addr}"); + axum::serve(listener, app) + .await + .map_err(|e| e.to_string()) +} + +fn auth_ok(state: &AppState, headers: &HeaderMap, query: &HashMap) -> bool { + if state.pool_token.is_empty() { + return true; + } + if let Some(v) = headers.get("x-api-token").and_then(|v| v.to_str().ok()) { + if v.trim() == state.pool_token { + return true; + } + } + if let Some(v) = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + { + let t = v + .strip_prefix("Bearer ") + .or_else(|| v.strip_prefix("bearer ")) + .unwrap_or(v) + .trim(); + if t == state.pool_token { + return true; + } + } + if query.get("api_token").map(|s| s.as_str()) == Some(state.pool_token.as_str()) { + return true; + } + false +} + +async fn pending( + State(state): State, + headers: HeaderMap, + Query(q): Query>, +) -> impl IntoResponse { + if !auth_ok(&state, &headers, &q) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"err": "unauthorized"})), + ); + } + match state.hub.current() { + Some(job) => (StatusCode::OK, Json(job.raw)), + None => ( + StatusCode::OK, + Json(json!({"err": "no job yet; wait for upstream fullnode"})), + ), + } +} + +async fn notice( + State(state): State, + headers: HeaderMap, + Query(q): Query>, +) -> impl IntoResponse { + if !auth_ok(&state, &headers, &q) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"err": "unauthorized"})), + ); + } + let want = q + .get("height") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let wait_s = q + .get("wait") + .and_then(|s| s.parse::().ok()) + .unwrap_or(45) + .min(120); + let deadline = tokio::time::Instant::now() + Duration::from_secs(wait_s.max(1)); + loop { + let h = state.hub.height(); + if h > want { + return (StatusCode::OK, Json(json!({"height": h}))); + } + if tokio::time::Instant::now() >= deadline { + return (StatusCode::OK, Json(json!({"height": h}))); + } + tokio::time::sleep(Duration::from_millis(400)).await; + } +} + +async fn submit( + State(state): State, + headers: HeaderMap, + Query(q): Query>, +) -> impl IntoResponse { + if !auth_ok(&state, &headers, &q) { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"err": "unauthorized", "ret": 1})), + ); + } + let height = q + .get("height") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let block_nonce = q.get("block_nonce").cloned().unwrap_or_default(); + let coinbase_nonce = q.get("coinbase_nonce").cloned().unwrap_or_default(); + if height == 0 || block_nonce.is_empty() { + return ( + StatusCode::OK, + Json(json!({"err": "missing height or block_nonce", "ret": 1})), + ); + } + match state + .upstream + .submit_success(height, &block_nonce, &coinbase_nonce) + .await + { + Ok(body) => { + // Pass through upstream JSON if possible + if let Ok(v) = serde_json::from_str::(&body) { + (StatusCode::OK, Json(v)) + } else { + (StatusCode::OK, Json(json!({"ret": 0, "msg": body}))) + } + } + Err(e) => ( + StatusCode::OK, + Json(json!({"err": e, "ret": 1})), + ), + } +} diff --git a/miner-pool/src/stratum.rs b/miner-pool/src/stratum.rs new file mode 100644 index 0000000..9b43e23 --- /dev/null +++ b/miner-pool/src/stratum.rs @@ -0,0 +1,230 @@ +//! Minimal Stratum-style JSON-RPC over TCP (line-delimited). +//! +//! Methods: +//! - mining.subscribe +//! - mining.authorize (password = pool token if set) +//! - mining.notify is pushed when job changes +//! - mining.submit → forwarded to fullnode +//! - mining.get_job (helper returning full Hacash pending JSON) + +use std::net::SocketAddr; +use std::sync::Arc; + +use serde_json::{Value as JV, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tracing::{info, warn}; + +use crate::job::JobHub; +use crate::upstream::Upstream; + +pub async fn serve( + addr: SocketAddr, + hub: Arc, + upstream: Upstream, + pool_token: String, +) -> Result<(), String> { + let listener = TcpListener::bind(addr) + .await + .map_err(|e| format!("stratum bind {addr}: {e}"))?; + info!("Stratum listening on {addr}"); + loop { + let (sock, peer) = listener.accept().await.map_err(|e| e.to_string())?; + let hub = hub.clone(); + let upstream = upstream.clone(); + let token = pool_token.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(sock, peer, hub, upstream, token).await { + warn!("stratum {peer}: {e}"); + } + }); + } +} + +async fn handle_client( + sock: TcpStream, + peer: SocketAddr, + hub: Arc, + upstream: Upstream, + pool_token: String, +) -> Result<(), String> { + let (reader, mut writer) = sock.into_split(); + let mut lines = BufReader::new(reader).lines(); + let mut authorized = pool_token.is_empty(); + let mut last_job = String::new(); + let mut worker = peer.to_string(); + + let (tx, mut rx) = tokio::sync::mpsc::channel::(32); + + let hub_push = hub.clone(); + let push_tx = tx.clone(); + tokio::spawn(async move { + let mut last = String::new(); + loop { + if let Some(job) = hub_push.current() { + if job.job_id != last { + last = job.job_id.clone(); + let intro = job + .raw + .get("block_intro") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let notify = json!({ + "id": null, + "method": "mining.notify", + "params": [ + job.job_id, + job.height, + intro, + job.raw.get("target").cloned().unwrap_or(JV::Null) + ] + }); + if push_tx.send(notify.to_string()).await.is_err() { + break; + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + }); + + loop { + tokio::select! { + maybe_line = lines.next_line() => { + let line = match maybe_line { + Ok(Some(l)) => l, + Ok(None) => break, + Err(e) => return Err(e.to_string()), + }; + if line.trim().is_empty() { + continue; + } + let req: JV = serde_json::from_str(&line).map_err(|e| e.to_string())?; + let id = req.get("id").cloned().unwrap_or(JV::Null); + let method = req.get("method").and_then(|m| m.as_str()).unwrap_or(""); + let params = req.get("params").cloned().unwrap_or(JV::Array(vec![])); + + let reply = match method { + "mining.subscribe" => { + json!({ + "id": id, + "result": [ [["mining.notify", "hacash"]], "00", 4 ], + "error": null + }) + } + "mining.authorize" => { + let pass = params + .as_array() + .and_then(|a| a.get(1)) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let user = params + .as_array() + .and_then(|a| a.get(0)) + .and_then(|v| v.as_str()) + .unwrap_or("worker"); + worker = user.to_string(); + if pool_token.is_empty() || pass == pool_token { + authorized = true; + if let Some(job) = hub.current() { + last_job = job.job_id.clone(); + let intro = job + .raw + .get("block_intro") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let notify = json!({ + "id": null, + "method": "mining.notify", + "params": [job.job_id, job.height, intro, JV::Null] + }); + let _ = tx.send(notify.to_string()).await; + } + json!({"id": id, "result": true, "error": null}) + } else { + json!({"id": id, "result": false, "error": [24, "unauthorized", null]}) + } + } + "mining.get_job" => { + if !authorized { + json!({"id": id, "result": null, "error": [24, "unauthorized", null]}) + } else if let Some(job) = hub.current() { + json!({"id": id, "result": job.raw, "error": null}) + } else { + json!({"id": id, "result": null, "error": [20, "no job", null]}) + } + } + "mining.submit" => { + if !authorized { + json!({"id": id, "result": false, "error": [24, "unauthorized", null]}) + } else { + // params: [worker, job_id, block_nonce, coinbase_nonce] + let arr = params.as_array().cloned().unwrap_or_default(); + let job_id = arr.get(1).and_then(|v| v.as_str()).unwrap_or(""); + let block_nonce = arr.get(2).and_then(|v| v.as_str()).unwrap_or(""); + let coinbase_nonce = arr + .get(3) + .and_then(|v| v.as_str()) + .unwrap_or("00"); + let height = job_id + .strip_prefix('h') + .and_then(|s| s.parse::().ok()) + .or_else(|| hub.current().map(|j| j.height)) + .unwrap_or(0); + match upstream + .submit_success(height, block_nonce, coinbase_nonce) + .await + { + Ok(body) => { + info!("stratum submit from {worker} height={height}: {body}"); + let ok = !body.contains("\"ret\":1"); + json!({"id": id, "result": ok, "error": null}) + } + Err(e) => { + json!({"id": id, "result": false, "error": [20, e, null]}) + } + } + } + } + "" => json!({"id": id, "result": null, "error": [20, "missing method", null]}), + other => json!({ + "id": id, + "result": null, + "error": [20, format!("unknown method {other}"), null] + }), + }; + + let mut out = reply.to_string(); + out.push('\n'); + writer + .write_all(out.as_bytes()) + .await + .map_err(|e| e.to_string())?; + } + Some(push) = rx.recv() => { + if !authorized && !pool_token.is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(&push) { + if let Some(jid) = v + .get("params") + .and_then(|p| p.as_array()) + .and_then(|a| a.first()) + .and_then(|x| x.as_str()) + { + if jid == last_job { + continue; + } + last_job = jid.to_string(); + } + } + let mut out = push; + out.push('\n'); + if writer.write_all(out.as_bytes()).await.is_err() { + break; + } + } + } + } + Ok(()) +} diff --git a/miner-pool/src/upstream.rs b/miner-pool/src/upstream.rs new file mode 100644 index 0000000..7ac8806 --- /dev/null +++ b/miner-pool/src/upstream.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; +use std::time::Duration; + +use reqwest::Client; +use serde_json::Value as JV; +use tracing::{debug, info, warn}; + +use crate::job::JobHub; + +#[derive(Clone)] +pub struct Upstream { + base: String, + token: String, + client: Client, + hub: Arc, +} + +impl Upstream { + pub fn new(host_port: String, token: String, hub: Arc) -> Self { + let base = host_port.trim().trim_start_matches("http://").to_string(); + Self { + base, + token: token.trim().to_string(), + client: Client::builder() + .timeout(Duration::from_secs(30)) + .no_proxy() + .build() + .unwrap_or_else(|_| Client::new()), + hub, + } + } + + fn apply_token(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if self.token.is_empty() { + req + } else { + req.header("x-api-token", &self.token) + } + } + + pub async fn fetch_pending(&self) -> Result { + let url = format!( + "http://{}/query/miner/pending?stuff=true&t={}", + self.base, + now_ms() + ); + let req = self.apply_token(self.client.get(&url)); + let resp = req.send().await.map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("upstream HTTP {}", resp.status())); + } + resp.json::().await.map_err(|e| e.to_string()) + } + + pub async fn submit_success( + &self, + height: u64, + block_nonce: &str, + coinbase_nonce: &str, + ) -> Result { + let url = format!( + "http://{}/submit/miner/success?height={height}&block_nonce={block_nonce}&coinbase_nonce={coinbase_nonce}&t={}", + self.base, + now_ms() + ); + let req = self.apply_token(self.client.get(&url)); + let resp = req.send().await.map_err(|e| e.to_string())?; + resp.text().await.map_err(|e| e.to_string()) + } + + pub async fn run_poll_loop(&self, poll_ms: u64) { + let mut last_h = 0u64; + loop { + match self.fetch_pending().await { + Ok(raw) => { + let height = raw + .get("height") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let has_intro = raw.get("block_intro").and_then(|v| v.as_str()).is_some(); + if height > 0 && has_intro { + if height != last_h { + info!("upstream job height={height}"); + last_h = height; + } else { + debug!("upstream job refresh height={height}"); + } + self.hub.update(height, raw); + } else { + let err = raw + .get("err") + .and_then(|v| v.as_str()) + .unwrap_or("no block_intro"); + warn!("upstream pending incomplete: {err}"); + } + } + Err(e) => warn!("upstream fetch failed: {e}"), + } + tokio::time::sleep(Duration::from_millis(poll_ms)).await; + } + } +} + +fn now_ms() -> u128 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} From 1cbc08d459de2d62e0b084b7ee94b587f71fdba2 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 01:52:03 +0200 Subject: [PATCH 06/74] feat(panel,cuda): pool directory, CUDA PTX forward-compat, honest pool UX Panel: - Updatable pool directory (built-in list + optional pools.json), pool dropdown with per-pool notes/links + Refresh; apply/override by name, no rebuild to add - "Test connection" / "Test upstream" TCP reachability probes (probe_reachable) - Advanced worker settings in the GUI (nonce_max/notice_wait) so no file editing - Honest relabel: "PUBLIC FREE-IP POOL" -> "SHARED NODE / OPEN WORK RELAY" with a work-relay/no-payout disclosure and an honest NAT/CGNAT reachability note - Master Panel tab (fleet worker table) [carried, same files] CUDA (x16rs-cuda): - Add PTX gencode (arch=compute_89,code=compute_89) so newer NVIDIA archs (Hopper sm_90, Blackwell/RTX 50xx sm_120) JIT at runtime instead of failing with cudaErrorNoKernelImageForDevice; validated with nvcc 13.3 - Add cuda-13/12.8/12.6 Linux toolkit discovery paths i18n: - Fix the "GPU mining uses OpenCL only, no CUDA" contradiction across 9 locales Docs: - COMMUNITY-POOL-DESIGN.md: trust-minimized PPLNS batched-settlement pool plan - MINING-NVIDIA-CUDA.md: PTX forward-compat note Co-Authored-By: Claude Opus 4.8 --- docs/COMMUNITY-POOL-DESIGN.md | 217 ++++++++++++++++++++++++++++ docs/MINING-NVIDIA-CUDA.md | 81 ++++------- miner-panel/src/config.rs | 14 +- miner-panel/src/connect.rs | 223 +++++++++++++++++++++++++++-- miner-panel/src/fleet.rs | 152 ++++++++++++++++++++ miner-panel/src/i18n.rs | 18 +-- miner-panel/src/main.rs | 82 +++++++---- miner-panel/src/mining_control.rs | 11 ++ miner-panel/src/ui_settings_tab.rs | 204 ++++++++++++++++++++++---- x16rs-cuda/build.rs | 27 +++- 10 files changed, 896 insertions(+), 133 deletions(-) create mode 100644 docs/COMMUNITY-POOL-DESIGN.md diff --git a/docs/COMMUNITY-POOL-DESIGN.md b/docs/COMMUNITY-POOL-DESIGN.md new file mode 100644 index 0000000..61fdc91 --- /dev/null +++ b/docs/COMMUNITY-POOL-DESIGN.md @@ -0,0 +1,217 @@ +# Hacash Community Pool — Design + +A trust-minimized mining pool for newcomers, designed within what the Hacash +mainnet actually allows today (verified against the node source, 2026-07). +Goal: small GPUs (RTX 3050, RX 9060 XT) get **smooth, frequent, fair** payouts — +without the operator taking meaningful custody of anyone's funds. + +This document is the plan. It is intentionally phased so we ship value early and +add trust-minimization on top, rather than building everything before anything +works. + +--- + +## 1. Philosophy + +- **Newcomer-first.** A user picks the pool in the panel, pastes their HAC + address, presses Start. No CLI, no config files. +- **Honest.** We never call something a "payout pool" until it pays fairly, and + we always disclose the custody model in plain language in the UI. +- **Trust-minimized, not custodial-forever.** Custody is bounded, guarded, and + ultimately escapable by miners (see §6). We never hold more than a short + settlement window's worth, and never behind a single key. +- **No consensus fork.** Everything here runs on the node **as-is**. No changes + to Hacash consensus; the pool is off-node software plus normal transactions. + +--- + +## 2. The constraints we must design within (verified facts) + +These are the "physics". Every design choice below follows from them. + +| Fact | Consequence | Source | +|------|-------------|--------| +| Coinbase is **single-output**: one PRIVAKEY address, `reward == block_reward(height)` | A block reward cannot be split on-chain among many miners | `mint/src/check/coinbase.rs:12-18,114-142` | +| Consensus does **not** bind the coinbase to node config — `/submit/block` accepts any valid block with any PRIVAKEY coinbase | The pool can assemble blocks off-node and choose the coinbase address | `mint/src/api/submit_block.rs`; `mint/src/check/coinbase.rs:114-142` | +| Stock template API (`/query/miner/pending`) hardwires coinbase to `[miner] reward`; worker submits only 2 nonces | To choose coinbase we must build templates ourselves, not ask the node | `mint/src/check/block_build.rs:27-31`; `mint/src/api/miner_success.rs` | +| **No "share" concept** anywhere — PoW is validated only against the full network target | Share accounting is 100% off-node (pool ↔ worker) | `mint/src/api/miner_success.rs:50`; `mint/src/check/block_accept.rs:28` | +| A normal transfer can be **any fractional amount**, and one tx carries up to **200 actions** (`TX_ACTIONS_MAX=200`) | The pool can pay ~200 miners fractional amounts in one cheap tx | `basis/src/component/action.rs` (TX_ACTIONS_MAX); `protocol/src/action/hacash.rs:4,55` (HacToTrs 1, HacFromToTrs 14) | +| "Istanbul" (mainnet activation at height **765432**, already live) unlocked: **type3 multisig** (≤200 signers), **VM contracts** (40/41/44), **P2SH scriptmh** (P2SHScriptProve 46) + VM native hashes + `ViewCheckSign` + `HeightScope`/`BalanceFloor` guards | We have a real toolkit to harden custody | `protocol/src/upgrade.rs:8,33-42,88-141`; `vm/src/action/p2sh.rs:86`; `vm/src/native/hash.rs` | +| Payment channels ship **cooperative open/close only**; trustless unilateral exit is modeled but **unregistered** | True off-chain streaming needs a node change — out of scope for v1/v2 | `mint/src/action/mod.rs:30-36`; `field/src/component/channel.rs` | +| Economics: block reward **8 HAC**, block time **5 min**, network **~26 GH/s** | A 10-GPU pool finds ~1 block / 3 h; a 3050 alone would wait ~11 days per block | `mint/src/genesis/reward.rs`; `mint/src/config.rs:9`; explorer.hacash.org | + +**The core insight:** the "minimum payout = one whole block" barrier applies only +to the **coinbase**. If the pool receives whole blocks and **redistributes via +normal transfers**, it can pay each miner their exact fractional share, often and +cheaply. That is the whole design. The cost is custody between settlements, which +§6 bounds and hardens. + +--- + +## 3. Architecture + +``` + miners (poworker, modified) pool operator (e.g. home M6 + fullnode) + ┌───────────────────────┐ work + shares ┌──────────────────────────────────┐ + │ GPU/CPU, own wallet │ ───────────────▶ │ hac-pool daemon │ + │ mines pool templates │ ◀─────────────── │ • share validator (share target)│ + │ submits SHARES │ share target │ • share accounting (PPLNS) │ + └───────────────────────┘ │ • block assembler (own coinbase)│ + │ • settlement engine (batched) │ + full-solution ─────────────────────────────▶│ • treasury (multisig / P2SH-HTLC)│ + └───────────────┬──────────────────┘ + │ /submit/block, batched transfers + ▼ + Hacash fullnode (unchanged) +``` + +Components (all off-node): + +1. **Share validator.** Advertises a pool-chosen **share target** below the + network target; validates each submitted share by re-running + `x16rs::block_hash` over the reconstructed 89-byte block header and comparing + to the share target (`hash_bigger_than`). Reusable primitives already exist. +2. **Share accounting.** PPLNS-style: keep a sliding window of the last *N* + shares; each miner's credit = their share count in the window. Transparent + (published log). +3. **Block assembler.** Re-implements the ~85 lines of `impl_packing_next_block` + off-node: pulls `prevhash/height/difficulty/timestamp` from `/query/block/intro` + + `/query/latest`, calls `create_coinbase_tx(height, msg, POOL_ADDRESS)` + (already address-parameterized), builds `BlockV1`, computes the merkle root, + exposes the header nonce slot. Coinbase pays the **pool treasury** (see §6). +4. **Settlement engine.** On a cadence (e.g. every *S* blocks), computes each + miner's owed HAC from the PPLNS window and pays up to 200 miners in one + batched transaction (`HacToTrs`), directly to their own wallets. +5. **Treasury.** Where block rewards land before settlement. Hardened per §6. + +--- + +## 4. Share accounting model (PPLNS) + +- Pool sets `share_target = network_target × D` where `D` (e.g. 1/1024) makes + shares common enough for smooth accounting but not spammy. +- Each valid share credits the submitting miner 1 unit in a rolling window of the + last *N* shares (window ≈ a few multiples of "shares per found block"). +- When the pool finds a **full-network solution** (a share that also beats the + real target), it submits the block via `/submit/block`; the 8 HAC lands in the + treasury. +- A miner's entitlement over any period = (their shares in window) / (total shares + in window) × (HAC the pool earned in that period). This is **PPLNS**: fair, + hop-resistant, and standard. +- Everything is published (share log + per-miner running balance + settlement + tx hashes) so anyone can verify payouts match work. + +--- + +## 5. Settlement model + +- **Batched fractional transfers.** Every *S* blocks (tunable; e.g. 12 blocks ≈ + 1 hour), pay every miner whose accrued balance ≥ `min_payout` (dust floor, + e.g. 0.01 HAC) in **one** `HacToTrs` tx carrying up to 200 outputs. +- **Sub-block granularity achieved.** A 3050 (~8 MH/s) accrues ~its fair fraction + of every 8 HAC the pool earns, and gets it every settlement window — hours, not + ~11 days. This is the entire point. +- **Fees.** Hacash fees are negligible today; one batched tx per window per ~200 + miners is cheap. Fee is paid by the treasury (a tiny pool fee %, disclosed). +- **Carry-over.** Balances below `min_payout` roll to the next window. + +--- + +## 6. Trust / custody — bounded, guarded, escapable + +This is a **custodial** design between settlements (the pool holds rewards before +redistributing) — there is no fully-trustless smooth option on Hacash today. We +minimize the trust in three stacked layers, shipped in order: + +- **Layer A — Bound it (Phase 1).** Settle frequently (small *S*). The treasury + never holds more than ~*S* blocks' worth. Publish everything. Trust = "operator + won't run off with < one hour of pooled reward, in public." +- **Layer B — Guard it (Phase 2).** Make the treasury a **type3 multisig** + (`ReqSignList`, ≤200 signers). Funds move only with M-of-N community signatures, + so no single operator key can move pooled funds. + Evidence: `protocol/src/transaction/type3.rs`, `protocol/src/action/reqsign.rs`. +- **Layer C — Make it escapable (Phase 3).** Escrow each miner's accrued balance + in a **P2SH lockbox** (`P2SHScriptProve` 46) that the miner can **self-claim** + (hashlock / their key) with a **height-locked refund** — an HTLC-shaped escrow. + If the operator disappears, miners claim their owed balance themselves. This is + the closest thing to trustless the chain offers without a node change. + Evidence: `vm/src/action/p2sh.rs:86`; `vm/src/native/hash.rs`; `HeightScope` + `protocol/src/action/chain.rs:33`; `ViewCheckSign` `vm/src/action/envfunc.rs:82`. + +What we explicitly do **not** do: +- No multi-output/split coinbase — needs a consensus change (org-level). +- No payment-channel streaming — needs registering the modeled channel challenge + action (a node/consensus change). +- No opaque custody. If we can't disclose it and bound it, we don't ship it. + +--- + +## 7. Worker changes (poworker — we own it) + +Today `poworker` talks only to the node's fixed-template API and submits full +solutions (`app/src/poworker.rs`). For the pool it must, additionally: + +1. Pull the pool's template (coinbase = pool treasury) instead of the node's. +2. Mine against the pool's **share target** and submit **shares** (partial proofs) + to the pool over a small pool↔worker protocol (not the node's 2-nonce submit). +3. Report its **payout address** to the pool once at connect. + +This is client software we control; no node change. The GUI adds a "Pool" mode +that already exists — it just points at the pool endpoint (see the pool directory +work in `miner-panel/src/connect.rs`). + +--- + +## 8. Phased roadmap + +| Phase | Deliverable | Custody | Effort | +|-------|-------------|---------|--------| +| **P0** | Honest work-relay / shared node (done) | none | shipped | +| **P1** | Batched PPLNS pool: share validator + accounting + block assembler + batched settlement + modified worker. Frequent, fair, fractional payouts. Layer A trust. | bounded (~1 window) | the real build | +| **P2** | Multisig treasury (Layer B) | bounded + no single key | small, on top of P1 | +| **P3** | P2SH-HTLC per-miner escrow (Layer C) | escapable by miners | larger (lockbox bytecode + audit) | + +Ship P1, run it on the M6 with a few friends, prove demand, then P2/P3. + +--- + +## 9. Parameters (initial guesses — tune on testnet first) + +- Share difficulty factor `D`: start 1/1024, adjust so a mid-GPU emits a few + shares/minute. +- PPLNS window `N`: ≈ 3× (expected shares per found block). +- Settlement cadence `S`: 12 blocks (~1 h) for P1; shorten if treasury feels big. +- `min_payout` dust floor: 0.01 HAC. Pool fee: small, disclosed (e.g. 1%). + +--- + +## 10. Risks & things to verify at implementation + +- **Off-node assembly must be byte-exact.** Serialization, merkle prelude, + `x16rs::block_hash`, and next-difficulty (ASERT) must match the node or blocks + are rejected. Mitigation: the pool is a Rust process **linking the workspace + crates** (`mint`/`protocol`/`x16rs`/`basis`) — reuse, don't reimplement. Verify + exact struct field names/serialization against `protocol/src/block/` and + `mint/src/check/block_build.rs` before coding. +- **Testnet first.** Every gate is bypassed on non-zero `chain_id` + (`protocol/src/upgrade.rs:11`) — build and test the whole flow on testnet with + fake money before a single HAC of real reward is at stake. +- **Fees / mempool.** No mempool-dump RPC exists, so off-node templates are + coinbase-only (no fee txs). Fine while fees ≈ 0; revisit if that changes. +- **Reachability.** A home pool needs a public reachable IP (NAT/CGNAT blocks it) + — orthogonal to this design but required for others to join (see the panel's + reachability caveat). +- **Reorgs / stale.** Standard pool concerns: handle share timing across block + changes, don't credit shares for a stale height. + +--- + +## 11. Bottom line + +On Hacash today the best realizable pool for newcomers is a **transparent PPLNS +pool with frequent batched fractional settlement**, hardened with multisig and +optional P2SH-HTLC escrow. It gives small GPUs smooth, fair, frequent payouts — +the thing whole-block rotation cannot — while keeping custody bounded, guarded, +and (in P3) escapable. Fully trustless smooth payouts would require a consensus +change (multi-output coinbase) or activating trustless channels; both are +node/org-level and out of scope for a community build. diff --git a/docs/MINING-NVIDIA-CUDA.md b/docs/MINING-NVIDIA-CUDA.md index 9d72558..ddf831d 100644 --- a/docs/MINING-NVIDIA-CUDA.md +++ b/docs/MINING-NVIDIA-CUDA.md @@ -2,66 +2,51 @@ Native CUDA block miner for Hacash, integrated with the existing `poworker` + fullnode RPC stack (same protocol as OpenCL/CPU miners). -> **Work in progress — NOT production ready.** Kernels compile on Windows (CUDA 12.x/13.x + MSVC Build Tools), but **GPU runtime has not been validated** on this dev machine (no local NVIDIA GPU). Genesis test and end-to-end mining require an **RTX tester** — see [HANDOFF-RTX.md](../scripts/mining-nvidia/HANDOFF-RTX.md). +## Status (requirement 2) -**Status:** Kernels compile on Windows (CUDA 12.x/13.x + MSVC Build Tools). GPU runtime validation requires an NVIDIA RTX machine. +| Item | Status | +|------|--------| +| Kernels + host (`x16rs-cuda`) | Yes | +| CUDA Toolkit | **12.x / 13.x** | +| GPU arch fatbin | SASS **sm_75** (T4/20xx), **sm_86** (30xx), **sm_89** (40xx) **+ PTX `compute_89`** | +| Newer GPUs (sm_90 Hopper, sm_120 Blackwell/50xx) | **JIT via embedded PTX** (driver compiles at launch; no source edit) | +| Runtime validation | **PASS on NVIDIA Tesla T4** (Google Colab, 2026-07-22); sm_86/89 via fatbin, sm_90+ via PTX (not yet runtime-verified) | +| Tests | `cargo test -p x16rs-cuda --features cuda` → **4 passed** | -**RTX handoff:** [scripts/mining-nvidia/HANDOFF-RTX.md](../scripts/mining-nvidia/HANDOFF-RTX.md) (Greek + English checklist for testers). +Colab free-tier smoke: [scripts/mining-nvidia/COLAB-T4.md](../scripts/mining-nvidia/COLAB-T4.md). ## Requirements -- NVIDIA GPU (RTX 20xx / 30xx / 40xx — sm_75 / sm_86 / sm_89) +- NVIDIA GPU (T4 / RTX 20xx / 30xx / 40xx) - [CUDA Toolkit](https://developer.nvidia.com/cuda-downloads) 12.x or 13.x -- Windows: [Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/) with **Desktop development with C++** (`cl.exe` for nvcc) +- Windows: VS Build Tools with C++ (`cl.exe` for nvcc) **or** Linux + nvcc - Rust toolchain (edition 2024) -- Fullnode with `[miner] enable = true` +- Fullnode with miner API, **or** `hac-pool` in front of it -## Build (Windows) +## Build + +### Windows ```bat scripts\mining-nvidia\BUILD-CUDA-MINER.bat ``` -The script auto-detects `CUDA_PATH`, runs `vcvars64.bat`, and builds `target\release\poworker.exe`. - -Manual build: +### Linux / Colab -```bat -call "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\VC\Auxiliary\Build\vcvars64.bat" -set CUDA_PATH=C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.3 +```bash +export CUDA_PATH=/usr/local/cuda +cargo test -p x16rs-cuda --features cuda cargo build --release --bin poworker --features cuda ``` -Successful kernel build prints: `Using CUDA Toolkit at ...` (no `build without GPU kernels` warning). - -## RTX tester handoff checklist - -Run on a machine **with NVIDIA GPU**: - -```bat -scripts\mining-nvidia\BUILD-CUDA-MINER.bat -scripts\mining-nvidia\TEST-CUDA-GPU.bat -scripts\mining-nvidia\INSTALL-CUDA-CONFIG.bat -scripts\mining-nvidia\START-CUDA-MINING.bat -``` - -1. **Genesis GPU test** must pass: - - Expected hash: `000000077790ba2fcdeaef4a4299d9b667135bac577ce204dee8388f1b97f7e6` -2. **poworker startup** must show: - - `[CUDA] Device #0: ...` - - `[CUDA] Initialized device #0 work_groups=...` -3. **Mining** against a fullnode with pending work — submit a block or report hashrate logs. - -Report back: GPU model, CUDA version, genesis test result, and any `nvcc`/runtime errors. - -Example config: `scripts/mining-nvidia/poworker.cuda.ini.example` +Successful kernel build logs: `Using CUDA Toolkit at ...` ## Configure (`poworker.config.ini`) ```ini [default] connect = 127.0.0.1:8080 -supervene = 4 +; or public pool: connect = POOL_IP:3333 [gpu] use_cuda = true @@ -69,30 +54,16 @@ use_opencl = false cuda_device = 0 work_groups = 131072 unit_size = 8 -cpu_assist = true ``` -CUDA takes priority over OpenCL when `use_cuda = true`. - ## Architecture | Layer | Path | -|-------|------| -| RPC / work loop | `app/src/poworker.rs` (unchanged protocol) | +|------|------| +| RPC / work loop | `app/src/poworker.rs` | | CUDA backend | `app/src/cuda_pow.rs` | | GPU kernels | `x16rs-cuda/cuda/block_miner.cu` | -| OpenCL reuse | `x16rs/opencl/*.cl` via `ocl_compat.cuh` | - -Kernels implement the same x16rs flow as OpenCL: SHA3-256 block intro → x16rs chain → nonce batch search. - -## Tests - -```bat -cargo test -p x16rs-cuda --features cuda -``` - -Genesis vector (`x16rs/tests/test.rs`) is cross-checked on GPU when CUDA is available. -## vs community CUDA miners +## vs third-party CUDA pool miners -This miner uses **official fullnode RPC** (`/query/miner/pending`, `/submit/miner/success`). Third-party CUDA pool miners use a different protocol and are not drop-in replacements. \ No newline at end of file +This miner uses **official fullnode / hac-pool miner RPC**. Closed Stratum-only miners are not drop-in; use `hac-pool` HTTP port with stock `poworker`, or Stratum port with a compatible client. diff --git a/miner-panel/src/config.rs b/miner-panel/src/config.rs index 2a35975..a896161 100644 --- a/miner-panel/src/config.rs +++ b/miner-panel/src/config.rs @@ -37,6 +37,10 @@ pub struct PanelSettings { pub thermal_gpu_index: u32, pub work_groups: u32, pub unit_size: u32, + /// poworker: max nonce searched per batch (default u32::MAX). + pub nonce_max: u32, + /// poworker: seconds to wait for a new-block notice (default 45). + pub notice_wait: u64, } const BENCHMARK_BACKUP_PRESENT: &str = "HACASH_MINER_PANEL_AUTOTUNE_BACKUP_V1:PRESENT\n"; @@ -441,8 +445,8 @@ pub fn write_poworker_config(path: &Path, s: &PanelSettings) -> std::io::Result< r"; Generated by miner-panel: do not edit by hand; use the panel UI. connect = {connect} supervene = {sv} -nonce_max = 4294967295 -notice_wait = 45 +nonce_max = {nonce_max} +notice_wait = {notice_wait} {efficiency} [gpu] @@ -462,6 +466,8 @@ debug = 0 ", connect = s.connect, sv = s.cpu.supervene, + nonce_max = s.nonce_max, + notice_wait = s.notice_wait, // Thermal cap must be below full load; half of WG (min 1) actually reduces heat. efficiency = efficiency_section( s, @@ -578,6 +584,8 @@ mod write_tuning_tests { thermal_gpu_index: 0, work_groups: wg, unit_size: us, + nonce_max: u32::MAX, + notice_wait: 45, } } @@ -820,6 +828,8 @@ impl PanelSettings { thermal_gpu_index: self.thermal_gpu_index, work_groups: self.work_groups, unit_size: self.unit_size, + nonce_max: self.nonce_max, + notice_wait: self.notice_wait, } } } diff --git a/miner-panel/src/connect.rs b/miner-panel/src/connect.rs index e922917..624b83e 100644 --- a/miner-panel/src/connect.rs +++ b/miner-panel/src/connect.rs @@ -1,3 +1,7 @@ +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::time::{Duration, Instant}; + #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum ConnectMode { /// Local hacash.exe fullnode (solo mining, rewards to your wallet). @@ -87,27 +91,163 @@ pub fn is_local_connect(connect: &str) -> bool { host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1" } +/// One selectable entry in the pool directory. +/// /// 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, +/// (/query/miner/pending, /query/miner/notice, /submit/miner/success). The base +/// Hacash worker protocol is just `connect = host:port` — there is no separate +/// pool auth/stratum layer — so any node or pool that speaks this API is +/// reachable by pointing `connect` at it. New pools can therefore be added +/// without rebuilding the panel: drop a `pools.json` next to the exe (see +/// [`load_pool_directory`]) and they appear in the dropdown. +#[derive(Clone, Debug, PartialEq)] +pub struct PoolInfo { + /// Display name in the dropdown. + pub name: String, + /// host:port that speaks the miner API. Empty = the user must paste it + /// (used for pools that hand out their address via a web config generator). + pub connect: String, + /// One-line guidance shown under the dropdown. + pub note: String, + /// Optional "learn more / get your address" link. + pub url: String, + /// True only for endpoints we actually connected to and verified. + pub verified: bool, + /// Optional per-pool worker overrides, applied when the pool is selected. + /// `None` keeps the panel's current value. + pub nonce_max: Option, + pub notice_wait: Option, +} + +impl PoolInfo { + fn simple(name: &str, connect: &str, note: &str, url: &str) -> PoolInfo { + PoolInfo { + name: name.to_string(), + connect: connect.to_string(), + note: note.to_string(), + url: url.to_string(), + verified: false, + nonce_max: None, + notice_wait: None, + } + } } -pub fn pool_presets() -> Vec { +/// The pools that ship with the panel. Always present, even offline. +/// Community payout pools hand out their `host:port` through a web config +/// generator, so we cannot hard-code a verified address; the user pastes it +/// (or we publish it later via `pools.json`, with no rebuild). +pub fn builtin_pools() -> Vec { vec![ - PoolPreset { - label: "Custom pool host", - host: "", - }, - PoolPreset { - label: "LAN fullnode / cluster", - host: "192.168.1.10:8080", - }, + PoolInfo::simple( + "Custom pool / node", + "", + "Enter any host:port that runs the Hacash miner API (a pool or a shared full node).", + "", + ), + PoolInfo::simple( + "LAN full node / cluster", + "192.168.1.10:8080", + "Point every PC on your network at one full node; their hashrate adds up.", + "", + ), + PoolInfo::simple( + "Hacash.Diamonds pool", + "", + "Community pool. Get your host:port from the pool page, then paste it above.", + "https://www.hacash.diamonds/pool", + ), + PoolInfo::simple( + "Hacash Community (HACPool)", + "", + "Community pool: PROP payouts, low fee, small minimum. Get host:port from the pool site.", + "https://pool.hacash.community", + ), + PoolInfo::simple( + "HacashPool.com", + "", + "Community pool. Get your host:port from the pool site, then paste it above.", + "https://hacashpool.com", + ), ] } +#[derive(serde::Deserialize)] +struct PoolJson { + name: String, + #[serde(default)] + connect: String, + #[serde(default)] + note: String, + #[serde(default)] + url: String, + #[serde(default)] + verified: bool, + #[serde(default)] + nonce_max: Option, + #[serde(default)] + notice_wait: Option, +} + +/// Build the pool directory: the built-in list, then merge an optional +/// `pools.json` sitting next to the panel. Entries whose `name` matches a +/// built-in override it (so a verified address can be published for a known +/// pool); new names are appended. A fresh pool therefore appears in the panel +/// by shipping/downloading a `pools.json` — no rebuild required. A missing or +/// malformed file simply falls back to the built-ins. +pub fn load_pool_directory(dir: &Path) -> Vec { + let mut pools = builtin_pools(); + let Ok(raw) = std::fs::read_to_string(dir.join("pools.json")) else { + return pools; + }; + let Ok(entries) = serde_json::from_str::>(&raw) else { + return pools; + }; + for e in entries { + if e.name.trim().is_empty() { + continue; + } + let info = PoolInfo { + name: e.name, + connect: e.connect, + note: e.note, + url: e.url, + verified: e.verified, + nonce_max: e.nonce_max, + notice_wait: e.notice_wait, + }; + match pools + .iter_mut() + .find(|p| p.name.eq_ignore_ascii_case(&info.name)) + { + Some(slot) => *slot = info, + None => pools.push(info), + } + } + pools +} + +/// Best-effort reachability check: resolve `connect` (host:port) and open a TCP +/// connection with a short timeout. Confirms the endpoint is listening and +/// reachable FROM HERE. It cannot prove external/NAT reachability of a pool you +/// host — only that this machine can open the socket. Returns the elapsed +/// milliseconds on success, or a human-readable error. +pub fn probe_reachable(connect: &str, timeout_ms: u64) -> Result { + let addr = normalize_connect(connect)?; + let socket_addrs = addr + .to_socket_addrs() + .map_err(|e| format!("cannot resolve {addr}: {e}"))?; + let started = Instant::now(); + let mut last_err = format!("no address resolved for {addr}"); + for sa in socket_addrs { + match TcpStream::connect_timeout(&sa, Duration::from_millis(timeout_ms)) { + Ok(_) => return Ok(started.elapsed().as_millis()), + Err(e) => last_err = e.to_string(), + } + } + Err(last_err) +} + #[cfg(test)] mod tests { use super::*; @@ -149,4 +289,59 @@ mod tests { assert!(normalize_connect("pool.example:0").is_err()); assert!(normalize_connect("pool.example").is_err()); } + + #[test] + fn pool_directory_merges_and_overrides_pools_json() { + let dir = std::env::temp_dir().join(format!( + "hacash-pooldir-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + + // No pools.json -> built-ins only, "Custom" first. + let base = load_pool_directory(&dir); + assert_eq!(base[0].name, "Custom pool / node"); + let base_len = base.len(); + + std::fs::write( + dir.join("pools.json"), + r#"[ + {"name":"Hacash.Diamonds pool","connect":"1.2.3.4:8080","verified":true}, + {"name":"Fresh Community Pool","connect":"5.6.7.8:3333","notice_wait":30} + ]"#, + ) + .unwrap(); + let merged = load_pool_directory(&dir); + + // Same name -> overridden in place (new connect + verified flag). + let diamonds = merged + .iter() + .find(|p| p.name == "Hacash.Diamonds pool") + .unwrap(); + assert_eq!(diamonds.connect, "1.2.3.4:8080"); + assert!(diamonds.verified); + + // New name -> appended, with its optional override parsed. + let fresh = merged + .iter() + .find(|p| p.name == "Fresh Community Pool") + .unwrap(); + assert_eq!(fresh.connect, "5.6.7.8:3333"); + assert_eq!(fresh.notice_wait, Some(30)); + + assert_eq!(merged.len(), base_len + 1); + assert_eq!(merged[0].name, "Custom pool / node"); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn probe_reachable_rejects_invalid_address() { + assert!(probe_reachable("", 100).is_err()); + assert!(probe_reachable("not-a-host-port", 100).is_err()); + } } diff --git a/miner-panel/src/fleet.rs b/miner-panel/src/fleet.rs index 493faac..5750346 100644 --- a/miner-panel/src/fleet.rs +++ b/miner-panel/src/fleet.rs @@ -585,6 +585,158 @@ impl FleetState { }); } + /// Dedicated "Master Panel" tab: a table of every worker reporting to this panel — + /// the local miner plus each remote / VPS miner that enabled sharing and was added + /// as a peer. One row per worker: status, live hashrate, estimated HAC/day, power and + /// the synced block height (so you can see at a glance which rigs are up and on tip). + pub fn show_master(&mut self, ui: &mut egui::Ui, local: &MiningStatsSnapshot) { + let online: Vec<&PeerResult> = + self.results.iter().filter(|r| r.stats.is_some()).collect(); + let mut total_hr = local.hashrate_hps; + let mut total_hac = local.hac_per_day; + let mut total_w = local.watts; + for r in &online { + if let Some(s) = &r.stats { + total_hr += s.hashrate_hps; + total_hac += s.hac_per_day; + total_w += s.watts; + } + } + + theme::section_card().show(ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new("MASTER PANEL • ALL WORKERS") + .strong() + .size(12.0) + .color(theme::colors::ACCENT), + ); + ui.label( + egui::RichText::new("Local miner plus every remote / VPS miner reporting here") + .color(theme::colors::TEXT_MUTED) + .size(11.5), + ); + }); + if self.poll_running_generation.is_some() { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spinner(); + }); + } + }); + ui.add_space(10.0); + egui::Grid::new("master_totals") + .num_columns(4) + .spacing([12.0, 10.0]) + .min_col_width(170.0) + .show(ui, |ui| { + theme::show_stat_card( + ui, + theme::colors::ACCENT, + "Total hashrate", + &format_hashrate(total_hr), + ); + theme::show_stat_card( + ui, + theme::colors::GOLD, + "Workers online", + &format!("{} / {}", online.len() + 1, self.config.peers.len() + 1), + ); + theme::show_stat_card( + ui, + theme::colors::ACCENT, + "Total power", + &format!("{total_w:.0} W"), + ); + theme::show_stat_card( + ui, + theme::colors::GOLD, + "Est. HAC / day", + &format!("{total_hac:.4}"), + ); + ui.end_row(); + }); + + ui.add_space(12.0); + egui::Grid::new("master_workers") + .num_columns(6) + .striped(true) + .spacing([16.0, 8.0]) + .min_col_width(90.0) + .show(ui, |ui| { + for h in ["Status", "Worker", "Hashrate", "Est. HAC/day", "Power", "Height"] { + ui.label( + egui::RichText::new(h) + .strong() + .size(11.5) + .color(theme::colors::TEXT_MUTED), + ); + } + ui.end_row(); + + // Local miner row. + ui.label(egui::RichText::new("\u{25cf}").color(theme::colors::ACCENT)); + ui.label("This PC (local)"); + ui.label(format_hashrate(local.hashrate_hps)); + ui.label(format!("{:.4}", local.hac_per_day)); + ui.label(format!("{:.0} W", local.watts)); + ui.label(if local.height > 0 { + local.height.to_string() + } else { + "-".to_string() + }); + ui.end_row(); + + // One row per configured remote miner. + for peer in &self.config.peers { + let stats = self + .results + .iter() + .find(|r| r.peer.address == peer.address) + .and_then(|r| r.stats.as_ref()); + match stats { + Some(s) => { + ui.label(egui::RichText::new("\u{25cf}").color(theme::colors::ACCENT)); + ui.label(&peer.name); + ui.label(format_hashrate(s.hashrate_hps)); + ui.label(format!("{:.4}", s.hac_per_day)); + ui.label(format!("{:.0} W", s.watts)); + ui.label(if s.height > 0 { + s.height.to_string() + } else { + "-".to_string() + }); + } + None => { + ui.label( + egui::RichText::new("\u{25cf}").color(theme::colors::TEXT_MUTED), + ); + ui.label(&peer.name); + ui.label( + egui::RichText::new("offline").color(theme::colors::TEXT_MUTED), + ); + ui.label("-"); + ui.label("-"); + ui.label("-"); + } + } + ui.end_row(); + } + }); + + if self.config.peers.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new( + "No remote miners yet. On each VPS/remote panel enable LAN sharing, then add it under the Dashboard tab -> Manage miners (name + address + token).", + ) + .color(theme::colors::TEXT_MUTED) + .size(11.0), + ); + } + }); + } + pub fn show_dashboard(&mut self, ui: &mut egui::Ui, local: &MiningStatsSnapshot) { let online: Vec<&PeerResult> = self .results diff --git a/miner-panel/src/i18n.rs b/miner-panel/src/i18n.rs index 728d732..a096935 100644 --- a/miner-panel/src/i18n.rs +++ b/miner-panel/src/i18n.rs @@ -334,7 +334,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. Settings → HACD → wallet, bid password, min/max/step (format 1 = 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: "HAC GPU mining uses OpenCL only (AMD/NVIDIA/Intel): no CUDA. HACD is CPU/full-node only.", + help_hardware_note: "HAC GPU mining uses OpenCL (AMD/Intel) or CUDA (NVIDIA). HACD is CPU/full-node only.", help_options_title: "Options reference (panel + .ini + executables)", no_gpu: "No GPU", label_language: "Language:", @@ -446,7 +446,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. Ρυθμίσεις → HACD → wallet, κωδικός bid, min/max/step (1 = 1 HAC).", help_hacd_step4: "4. Αποθήκευση & Έναρξη: diaworker κάνει mine· fullnode κάνει bid στο [diamondminer].", help_hacd_step5: "5. Κάνε restart το fullnode μετά από αλλαγή wallet ή bid.", - help_hardware_note: "Το HAC GPU mining χρησιμοποιεί μόνο OpenCL (AMD/NVIDIA/Intel): ποτέ CUDA. Το HACD είναι αποκλειστικά CPU/full-node.", + help_hardware_note: "Το HAC GPU mining χρησιμοποιεί OpenCL (AMD/Intel) ή CUDA (NVIDIA). Το HACD είναι αποκλειστικά CPU/full-node.", help_options_title: "Αναφορά επιλογών (panel + .ini + executables)", no_gpu: "Χωρίς GPU", label_language: "Γλώσσα:", @@ -558,7 +558,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. Ayarlar → HACD → cüzdan, teklif şifresi, min/max/step (1 = 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: "HAC GPU madenciliği yalnızca OpenCL kullanır (AMD/NVIDIA/Intel): CUDA yok. HACD yalnızca CPU/full-node kullanır.", + help_hardware_note: "HAC GPU madenciliği OpenCL (AMD/Intel) veya CUDA (NVIDIA) kullanır. HACD yalnızca CPU/full-node kullanır.", help_options_title: "Seçenekler referansı (panel + .ini + exe)", no_gpu: "GPU yok", label_language: "Dil:", @@ -670,7 +670,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. 设置 → HACD → 钱包、竞价密码、min/max/step(1 = 1 HAC)。", help_hacd_step4: "4. 保存并启动: diaworker 挖矿;全节点通过 [diamondminer] 自动竞价。", help_hacd_step5: "5. 更改钱包或竞价后请重启全节点。", - help_hardware_note: "HAC GPU 挖矿仅使用 OpenCL(AMD/NVIDIA/Intel),不使用 CUDA。HACD 仅支持 CPU/全节点挖矿。", + help_hardware_note: "HAC GPU 挖矿使用 OpenCL(AMD/Intel)或 CUDA(NVIDIA)。HACD 仅支持 CPU/全节点挖矿。", help_options_title: "选项参考(面板 + .ini + 可执行文件)", no_gpu: "无 GPU", label_language: "语言:", @@ -782,7 +782,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. 設定 → HACD → ウォレット、入札パスワード、min/max/step(1 = 1 HAC)。", help_hacd_step4: "4. 保存して開始: diaworker がマイニング、fullnode が [diamondminer] で入札。", help_hacd_step5: "5. ウォレットまたは入札変更後は fullnode を再起動。", - help_hardware_note: "HAC の GPU マイニングは OpenCL のみ(AMD/NVIDIA/Intel)、CUDA は不使用。HACD は CPU/フルノード専用です。", + help_hardware_note: "HAC の GPU マイニングは OpenCL(AMD/Intel)または CUDA(NVIDIA)を使用します。HACD は CPU/フルノード専用です。", help_options_title: "オプション一覧(パネル + .ini + 実行ファイル)", no_gpu: "GPU なし", label_language: "言語:", @@ -894,7 +894,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. Ajustes → HACD → wallet, contraseña puja, min/max/step (1 = 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: "La minería GPU de HAC usa solo OpenCL (AMD/NVIDIA/Intel), nunca CUDA. HACD es solo CPU/full-node.", + help_hardware_note: "La minería GPU de HAC usa OpenCL (AMD/Intel) o CUDA (NVIDIA). HACD es solo CPU/full-node.", help_options_title: "Referencia de opciones (panel + .ini + ejecutables)", no_gpu: "Sin GPU", label_language: "Idioma:", @@ -1006,7 +1006,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. Réglages → HACD → wallet, mot de passe enchère, min/max/step (1 = 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: "Le minage GPU HAC utilise uniquement OpenCL (AMD/NVIDIA/Intel), jamais CUDA. HACD est uniquement CPU/full-node.", + help_hardware_note: "Le minage GPU HAC utilise OpenCL (AMD/Intel) ou CUDA (NVIDIA). HACD est uniquement CPU/full-node.", help_options_title: "Référence des options (panel + .ini + exécutables)", no_gpu: "Sans GPU", label_language: "Langue :", @@ -1118,7 +1118,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. ตั้งค่า → HACD → กระเป๋า รหัสประมูล min/max/step (1 = 1 HAC)", help_hacd_step4: "4. บันทึกและเริ่ม: diaworker ขุด fullnode ประมูลผ่าน [diamondminer]", help_hacd_step5: "5. รีสตาร์ท fullnode หลังเปลี่ยนกระเป๋าหรือการประมูล", - help_hardware_note: "การขุด HAC ด้วย GPU ใช้ OpenCL เท่านั้น (AMD/NVIDIA/Intel) ไม่ใช้ CUDA ส่วน HACD ใช้ CPU/full-node เท่านั้น", + help_hardware_note: "การขุด HAC ด้วย GPU ใช้ OpenCL (AMD/Intel) หรือ CUDA (NVIDIA) ส่วน HACD ใช้ CPU/full-node เท่านั้น", help_options_title: "คู่มือตัวเลือก (แผง + .ini + โปรแกรม)", no_gpu: "ไม่มี GPU", label_language: "ภาษา:", @@ -1230,7 +1230,7 @@ pub fn strings(lang: Lang) -> Strings { help_hacd_step3: "3. Настройки → HACD → кошелёк, пароль ставок, min/max/step (1 = 1 HAC).", help_hacd_step4: "4. Сохранить и Старт: diaworker майнит; fullnode ставит через [diamondminer].", help_hacd_step5: "5. Перезапустите fullnode после смены кошелька или ставок.", - help_hardware_note: "GPU-майнинг HAC использует только OpenCL (AMD/NVIDIA/Intel), без CUDA. HACD работает только на CPU/full-node.", + help_hardware_note: "GPU-майнинг HAC использует OpenCL (AMD/Intel) или CUDA (NVIDIA). HACD работает только на CPU/full-node.", help_options_title: "Справочник опций (панель + .ini + exe)", no_gpu: "Без GPU", label_language: "Язык:", diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs index 4cb3e60..886601c 100644 --- a/miner-panel/src/main.rs +++ b/miner-panel/src/main.rs @@ -34,7 +34,9 @@ use config::{ recover_interrupted_benchmark, restore_benchmark_backup, write_diaworker_config, write_poworker_benchmark_config, write_poworker_config, }; -use connect::{ConnectMode, SOLO_DEFAULT, connect_port, normalize_connect, pool_presets}; +use connect::{ + ConnectMode, PoolInfo, SOLO_DEFAULT, connect_port, load_pool_directory, normalize_connect, +}; use currency::{Currency, load_currency, save_currency}; use eframe::egui; use hacash_config::{ @@ -106,6 +108,14 @@ struct MinerApp { connect: String, connect_mode: ConnectMode, pool_preset_idx: usize, + /// Built-in pools plus any from a `pools.json` next to the exe (updatable). + pool_directory: Vec, + /// poworker knobs surfaced in the GUI so no file editing is ever needed. + nonce_max: u32, + notice_wait: u64, + /// Result text of the last "Test connection" / "Test upstream" reachability probe. + connect_test_status: String, + upstream_test_status: String, max_temp_c: u32, pause_unprofitable: bool, work_groups: u32, @@ -281,6 +291,7 @@ impl MinerApp { } } let connect_mode = ConnectMode::for_connect(&connect); + let pool_directory = load_pool_directory(&work_dir); let mut app = Self { work_dir, config_path, @@ -312,6 +323,11 @@ impl MinerApp { connect, connect_mode, pool_preset_idx: 0, + pool_directory, + nonce_max: u32::MAX, + notice_wait: 45, + connect_test_status: String::new(), + upstream_test_status: String::new(), max_temp_c, pause_unprofitable, work_groups, @@ -517,9 +533,16 @@ impl MinerApp { thermal_gpu_index: self.device_id, work_groups: self.work_groups, unit_size: self.unit_size, + nonce_max: self.nonce_max, + notice_wait: self.notice_wait, } } + /// Master Panel tab: the fleet worker table (local miner + remote/VPS miners). + fn ui_master(&mut self, ui: &mut egui::Ui) { + self.fleet.show_master(ui, &self.stats); + } + fn set_mining_kind(&mut self, kind: MiningKind) { if kind == self.mining_kind { return; @@ -723,11 +746,28 @@ impl MinerApp { 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(); - } + let Some(p) = self.pool_directory.get(idx).cloned() else { + return; + }; + // Empty connect = a pool whose address comes from its web config + // generator; keep whatever the user has typed and let the note guide them. + if !p.connect.is_empty() { + self.connect = p.connect; + } + if let Some(v) = p.nonce_max { + self.nonce_max = v; + } + if let Some(v) = p.notice_wait { + self.notice_wait = v; + } + } + + /// Re-read `pools.json` next to the exe so freshly published pools appear + /// without restarting the panel. + fn refresh_pool_directory(&mut self) { + self.pool_directory = load_pool_directory(&self.work_dir); + if self.pool_preset_idx >= self.pool_directory.len() { + self.pool_preset_idx = 0; } } @@ -1207,30 +1247,24 @@ impl eframe::App for MinerApp { ) { self.tab = 1; } + if theme::tab_pill(ui, self.tab == 3, theme::TabIcon::Dashboard, "Master Panel") + { + self.tab = 3; + } 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); - } - }); - } - _ => { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| self.ui_dashboard(ui)); - } - } + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| match self.tab { + 0 => self.ui_settings(ui), + 2 => self.ui_help(ui), + 3 => self.ui_master(ui), + _ => self.ui_dashboard(ui), + }); }); } diff --git a/miner-panel/src/mining_control.rs b/miner-panel/src/mining_control.rs index df585ab..c6672d3 100644 --- a/miner-panel/src/mining_control.rs +++ b/miner-panel/src/mining_control.rs @@ -63,6 +63,17 @@ impl MinerApp { return; } + // All-in-one: start local public pool before mining when hosting is enabled. + if self.mining_kind == MiningKind::Hac + && self.public_pool.host_enabled + && !self.public_pool_running + { + self.start_public_pool(); + if !self.public_pool_running { + return; + } + } + self.restart_worker = None; self.restart_attempts = 0; if self.mining_kind == MiningKind::Hac && self.gpu_presets[self.gpu_idx].slug != "none" { diff --git a/miner-panel/src/ui_settings_tab.rs b/miner-panel/src/ui_settings_tab.rs index 8bddab4..6bcb4af 100644 --- a/miner-panel/src/ui_settings_tab.rs +++ b/miner-panel/src/ui_settings_tab.rs @@ -4,10 +4,19 @@ use eframe::egui; use crate::MinerApp; use crate::OpenClAction; -use crate::connect::{ConnectMode, pool_presets}; +use crate::connect::{ConnectMode, PoolInfo}; use crate::mining_kind::MiningKind; use crate::theme; +/// Dropdown label for a pool: appends a check mark for endpoints we verified. +fn pool_menu_label(p: &PoolInfo) -> String { + if p.verified { + format!("{} \u{2713}", p.name) + } else { + p.name.clone() + } +} + impl MinerApp { pub(super) fn ui_settings(&mut self, ui: &mut egui::Ui) { let t = self.t(); @@ -282,24 +291,42 @@ impl MinerApp { }, ); ui.vertical(|ui| { - if self.connect_mode == ConnectMode::Pool && self.mining_kind == MiningKind::Hac { - let pools = pool_presets(); - let preset_label = pools + let hac_pool = self.connect_mode == ConnectMode::Pool + && self.mining_kind == MiningKind::Hac; + if hac_pool { + // Clone the directory so the combo closure can call + // &mut self (apply/refresh) without aliasing self. + let pools = self.pool_directory.clone(); + let selected_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); + .map(pool_menu_label) + .unwrap_or_else(|| "Pool".to_string()); + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt("pool_preset") + .selected_text(selected_label) + .width(300.0) + .show_ui(ui, |ui| { + for (i, p) in pools.iter().enumerate() { + if ui + .selectable_value( + &mut self.pool_preset_idx, + i, + pool_menu_label(p), + ) + .clicked() + { + self.apply_pool_preset(i); + } } - } - }); + }); + if ui + .button("Refresh") + .on_hover_text("Reload pools.json next to the panel") + .clicked() + { + self.refresh_pool_directory(); + } + }); } ui.add( egui::TextEdit::singleline(&mut self.connect) @@ -307,39 +334,129 @@ impl MinerApp { .margin(egui::Margin::symmetric(8.0, 6.0)), ); if self.connect_mode == ConnectMode::Pool { - ui.label( - egui::RichText::new(if self.mining_kind == MiningKind::Hacd { - "All HACD miners may point to the same full node; its hashrate is accumulated." - } else { - t.connect_pool_hint - }) + ui.horizontal(|ui| { + if ui + .button("Test connection") + .on_hover_text("Check the address is reachable from this PC") + .clicked() + { + self.connect_test_status = match crate::connect::probe_reachable( + &self.connect, + 1500, + ) { + Ok(ms) => format!("Reachable ({} ms)", ms), + Err(e) => format!("Not reachable: {}", e), + }; + } + if !self.connect_test_status.is_empty() { + let color = if self.connect_test_status.starts_with("Reachable") { + theme::colors::GREEN + } else { + theme::colors::GOLD_DIM + }; + ui.label( + egui::RichText::new(&self.connect_test_status) + .size(11.5) + .color(color), + ); + } + }); + } + if self.connect_mode == ConnectMode::Pool { + if hac_pool { + // Per-pool guidance + link from the directory entry. + if let Some(p) = + self.pool_directory.get(self.pool_preset_idx).cloned() + { + let note = if p.note.is_empty() { + t.connect_pool_hint.to_string() + } else { + p.note.clone() + }; + ui.label( + egui::RichText::new(note) + .size(11.5) + .color(theme::colors::TEXT_MUTED), + ); + if !p.url.is_empty() { + ui.hyperlink_to(format!("Open {}", p.url), &p.url); + } + } + } else { + ui.label( + egui::RichText::new( + "All HACD miners may point to the same full node; its hashrate is accumulated.", + ) .size(11.5) .color(theme::colors::TEXT_MUTED), - ); + ); + } } }); ui.end_row(); }); }); + // Everything a different pool might need, editable from the GUI so the + // user never has to open poworker.config.ini. Defaults suit every pool; + // a directory entry can also preset these when a pool is selected. + if self.mining_kind == MiningKind::Hac && self.connect_mode == ConnectMode::Pool { + ui.add_space(8.0); + egui::CollapsingHeader::new("Advanced worker settings (optional)") + .default_open(false) + .show(ui, |ui| { + ui.label( + egui::RichText::new( + "Only change these if a pool documents specific values.", + ) + .size(11.0) + .color(theme::colors::TEXT_MUTED), + ); + egui::Grid::new("adv_worker_grid") + .num_columns(2) + .spacing([20.0, 8.0]) + .show(ui, |ui| { + theme::field_label(ui, "nonce_max"); + ui.add(egui::DragValue::new(&mut self.nonce_max)); + ui.end_row(); + theme::field_label(ui, "notice_wait (s)"); + ui.add(egui::DragValue::new(&mut self.notice_wait).range(1..=600)); + ui.end_row(); + }); + if ui.button("Reset to defaults").clicked() { + self.nonce_max = u32::MAX; + self.notice_wait = 45; + } + }); + } + // All-in-one public free-IP pool (hac-pool) if self.mining_kind == MiningKind::Hac { ui.add_space(12.0); theme::section_card().show(ui, |ui| { ui.label( - egui::RichText::new("PUBLIC FREE-IP POOL (ALL-IN-ONE)") + egui::RichText::new("SHARED NODE / OPEN WORK RELAY") .strong() .size(12.0) .color(theme::colors::ACCENT), ); ui.label( egui::RichText::new( - "Host a public pool from this PC. Others connect with your IP:HTTP port. \ -Local mining can use 127.0.0.1 via the pool. Requires hac-pool.exe next to the panel.", + "Share this PC's mining work so others can point their miners at YOUR IP:HTTP port \ +(local mining can use 127.0.0.1). Requires hac-pool.exe next to the panel.", ) .size(11.5) .color(theme::colors::TEXT_MUTED), ); + ui.label( + egui::RichText::new( + "Honest note: this is a work relay, not a share/payout pool. Any block found is \ +minted to THIS node's reward wallet (the host) - connected workers help find blocks but are not \ +individually paid. No share accounting or payouts (v1).", + ) + .size(11.0) + .color(theme::colors::GOLD_DIM), + ); ui.add_space(8.0); let mut host = self.public_pool.host_enabled; @@ -440,6 +557,35 @@ Local mining can use 127.0.0.1 via the pool. Requires hac-pool.exe next to the p ui.label(egui::RichText::new(badge.0).color(badge.1).strong()); }); + ui.horizontal(|ui| { + if ui + .button("Test upstream") + .on_hover_text("Check the upstream full node is reachable from this PC") + .clicked() + { + self.upstream_test_status = match crate::connect::probe_reachable( + &self.public_pool.upstream, + 1500, + ) { + Ok(ms) => format!("Upstream reachable ({} ms)", ms), + Err(e) => format!("Upstream not reachable: {}", e), + }; + } + if !self.upstream_test_status.is_empty() { + let color = + if self.upstream_test_status.starts_with("Upstream reachable") { + theme::colors::GREEN + } else { + theme::colors::GOLD_DIM + }; + ui.label( + egui::RichText::new(&self.upstream_test_status) + .size(11.5) + .color(color), + ); + } + }); + if !self.public_pool_status.is_empty() { ui.label( egui::RichText::new(&self.public_pool_status) @@ -449,7 +595,9 @@ Local mining can use 127.0.0.1 via the pool. Requires hac-pool.exe next to the p } ui.label( egui::RichText::new(format!( - "External workers: connect = YOUR_PUBLIC_IP:{} (firewall must allow it)", + "External workers connect to YOUR_PUBLIC_IP:{}. For this to reach them over \ +the internet you need a public IP and the port forwarded/allowed by your router + firewall (home \ +NAT/CGNAT often blocks it). This panel cannot verify external reachability - test from another network.", self.public_pool.http_port )) .size(11.0) diff --git a/x16rs-cuda/build.rs b/x16rs-cuda/build.rs index 91d20dc..53e0051 100644 --- a/x16rs-cuda/build.rs +++ b/x16rs-cuda/build.rs @@ -15,6 +15,24 @@ fn discover_cuda_root() -> Option { return entry.path().to_str().map(|s| s.to_string()); } } + return None; + } + // Linux / Colab: common toolkit prefixes + for candidate in [ + "/usr/local/cuda", + "/usr/local/cuda-13", + "/usr/local/cuda-12.8", + "/usr/local/cuda-12.6", + "/usr/local/cuda-12", + "/usr/local/cuda-12.4", + "/usr/local/cuda-12.2", + "/usr/local/cuda-11", + "/usr/lib/cuda", + ] { + let nvcc = PathBuf::from(candidate).join("bin").join("nvcc"); + if nvcc.is_file() { + return Some(candidate.to_string()); + } } None } @@ -74,11 +92,18 @@ fn main() { build.define("__CUDA__", None); build.define("__ENDIAN_LITTLE__", None); - // RTX 20xx / 30xx / 40xx fat binary + // Real SASS for shipping NVIDIA GPUs (RTX 20xx/30xx/40xx = sm_75/86/89) PLUS a + // virtual PTX target (compute_89, code=compute_89). PTX is forward-compatible: + // the runtime driver JIT-compiles it to the actual GPU's SASS, so a newer + // architecture the fatbin has no SASS for (Hopper sm_90, Blackwell / RTX 50xx + // sm_120, ...) still runs instead of failing at launch with + // cudaErrorNoKernelImageForDevice. This is what makes "new CUDA / new GPUs" + // work without editing this list every generation. for arch in [ "arch=compute_75,code=sm_75", "arch=compute_86,code=sm_86", "arch=compute_89,code=sm_89", + "arch=compute_89,code=compute_89", ] { build.flag("-gencode").flag(arch); } From 640b94a9ab2646504349e13361ff48da7da3ca8f Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 02:18:55 +0200 Subject: [PATCH 07/74] =?UTF-8?q?feat(pool):=20P1.0=20spike=20=E2=80=94=20?= =?UTF-8?q?off-node=20block=20with=20chosen=20coinbase,=20accepted=20by=20?= =?UTF-8?q?node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proves the foundation of the no-custody / batched community-pool design: a process OUTSIDE the fullnode assembles a valid coinbase-only BlockV1 whose coinbase pays a CHOSEN address, CPU-mines it, and submits it via POST /submit/block — with ZERO node changes. Validated end-to-end on a local isolated testnet (chain_id=2): submitted block 1 paying an address the node has no [miner] config for; node accepted it ({ok:true}), the chain advanced to height 1, and the chosen address now holds the 1 HAC block reward. Reuses the node's own pub APIs (create_coinbase_tx, BlockV1, calculate_mrklroot, x16rs::block_hash, DifficultyTarget, genesis_block_hash) via workspace path deps; mirrors impl_packing_next_block for the coinbase-only case. Spike only; targets fresh-testnet bootstrap difficulty (LOWEST_DIFFICULTY) and does not yet reproduce mainnet ASERT difficulty (next milestone). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 15 ++++ Cargo.toml | 1 + pool-spike/Cargo.toml | 20 +++++ pool-spike/src/main.rs | 200 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 pool-spike/Cargo.toml create mode 100644 pool-spike/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 0a5a468..b4117af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3760,6 +3760,21 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "pool-spike" +version = "0.1.0" +dependencies = [ + "basis", + "field", + "hex", + "mint", + "protocol", + "reqwest", + "serde_json", + "sys", + "x16rs", +] + [[package]] name = "potential_utf" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index 09883d3..2ea9460 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "x16rs", "x16rs-cuda", "miner-pool", + "pool-spike", ] exclude = [ "chainv1", diff --git a/pool-spike/Cargo.toml b/pool-spike/Cargo.toml new file mode 100644 index 0000000..abc0e6d --- /dev/null +++ b/pool-spike/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "pool-spike" +version = "0.1.0" +edition = "2024" +description = "P1 feasibility spike: assemble a coinbase-only block off-node with a chosen coinbase and submit via /submit/block" + +[[bin]] +name = "pool-spike" +path = "src/main.rs" + +[dependencies] +field = { path = "../field" } +basis = { path = "../basis" } +protocol = { path = "../protocol" } +mint = { path = "../mint" } +sys = { path = "../sys" } +x16rs = { path = "../x16rs" } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] } +serde_json = "1.0" +hex = "0.4.3" diff --git a/pool-spike/src/main.rs b/pool-spike/src/main.rs new file mode 100644 index 0000000..bff10fb --- /dev/null +++ b/pool-spike/src/main.rs @@ -0,0 +1,200 @@ +//! P1 feasibility spike for the community pool. +//! +//! Proves the make-or-break claim: a process OUTSIDE the node can assemble a +//! valid block whose coinbase pays a CHOSEN address, CPU-mine it, and have the +//! node accept it via POST /submit/block — with no node change. +//! +//! It mirrors the node's own `impl_packing_next_block` +//! (mint/src/check/block_build.rs) for a coinbase-only block, then mines and +//! submits. Run it against a FRESH LOCAL TESTNET (chain_id != 0), where the +//! first ~289 blocks use LOWEST_DIFFICULTY and a single CPU core finds a block +//! near-instantly. It does NOT reproduce mainnet ASERT difficulty (out of scope +//! for the spike). +//! +//! Usage: pool-spike [node_base_url] [payout_privakey_address] +//! e.g. pool-spike http://127.0.0.1:8088 1MzNY1oA3kfgYi75zquj3SRUPYztzXHzK9 + +use std::env; +use std::time::Duration; + +use basis::difficulty::*; +use basis::interface::*; +use field::*; +use protocol::block::*; +use protocol::transaction::*; +use sys::*; + +use mint::create_coinbase_tx; + +use serde_json::Value; + +fn main() { + let args: Vec = env::args().collect(); + let base = args + .get(1) + .cloned() + .unwrap_or_else(|| "http://127.0.0.1:8088".to_string()); + let base = base.trim_end_matches('/').to_string(); + let payout = args + .get(2) + .cloned() + .unwrap_or_else(|| "1MzNY1oA3kfgYi75zquj3SRUPYztzXHzK9".to_string()); + + println!("== pool-spike =="); + println!("node = {base}"); + println!("payout = {payout}"); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(20)) + .build() + .expect("http client"); + + // 1) chain tip height + let latest = get_json(&client, &format!("{base}/query/latest")); + println!("/query/latest -> {latest}"); + let prev_hei = find_u64(&latest, "height").expect("no 'height' in /query/latest"); + println!("prev height = {prev_hei}"); + + // 2) previous block: its hash is the next block's prevhash. Genesis (height 0) + // is not served by /query/block/intro, so take it from the linked genesis + // constant (genesis_block_hash() just parses the constant — no block hasher). + let next_hei = prev_hei + 1; + let (prevhash, prev_ts) = if prev_hei == 0 { + println!("prev = linked genesis (height 0)"); + (mint::genesis::genesis_block_hash(), 1549250700u64) + } else { + let intro_j = get_json(&client, &format!("{base}/query/block/intro?height={prev_hei}")); + println!("/query/block/intro?height={prev_hei} -> {intro_j}"); + let ph = find_str(&intro_j, "hash").expect("no 'hash' in block intro"); + let ts = find_u64(&intro_j, "timestamp").unwrap_or(0); + (Hash::from_hex(ph.as_bytes()).expect("bad prevhash hex"), ts) + }; + + // Fresh-testnet bootstrap difficulty (heights <= window+1 use LOWEST_DIFFICULTY). + let diff: u32 = LOWEST_DIFFICULTY; + let next_ts = std::cmp::max(curtimes(), prev_ts.saturating_add(1)); + println!("assembling block height={next_hei} difficulty={diff} timestamp={next_ts}"); + + // 3) coinbase paying OUR chosen address + let adr = Address::from_readable(&payout).expect("bad payout address"); + assert!( + adr.is_privakey(), + "payout address must be a PRIVAKEY (version-0) address" + ); + let cbtx = create_coinbase_tx(next_hei, Fixed16::default(), adr); + println!("coinbase built for height {next_hei}"); + + // 4) assemble BlockV1 (coinbase-only) — mirror of impl_packing_next_block + let trshxs: Vec = vec![cbtx.hash_with_fee()]; + let mut transactions = DynVecTransaction::default(); + transactions + .push(Box::new(cbtx.clone())) + .expect("push coinbase"); + let mut intro = BlockIntro { + head: BlockHead { + version: Uint1::from(1), + height: BlockHeight::from(next_hei), + timestamp: Timestamp::from(next_ts), + prevhash, + mrklroot: calculate_mrklroot(&trshxs), + transaction_count: Uint4::from(1u32), + }, + meta: BlockMeta { + nonce: Uint4::default(), + difficulty: Uint4::from(diff), + witness_stage: Fixed2::default(), + }, + }; + + // 5) CPU-mine: vary the header nonce until the x16rs block hash beats target. + let target = DifficultyTarget::from_num(diff).hash; + let mut nonce: u32 = 0; + let found: [u8; 32]; + loop { + intro.meta.nonce = Uint4::from(nonce); + let ph = x16rs::block_hash(next_hei, &intro.serialize()); + if !hash_bigger_than(&ph, &target) { + found = ph; + break; + } + nonce = nonce.wrapping_add(1); + if nonce == 0 { + // Exhausted 2^32 nonces; refresh the timestamp for a new search space. + intro.head.timestamp = Timestamp::from(curtimes()); + } + if nonce % 1_000_000 == 0 { + println!("mining... nonce={nonce}"); + } + } + println!("MINED: nonce={nonce} hash={}", hex::encode(found)); + + // 6) serialize + submit + let block = BlockV1 { intro, transactions }; + let bytes = block.serialize(); + println!("block bytes = {} (submitting hex)", bytes.len()); + let resp = post_hex( + &client, + &format!("{base}/submit/block?hexbody=true"), + &hex::encode(&bytes), + ); + println!("/submit/block -> {resp}"); + + // 7) verify the new tip + let check = get_json(&client, &format!("{base}/query/block/intro?height={next_hei}")); + println!("/query/block/intro?height={next_hei} -> {check}"); + match find_str(&check, "miner") { + Some(m) if m == payout => println!("\nSUCCESS: block {next_hei} accepted, coinbase pays {m}"), + Some(m) => println!("\nblock {next_hei} present but coinbase pays {m} (expected {payout})"), + None => println!("\nblock {next_hei} not found yet — check the submit response above"), + } +} + +fn get_json(client: &reqwest::blocking::Client, url: &str) -> Value { + let text = client + .get(url) + .send() + .and_then(|r| r.text()) + .unwrap_or_else(|e| format!("{{\"http_error\":\"{e}\"}}")); + serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text)) +} + +fn post_hex(client: &reqwest::blocking::Client, url: &str, body: &str) -> String { + client + .post(url) + .header("content-type", "text/plain") + .body(body.to_string()) + .send() + .and_then(|r| r.text()) + .unwrap_or_else(|e| format!("http_error: {e}")) +} + +/// Recursively find the first value for `key` anywhere in the JSON, as u64 +/// (accepts a JSON number or a numeric string). +fn find_u64(v: &Value, key: &str) -> Option { + find_value(v, key).and_then(|x| { + x.as_u64() + .or_else(|| x.as_str().and_then(|s| s.trim().parse().ok())) + }) +} + +fn find_str(v: &Value, key: &str) -> Option { + find_value(v, key).and_then(|x| x.as_str().map(|s| s.to_string())) +} + +fn find_value<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + match v { + Value::Object(map) => { + if let Some(found) = map.get(key) { + return Some(found); + } + for (_, child) in map { + if let Some(found) = find_value(child, key) { + return Some(found); + } + } + None + } + Value::Array(arr) => arr.iter().find_map(|child| find_value(child, key)), + _ => None, + } +} From fef8da4301e9c4ba81cc27b6e762eb22456fed5f Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 02:49:30 +0200 Subject: [PATCH 08/74] =?UTF-8?q?feat(pool):=20P1=20settlement=20proof=20?= =?UTF-8?q?=E2=80=94=20one=20signed=20tx=20pays=20N=20recipients=20fractio?= =?UTF-8?q?nal=20amounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds settle-spike + a shared lib (mine_and_submit_block with extra txs). Proves the pool's on-chain "payout" half end-to-end on the local testnet: a Type2 transaction from a controlled secp256k1 account pays 3 recipients fractional amounts (0.2/0.3/0.1 HAC) via HacToTrs actions, signed once with fill_sign, submitted to the mempool (ret:0), then confirmed by mining a block that includes it. All 3 recipient balances landed exactly. Both on-chain pool interactions are now validated with ZERO node changes: coinbase-in (chosen coinbase accepted) and settlement-out (batched fractional transfer). Remaining pool work (share protocol, PPLNS accounting) is off-node software that touches no consensus. Reuses sys::Account (secp256k1), protocol TransactionType2 + HacToTrs, Transaction::fill_sign; endpoints /submit/transaction + /submit/block. Co-Authored-By: Claude Opus 4.8 --- pool-spike/Cargo.toml | 4 + pool-spike/src/lib.rs | 144 ++++++++++++++++++++++++++++++ pool-spike/src/main.rs | 186 +++------------------------------------ pool-spike/src/settle.rs | 124 ++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 176 deletions(-) create mode 100644 pool-spike/src/lib.rs create mode 100644 pool-spike/src/settle.rs diff --git a/pool-spike/Cargo.toml b/pool-spike/Cargo.toml index abc0e6d..292c0cd 100644 --- a/pool-spike/Cargo.toml +++ b/pool-spike/Cargo.toml @@ -8,6 +8,10 @@ description = "P1 feasibility spike: assemble a coinbase-only block off-node wit name = "pool-spike" path = "src/main.rs" +[[bin]] +name = "settle-spike" +path = "src/settle.rs" + [dependencies] field = { path = "../field" } basis = { path = "../basis" } diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs new file mode 100644 index 0000000..9e8696e --- /dev/null +++ b/pool-spike/src/lib.rs @@ -0,0 +1,144 @@ +//! Shared helpers for the pool spikes: HTTP glue + off-node block assembly that +//! mirrors the node's `impl_packing_next_block` for a block containing a +//! coinbase plus optional extra transactions. Targets a fresh local testnet +//! (bootstrap LOWEST_DIFFICULTY); does not reproduce mainnet ASERT difficulty. + +use basis::difficulty::*; +use basis::interface::*; +use field::*; +use protocol::block::*; +use protocol::transaction::*; +use sys::*; + +use serde_json::Value; + +pub fn http_client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .expect("http client") +} + +pub fn get_json(client: &reqwest::blocking::Client, url: &str) -> Value { + let text = client + .get(url) + .send() + .and_then(|r| r.text()) + .unwrap_or_else(|e| format!("{{\"http_error\":\"{e}\"}}")); + serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text)) +} + +pub fn post_hex(client: &reqwest::blocking::Client, url: &str, body: &str) -> String { + client + .post(url) + .header("content-type", "text/plain") + .body(body.to_string()) + .send() + .and_then(|r| r.text()) + .unwrap_or_else(|e| format!("http_error: {e}")) +} + +pub fn find_u64(v: &Value, key: &str) -> Option { + find_value(v, key).and_then(|x| { + x.as_u64() + .or_else(|| x.as_str().and_then(|s| s.trim().parse().ok())) + }) +} + +pub fn find_str(v: &Value, key: &str) -> Option { + find_value(v, key).and_then(|x| x.as_str().map(|s| s.to_string())) +} + +pub fn find_value<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { + match v { + Value::Object(map) => map + .get(key) + .or_else(|| map.values().find_map(|child| find_value(child, key))), + Value::Array(arr) => arr.iter().find_map(|child| find_value(child, key)), + _ => None, + } +} + +/// The recipient's "hacash" balance string (e.g. "1:248"), or "" if none. +pub fn balance(client: &reqwest::blocking::Client, base: &str, addr: &str) -> String { + let j = get_json(client, &format!("{base}/query/balance?address={addr}")); + find_str(&j, "hacash").unwrap_or_default() +} + +/// Assemble a block whose coinbase pays `coinbase_addr`, plus `extra_txs`, +/// CPU-mine it at bootstrap difficulty, and submit via /submit/block. +/// Returns (next_height, submit_response). +pub fn mine_and_submit_block( + client: &reqwest::blocking::Client, + base: &str, + coinbase_addr: &str, + extra_txs: Vec>, +) -> (u64, String) { + let latest = get_json(client, &format!("{base}/query/latest")); + let prev_hei = find_u64(&latest, "height").expect("no 'height' in /query/latest"); + let next_hei = prev_hei + 1; + + let (prevhash, prev_ts) = if prev_hei == 0 { + (mint::genesis::genesis_block_hash(), 1549250700u64) + } else { + let ij = get_json(client, &format!("{base}/query/block/intro?height={prev_hei}")); + let ph = find_str(&ij, "hash").expect("no 'hash' in block intro"); + ( + Hash::from_hex(ph.as_bytes()).expect("bad prevhash hex"), + find_u64(&ij, "timestamp").unwrap_or(0), + ) + }; + + let diff: u32 = LOWEST_DIFFICULTY; + let next_ts = std::cmp::max(curtimes(), prev_ts.saturating_add(1)); + + let adr = Address::from_readable(coinbase_addr).expect("bad coinbase address"); + let cbtx = mint::create_coinbase_tx(next_hei, Fixed16::default(), adr); + + let mut trshxs: Vec = vec![cbtx.hash_with_fee()]; + let mut transactions = DynVecTransaction::default(); + transactions.push(Box::new(cbtx.clone())).expect("push coinbase"); + for tx in extra_txs { + trshxs.push(tx.hash_with_fee()); + transactions.push(tx).expect("push extra tx"); + } + let count = trshxs.len() as u32; + + let mut intro = BlockIntro { + head: BlockHead { + version: Uint1::from(1), + height: BlockHeight::from(next_hei), + timestamp: Timestamp::from(next_ts), + prevhash, + mrklroot: calculate_mrklroot(&trshxs), + transaction_count: Uint4::from(count), + }, + meta: BlockMeta { + nonce: Uint4::default(), + difficulty: Uint4::from(diff), + witness_stage: Fixed2::default(), + }, + }; + + let target = DifficultyTarget::from_num(diff).hash; + let mut nonce: u32 = 0; + loop { + intro.meta.nonce = Uint4::from(nonce); + let ph = x16rs::block_hash(next_hei, &intro.serialize()); + if !hash_bigger_than(&ph, &target) { + break; + } + nonce = nonce.wrapping_add(1); + if nonce == 0 { + intro.head.timestamp = Timestamp::from(curtimes()); + } + } + + let block = BlockV1 { intro, transactions }; + let resp = post_hex( + client, + &format!("{base}/submit/block?hexbody=true"), + &hex::encode(block.serialize()), + ); + (next_hei, resp) +} diff --git a/pool-spike/src/main.rs b/pool-spike/src/main.rs index bff10fb..e1e04db 100644 --- a/pool-spike/src/main.rs +++ b/pool-spike/src/main.rs @@ -1,32 +1,13 @@ -//! P1 feasibility spike for the community pool. -//! -//! Proves the make-or-break claim: a process OUTSIDE the node can assemble a -//! valid block whose coinbase pays a CHOSEN address, CPU-mine it, and have the -//! node accept it via POST /submit/block — with no node change. -//! -//! It mirrors the node's own `impl_packing_next_block` -//! (mint/src/check/block_build.rs) for a coinbase-only block, then mines and -//! submits. Run it against a FRESH LOCAL TESTNET (chain_id != 0), where the -//! first ~289 blocks use LOWEST_DIFFICULTY and a single CPU core finds a block -//! near-instantly. It does NOT reproduce mainnet ASERT difficulty (out of scope -//! for the spike). +//! P1.0 feasibility spike: assemble a coinbase-only block OFF-NODE whose +//! coinbase pays a CHOSEN address, CPU-mine it, and submit via /submit/block — +//! proving the node accepts an externally-chosen coinbase with no node change. +//! Run against a fresh local testnet (chain_id != 0). //! //! Usage: pool-spike [node_base_url] [payout_privakey_address] -//! e.g. pool-spike http://127.0.0.1:8088 1MzNY1oA3kfgYi75zquj3SRUPYztzXHzK9 use std::env; -use std::time::Duration; - -use basis::difficulty::*; -use basis::interface::*; -use field::*; -use protocol::block::*; -use protocol::transaction::*; -use sys::*; -use mint::create_coinbase_tx; - -use serde_json::Value; +use pool_spike::{balance, http_client, mine_and_submit_block}; fn main() { let args: Vec = env::args().collect(); @@ -44,157 +25,10 @@ fn main() { println!("node = {base}"); println!("payout = {payout}"); - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(20)) - .build() - .expect("http client"); - - // 1) chain tip height - let latest = get_json(&client, &format!("{base}/query/latest")); - println!("/query/latest -> {latest}"); - let prev_hei = find_u64(&latest, "height").expect("no 'height' in /query/latest"); - println!("prev height = {prev_hei}"); - - // 2) previous block: its hash is the next block's prevhash. Genesis (height 0) - // is not served by /query/block/intro, so take it from the linked genesis - // constant (genesis_block_hash() just parses the constant — no block hasher). - let next_hei = prev_hei + 1; - let (prevhash, prev_ts) = if prev_hei == 0 { - println!("prev = linked genesis (height 0)"); - (mint::genesis::genesis_block_hash(), 1549250700u64) - } else { - let intro_j = get_json(&client, &format!("{base}/query/block/intro?height={prev_hei}")); - println!("/query/block/intro?height={prev_hei} -> {intro_j}"); - let ph = find_str(&intro_j, "hash").expect("no 'hash' in block intro"); - let ts = find_u64(&intro_j, "timestamp").unwrap_or(0); - (Hash::from_hex(ph.as_bytes()).expect("bad prevhash hex"), ts) - }; - - // Fresh-testnet bootstrap difficulty (heights <= window+1 use LOWEST_DIFFICULTY). - let diff: u32 = LOWEST_DIFFICULTY; - let next_ts = std::cmp::max(curtimes(), prev_ts.saturating_add(1)); - println!("assembling block height={next_hei} difficulty={diff} timestamp={next_ts}"); - - // 3) coinbase paying OUR chosen address - let adr = Address::from_readable(&payout).expect("bad payout address"); - assert!( - adr.is_privakey(), - "payout address must be a PRIVAKEY (version-0) address" - ); - let cbtx = create_coinbase_tx(next_hei, Fixed16::default(), adr); - println!("coinbase built for height {next_hei}"); - - // 4) assemble BlockV1 (coinbase-only) — mirror of impl_packing_next_block - let trshxs: Vec = vec![cbtx.hash_with_fee()]; - let mut transactions = DynVecTransaction::default(); - transactions - .push(Box::new(cbtx.clone())) - .expect("push coinbase"); - let mut intro = BlockIntro { - head: BlockHead { - version: Uint1::from(1), - height: BlockHeight::from(next_hei), - timestamp: Timestamp::from(next_ts), - prevhash, - mrklroot: calculate_mrklroot(&trshxs), - transaction_count: Uint4::from(1u32), - }, - meta: BlockMeta { - nonce: Uint4::default(), - difficulty: Uint4::from(diff), - witness_stage: Fixed2::default(), - }, - }; - - // 5) CPU-mine: vary the header nonce until the x16rs block hash beats target. - let target = DifficultyTarget::from_num(diff).hash; - let mut nonce: u32 = 0; - let found: [u8; 32]; - loop { - intro.meta.nonce = Uint4::from(nonce); - let ph = x16rs::block_hash(next_hei, &intro.serialize()); - if !hash_bigger_than(&ph, &target) { - found = ph; - break; - } - nonce = nonce.wrapping_add(1); - if nonce == 0 { - // Exhausted 2^32 nonces; refresh the timestamp for a new search space. - intro.head.timestamp = Timestamp::from(curtimes()); - } - if nonce % 1_000_000 == 0 { - println!("mining... nonce={nonce}"); - } - } - println!("MINED: nonce={nonce} hash={}", hex::encode(found)); - - // 6) serialize + submit - let block = BlockV1 { intro, transactions }; - let bytes = block.serialize(); - println!("block bytes = {} (submitting hex)", bytes.len()); - let resp = post_hex( - &client, - &format!("{base}/submit/block?hexbody=true"), - &hex::encode(&bytes), - ); - println!("/submit/block -> {resp}"); - - // 7) verify the new tip - let check = get_json(&client, &format!("{base}/query/block/intro?height={next_hei}")); - println!("/query/block/intro?height={next_hei} -> {check}"); - match find_str(&check, "miner") { - Some(m) if m == payout => println!("\nSUCCESS: block {next_hei} accepted, coinbase pays {m}"), - Some(m) => println!("\nblock {next_hei} present but coinbase pays {m} (expected {payout})"), - None => println!("\nblock {next_hei} not found yet — check the submit response above"), - } -} - -fn get_json(client: &reqwest::blocking::Client, url: &str) -> Value { - let text = client - .get(url) - .send() - .and_then(|r| r.text()) - .unwrap_or_else(|e| format!("{{\"http_error\":\"{e}\"}}")); - serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text)) -} - -fn post_hex(client: &reqwest::blocking::Client, url: &str, body: &str) -> String { - client - .post(url) - .header("content-type", "text/plain") - .body(body.to_string()) - .send() - .and_then(|r| r.text()) - .unwrap_or_else(|e| format!("http_error: {e}")) -} - -/// Recursively find the first value for `key` anywhere in the JSON, as u64 -/// (accepts a JSON number or a numeric string). -fn find_u64(v: &Value, key: &str) -> Option { - find_value(v, key).and_then(|x| { - x.as_u64() - .or_else(|| x.as_str().and_then(|s| s.trim().parse().ok())) - }) -} - -fn find_str(v: &Value, key: &str) -> Option { - find_value(v, key).and_then(|x| x.as_str().map(|s| s.to_string())) -} + let client = http_client(); + let (h, resp) = mine_and_submit_block(&client, &base, &payout, vec![]); + println!("mined + submitted block {h} -> {resp}"); -fn find_value<'a>(v: &'a Value, key: &str) -> Option<&'a Value> { - match v { - Value::Object(map) => { - if let Some(found) = map.get(key) { - return Some(found); - } - for (_, child) in map { - if let Some(found) = find_value(child, key) { - return Some(found); - } - } - None - } - Value::Array(arr) => arr.iter().find_map(|child| find_value(child, key)), - _ => None, - } + std::thread::sleep(std::time::Duration::from_millis(900)); + println!("payout balance = {}", balance(&client, &base, &payout)); } diff --git a/pool-spike/src/settle.rs b/pool-spike/src/settle.rs new file mode 100644 index 0000000..0cd420c --- /dev/null +++ b/pool-spike/src/settle.rs @@ -0,0 +1,124 @@ +//! P1 settlement proof: build + SIGN + submit ONE transaction that pays MANY +//! recipients FRACTIONAL amounts (the pool's "batched settlement", up to 200 +//! outputs), then mine a block that includes it so the payouts confirm. Proves +//! the on-chain "payout" half of the pool. Sender + recipients are deterministic +//! accounts we control, so balances are verifiable. +//! +//! Usage: settle-spike [node_base_url] + +use std::env; + +use basis::interface::*; +use field::*; +use protocol::action::HacToTrs; +use protocol::transaction::TransactionType2; +use sys::*; + +use pool_spike::{balance, http_client, mine_and_submit_block, post_hex}; + +fn main() { + let base = env::args() + .nth(1) + .unwrap_or_else(|| "http://127.0.0.1:8088".to_string()); + let base = base.trim_end_matches('/').to_string(); + + let client = http_client(); + + // Deterministic accounts we control. + let sender = Account::create_by_secret_key_value([1u8; 32]).expect("sender account"); + let recipients: Vec<(Account, &str)> = vec![ + (Account::create_by_secret_key_value([2u8; 32]).unwrap(), "2:247"), // 0.2 HAC + (Account::create_by_secret_key_value([3u8; 32]).unwrap(), "3:247"), // 0.3 HAC + (Account::create_by_secret_key_value([4u8; 32]).unwrap(), "1:247"), // 0.1 HAC + ]; + + println!("== settle-spike =="); + println!("node = {base}"); + println!("sender = {}", sender.readable()); + let sender_bal = balance(&client, &base, sender.readable()); + println!("sender balance = {sender_bal}"); + + if sender_bal.is_empty() || sender_bal.starts_with("0:") { + println!( + "\nSender is unfunded. Fund it by mining one block to it, then re-run:\n \ + pool-spike {base} {}\n", + sender.readable() + ); + return; + } + + // Build ONE Type2 transaction paying all recipients (implicit FROM = main). + let main = Address::from(*sender.address()); + let fee = Amount::from("1:246").expect("fee"); // 0.01 HAC + let ts = curtimes(); + let mut tx = TransactionType2::new_by(main, fee, ts); + + println!("\nbuilding transfer with {} recipients:", recipients.len()); + for (rec, amt_str) in &recipients { + let to = Address::from_readable(rec.readable()).expect("recipient address"); + let amt = Amount::from(amt_str).expect("amount"); + let mut act = HacToTrs::new(); + act.to = AddrOrPtr::from_addr(to); + act.hacash = amt; + tx.push_action(Box::new(act)).expect("push action"); + println!(" -> {} {amt_str}", rec.readable()); + } + + // Sign once (sender == main => signs hash_with_fee). + tx.fill_sign(&sender).expect("fill_sign"); + let body_hex = hex::encode(tx.serialize()); + println!("signed tx bytes = {}", body_hex.len() / 2); + + println!("\nbefore:"); + for (rec, _) in &recipients { + println!(" {} = {}", rec.readable(), balance(&client, &base, rec.readable())); + } + + // (a) submit to the mempool — the pool's normal action. + let resp = post_hex(&client, &format!("{base}/submit/transaction?hexbody=true"), &body_hex); + println!("\n/submit/transaction -> {resp}"); + + // (b) confirm it by mining a block that INCLUDES the transfer (this testnet + // has no miner of its own), so the payouts actually land. + let (h, blkresp) = mine_and_submit_block( + &client, + &base, + sender.readable(), + vec![Box::new(tx) as Box], + ); + println!("mined confirming block {h} (coinbase+transfer) -> {blkresp}"); + + // Verify recipient balances after. + let mut after: Vec = Vec::new(); + for _ in 0..12 { + after = recipients + .iter() + .map(|(rec, _)| balance(&client, &base, rec.readable())) + .collect(); + if after.iter().all(|b| !b.is_empty() && !b.starts_with("0:")) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(800)); + } + + println!("\nafter:"); + let mut all_paid = true; + for ((rec, want), got) in recipients.iter().zip(after.iter()) { + let ok = !got.is_empty() && !got.starts_with("0:"); + all_paid &= ok; + println!( + " {} = {got} (wanted {want}) {}", + rec.readable(), + if ok { "OK" } else { "--" } + ); + } + println!(" sender = {}", balance(&client, &base, sender.readable())); + if all_paid { + println!( + "\nSUCCESS: one signed tx paid all {} recipients fractional amounts, confirmed on-chain.", + recipients.len() + ); + } else { + println!("\nNot all recipients funded yet — check the responses above."); + } +} From 0f85479b6861ee089b483ba263ed33059ba7affe Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 03:01:34 +0200 Subject: [PATCH 09/74] =?UTF-8?q?feat(pool):=20pool=5Fcore=20accounting=20?= =?UTF-8?q?brain=20=E2=80=94=20share=20target,=20PPLNS,=20exact=20payout?= =?UTF-8?q?=20split?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Off-node accounting core (no consensus, no node changes): - share_target_hash: the network target eased by 2^factor (saturating shift) - meets_target: share/block check via x16rs::block_hash vs a target - Pplns: rolling-window share accounting per worker - split_payout: largest-remainder exact split with pool fee + dust floor 7 unit tests. This is the brain that turns validated shares into the per-miner amounts fed to the already-proven batched settlement transfer, tying the two proven on-chain halves together. Co-Authored-By: Claude Opus 4.8 --- pool-spike/src/lib.rs | 2 + pool-spike/src/pool_core.rs | 206 ++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 pool-spike/src/pool_core.rs diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs index 9e8696e..2def40c 100644 --- a/pool-spike/src/lib.rs +++ b/pool-spike/src/lib.rs @@ -3,6 +3,8 @@ //! coinbase plus optional extra transactions. Targets a fresh local testnet //! (bootstrap LOWEST_DIFFICULTY); does not reproduce mainnet ASERT difficulty. +pub mod pool_core; + use basis::difficulty::*; use basis::interface::*; use field::*; diff --git a/pool-spike/src/pool_core.rs b/pool-spike/src/pool_core.rs new file mode 100644 index 0000000..4919119 --- /dev/null +++ b/pool-spike/src/pool_core.rs @@ -0,0 +1,206 @@ +//! pool_core — the pool's off-node accounting brain. No consensus, no node +//! changes. Ties the two proven on-chain halves together: workers submit shares +//! (validated here) -> PPLNS accounting -> exact payout split -> the proven +//! batched settlement transfer. + +use std::collections::{HashMap, VecDeque}; + +use basis::difficulty::{DifficultyTarget, hash_bigger_than}; + +/// Pool share target, easier than the network target by 2^log2_factor. Workers +/// mine against this; on average ~1 in 2^log2_factor shares is also a real block. +/// An "easier" target is a LARGER 256-bit threshold, so we multiply the network +/// target by 2^log2_factor, saturating at the all-0xFF ceiling. +pub fn share_target_hash(network_difficulty: u32, log2_factor: u32) -> [u8; 32] { + shift_left_saturating(network_target_hash(network_difficulty), log2_factor) +} + +/// Multiply a big-endian 256-bit value by 2^bits, saturating to all-0xFF. +fn shift_left_saturating(mut h: [u8; 32], bits: u32) -> [u8; 32] { + for _ in 0..bits { + let mut carry: u8 = 0; + for i in (0..32).rev() { + let v = ((h[i] as u16) << 1) | carry as u16; + h[i] = (v & 0xff) as u8; + carry = (v >> 8) as u8; + } + if carry != 0 { + return [0xff; 32]; // overflow -> easiest possible target + } + } + h +} + +/// The full network target for a difficulty (a share meeting THIS is a block). +pub fn network_target_hash(network_difficulty: u32) -> [u8; 32] { + DifficultyTarget::from_num(network_difficulty).hash +} + +/// True if the solved 89-byte block header meets `target` (x16rs hash <= target). +pub fn meets_target(height: u64, header: &[u8], target: &[u8; 32]) -> bool { + !hash_bigger_than(&x16rs::block_hash(height, header), target) +} + +/// PPLNS accounting over a rolling window of the last `window` accepted shares. +#[derive(Debug)] +pub struct Pplns { + window: usize, + order: VecDeque, + counts: HashMap, +} + +impl Pplns { + pub fn new(window: usize) -> Self { + Self { + window: window.max(1), + order: VecDeque::new(), + counts: HashMap::new(), + } + } + + /// Record one accepted share from `worker`, evicting the oldest when full. + pub fn record(&mut self, worker: &str) { + self.order.push_back(worker.to_string()); + *self.counts.entry(worker.to_string()).or_insert(0) += 1; + while self.order.len() > self.window { + if let Some(old) = self.order.pop_front() { + if let Some(c) = self.counts.get_mut(&old) { + *c -= 1; + if *c == 0 { + self.counts.remove(&old); + } + } + } + } + } + + /// Number of shares currently in the window. + pub fn total(&self) -> u64 { + self.order.len() as u64 + } + + /// worker -> share count in the current window, descending by count then id. + pub fn counts(&self) -> Vec<(String, u64)> { + let mut v: Vec<(String, u64)> = + self.counts.iter().map(|(k, &c)| (k.clone(), c)).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v + } +} + +/// Split `reward_units` (smallest integer units) among workers by share count +/// using the largest-remainder method (exact — no unit created or lost), after +/// taking `fee_units` off the top. Workers whose payout is below `dust_units` +/// are dropped (their remainder stays with the pool). Returns (worker, units). +pub fn split_payout( + reward_units: u64, + fee_units: u64, + dust_units: u64, + counts: &[(String, u64)], +) -> Vec<(String, u64)> { + let distributable = reward_units.saturating_sub(fee_units); + let total_shares: u64 = counts.iter().map(|(_, c)| *c).sum(); + if distributable == 0 || total_shares == 0 { + return vec![]; + } + // floor split + remainder, exact via largest-remainder + let mut rows: Vec<(String, u64, u128)> = Vec::with_capacity(counts.len()); + let mut assigned: u64 = 0; + for (w, c) in counts { + let exact = distributable as u128 * *c as u128; + let floor = (exact / total_shares as u128) as u64; + let rem = exact % total_shares as u128; + assigned += floor; + rows.push((w.clone(), floor, rem)); + } + let mut leftover = distributable - assigned; + rows.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0))); + for row in rows.iter_mut() { + if leftover == 0 { + break; + } + row.1 += 1; + leftover -= 1; + } + rows.into_iter() + .filter(|(_, units, _)| *units >= dust_units && *units > 0) + .map(|(w, units, _)| (w, units)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shift_left_saturating_multiplies_and_saturates() { + // 0x01 at byte 16, x16 (<<4) -> 0x10 at byte 16. + let mut v = [0u8; 32]; + v[16] = 0x01; + let mut want = [0u8; 32]; + want[16] = 0x10; + assert_eq!(shift_left_saturating(v, 4), want); + // overflow saturates to the easiest possible target. + assert_eq!(shift_left_saturating([0xff; 32], 1), [0xff; 32]); + assert_eq!(shift_left_saturating(v, 0), v); + } + + #[test] + fn share_target_is_never_harder_than_network() { + let diff = 0x1000_0000u32; + let net = network_target_hash(diff); + // factor 0 == the network target exactly; any factor is >= it (easier). + assert_eq!(share_target_hash(diff, 0), net); + assert!(!hash_bigger_than(&net, &share_target_hash(diff, 4))); + } + + #[test] + fn meets_target_bounds() { + let hdr = b"any 89-ish header bytes for the hash"; + assert!(meets_target(1, hdr, &[0xffu8; 32])); // easiest possible target + assert!(!meets_target(1, hdr, &[0x00u8; 32])); // impossible target + } + + #[test] + fn pplns_window_evicts_oldest() { + let mut p = Pplns::new(4); + for w in ["a", "a", "b", "c", "d", "a"] { + p.record(w); + } + // last 4 shares kept: [b, c, d, a] + assert_eq!(p.total(), 4); + let counts = p.counts(); + assert_eq!(counts.iter().map(|(_, c)| *c).sum::(), 4); + let a = counts.iter().find(|(w, _)| w == "a").map(|(_, c)| *c).unwrap(); + assert_eq!(a, 1); + } + + #[test] + fn split_is_proportional_and_exact() { + let counts = vec![("a".to_string(), 3u64), ("b".to_string(), 1u64)]; + let out: HashMap = split_payout(100, 0, 0, &counts).into_iter().collect(); + assert_eq!(out["a"], 75); + assert_eq!(out["b"], 25); + } + + #[test] + fn split_largest_remainder_loses_no_unit() { + let counts = vec![ + ("a".to_string(), 1u64), + ("b".to_string(), 1u64), + ("c".to_string(), 1u64), + ]; + let out = split_payout(100, 0, 0, &counts); + assert_eq!(out.iter().map(|(_, u)| *u).sum::(), 100); // 34/33/33 + } + + #[test] + fn split_takes_fee_and_drops_dust() { + let counts = vec![("big".to_string(), 99u64), ("tiny".to_string(), 1u64)]; + let out: HashMap = + split_payout(1000, 100, 20, &counts).into_iter().collect(); + // distributable 900: big=891, tiny=9 < dust 20 -> dropped + assert_eq!(out.get("big"), Some(&891)); + assert!(!out.contains_key("tiny")); + } +} From bf0d962ac464fe813d6f73f8303042037dc8058d Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 14:03:39 +0200 Subject: [PATCH 10/74] =?UTF-8?q?feat(pool):=20working=20end-to-end=20pool?= =?UTF-8?q?=20=E2=80=94=20work,=20shares,=20PPLNS,=20blocks,=20proportiona?= =?UTF-8?q?l=20payout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the pool engine on top of the proven primitives, all off-node, no node changes: - lib: Template + per-worker extranonce (coinbase miner_nonce) giving each worker a private search space; fetch_template / intro_bytes / assemble_block / submit_block_bytes - pool-server: blocking HTTP pool. GET /work (template + share target + extranonce), GET /share (validate via pool_core, record PPLNS, and on a network-target hit assemble + submit the real block), GET /stats - test-miner: the worker side — pulls work, mines the 32-bit nonce at header bytes 79..83, submits shares - pool-payout: reads live PPLNS counts, splits with pool_core::split_payout, and pays every miner in ONE signed transaction, confirmed on-chain Verified end-to-end on the local testnet: alice 2 shares + bob 3 shares -> 5 blocks mined through the pool (chain 4->9), pool wallet earned 5 HAC, then 4.5 HAC split exactly 2:3 -> alice +1.8 HAC, bob +2.7 HAC in a single signed tx. Co-Authored-By: Claude Opus 4.8 --- pool-spike/Cargo.toml | 12 ++ pool-spike/src/lib.rs | 99 +++++++++++++++++ pool-spike/src/miner.rs | 67 ++++++++++++ pool-spike/src/payout.rs | 145 ++++++++++++++++++++++++ pool-spike/src/server.rs | 231 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 554 insertions(+) create mode 100644 pool-spike/src/miner.rs create mode 100644 pool-spike/src/payout.rs create mode 100644 pool-spike/src/server.rs diff --git a/pool-spike/Cargo.toml b/pool-spike/Cargo.toml index 292c0cd..f735c47 100644 --- a/pool-spike/Cargo.toml +++ b/pool-spike/Cargo.toml @@ -12,6 +12,18 @@ path = "src/main.rs" name = "settle-spike" path = "src/settle.rs" +[[bin]] +name = "pool-server" +path = "src/server.rs" + +[[bin]] +name = "test-miner" +path = "src/miner.rs" + +[[bin]] +name = "pool-payout" +path = "src/payout.rs" + [dependencies] field = { path = "../field" } basis = { path = "../basis" } diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs index 2def40c..9a95715 100644 --- a/pool-spike/src/lib.rs +++ b/pool-spike/src/lib.rs @@ -67,6 +67,105 @@ pub fn balance(client: &reqwest::blocking::Client, base: &str, addr: &str) -> St find_str(&j, "hacash").unwrap_or_default() } +/// Everything the pool needs to build and verify blocks for the current tip. +/// The pool serves one template to all workers; each worker gets its own +/// extranonce (the coinbase `miner_nonce`), which changes the merkle root and +/// therefore gives every worker a private search space. +#[derive(Clone)] +pub struct Template { + pub height: u64, + pub prevhash: Hash, + pub timestamp: u64, + pub difficulty: u32, + pub coinbase_addr: Address, +} + +/// Read the chain tip and build a template for the next block. +pub fn fetch_template( + client: &reqwest::blocking::Client, + base: &str, + coinbase_addr: &str, +) -> Template { + let latest = get_json(client, &format!("{base}/query/latest")); + let prev_hei = find_u64(&latest, "height").expect("no 'height' in /query/latest"); + let height = prev_hei + 1; + let (prevhash, prev_ts) = if prev_hei == 0 { + (mint::genesis::genesis_block_hash(), 1549250700u64) + } else { + let ij = get_json(client, &format!("{base}/query/block/intro?height={prev_hei}")); + let ph = find_str(&ij, "hash").expect("no 'hash' in block intro"); + ( + Hash::from_hex(ph.as_bytes()).expect("bad prevhash hex"), + find_u64(&ij, "timestamp").unwrap_or(0), + ) + }; + Template { + height, + prevhash, + timestamp: std::cmp::max(curtimes(), prev_ts.saturating_add(1)), + difficulty: LOWEST_DIFFICULTY, + coinbase_addr: Address::from_readable(coinbase_addr).expect("bad coinbase address"), + } +} + +/// The template's coinbase carrying `extranonce` in its miner_nonce field. +pub fn coinbase_with_extranonce(tpl: &Template, extranonce: &[u8; 32]) -> mint::TransactionCoinbase { + let mut cb = mint::create_coinbase_tx(tpl.height, Fixed16::default(), tpl.coinbase_addr.clone()); + let en = Hash::from_hex(hex::encode(extranonce).as_bytes()).expect("extranonce"); + cb.extend = mint::CoinbaseExtend::must(mint::CoinbaseExtendDataV1 { + miner_nonce: en, + witness_count: Uint1::from(0), + }); + cb +} + +fn build_intro(tpl: &Template, cb: &mint::TransactionCoinbase, nonce: u32) -> BlockIntro { + BlockIntro { + head: BlockHead { + version: Uint1::from(1), + height: BlockHeight::from(tpl.height), + timestamp: Timestamp::from(tpl.timestamp), + prevhash: tpl.prevhash.clone(), + mrklroot: calculate_mrklroot(&vec![cb.hash_with_fee()]), + transaction_count: Uint4::from(1u32), + }, + meta: BlockMeta { + nonce: Uint4::from(nonce), + difficulty: Uint4::from(tpl.difficulty), + witness_stage: Fixed2::default(), + }, + } +} + +/// The 89-byte block header a worker hashes (nonce lives at bytes 79..83). +pub fn intro_bytes(tpl: &Template, cb: &mint::TransactionCoinbase, nonce: u32) -> Vec { + build_intro(tpl, cb, nonce).serialize() +} + +/// Serialized full block for a winning (extranonce, nonce). +pub fn assemble_block(tpl: &Template, cb: &mint::TransactionCoinbase, nonce: u32) -> Vec { + let mut txs = DynVecTransaction::default(); + txs.push(Box::new(cb.clone())).expect("push coinbase"); + BlockV1 { + intro: build_intro(tpl, cb, nonce), + transactions: txs, + } + .serialize() +} + +/// Submit already-serialized block bytes. +pub fn submit_block_bytes( + client: &reqwest::blocking::Client, + base: &str, + bytes: &[u8], +) -> String { + post_hex( + client, + &format!("{base}/submit/block?hexbody=true"), + &hex::encode(bytes), + ) +} + /// Assemble a block whose coinbase pays `coinbase_addr`, plus `extra_txs`, /// CPU-mine it at bootstrap difficulty, and submit via /submit/block. /// Returns (next_height, submit_response). diff --git a/pool-spike/src/miner.rs b/pool-spike/src/miner.rs new file mode 100644 index 0000000..c3a0c40 --- /dev/null +++ b/pool-spike/src/miner.rs @@ -0,0 +1,67 @@ +//! Test miner for the pool protocol: pulls work, mines the 32-bit block nonce +//! against the pool's share target, submits shares. This is the worker side +//! that the real poworker will later speak. +//! +//! Usage: test-miner [pool_base] [worker_name] [shares_to_find] + +use pool_spike::pool_core; +use pool_spike::{find_str, find_u64, get_json, http_client}; + +fn main() { + let a: Vec = std::env::args().collect(); + let pool = a + .get(1) + .cloned() + .unwrap_or_else(|| "http://127.0.0.1:9777".to_string()); + let pool = pool.trim_end_matches('/').to_string(); + let worker = a.get(2).cloned().unwrap_or_else(|| "w1".to_string()); + let want: u64 = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(3); + + let client = http_client(); + println!("== test-miner {worker} -> {pool} (want {want} shares) =="); + + let mut found = 0u64; + while found < want { + let w = get_json(&client, &format!("{pool}/work?worker={worker}")); + let (Some(height), Some(intro_hex), Some(st_hex)) = ( + find_u64(&w, "height"), + find_str(&w, "intro"), + find_str(&w, "share_target"), + ) else { + println!("bad work response: {w}"); + break; + }; + + let mut intro = hex::decode(&intro_hex).expect("intro hex"); + if intro.len() != 89 { + println!("unexpected header length {}", intro.len()); + break; + } + let stv = hex::decode(&st_hex).expect("share target hex"); + let mut share_target = [0u8; 32]; + share_target.copy_from_slice(&stv); + + // Mine: the block nonce lives at header bytes 79..83 (big-endian u32). + let mut hit = None; + for nonce in 0u32..3_000_000 { + intro[79..83].copy_from_slice(&nonce.to_be_bytes()); + if pool_core::meets_target(height, &intro, &share_target) { + hit = Some(nonce); + break; + } + } + + match hit { + Some(nonce) => { + let r = get_json( + &client, + &format!("{pool}/share?worker={worker}&height={height}&nonce={nonce}"), + ); + println!("height={height} nonce={nonce} -> {r}"); + found += 1; + } + None => println!("no share found in range at height {height}; refetching work"), + } + } + println!("done: {found} share(s) submitted by {worker}"); +} diff --git a/pool-spike/src/payout.rs b/pool-spike/src/payout.rs new file mode 100644 index 0000000..c13c189 --- /dev/null +++ b/pool-spike/src/payout.rs @@ -0,0 +1,145 @@ +//! Closes the pool loop: read the live PPLNS share counts from the pool server, +//! split the pool's earnings proportionally with pool_core::split_payout, and +//! pay every miner in ONE signed transaction (the proven batched settlement). +//! +//! Accounting unit here is 0.1 HAC (Amount unit 247), so `units` map directly to +//! the "mantissa:247" amount strings. +//! +//! Usage: pool-payout [pool_base] [node_base] [total_units] [fee_units] [dust_units] + +use basis::interface::*; +use field::*; +use protocol::action::HacToTrs; +use protocol::transaction::TransactionType2; +use sys::*; + +use pool_spike::pool_core::split_payout; +use pool_spike::{balance, get_json, http_client, mine_and_submit_block, post_hex}; + +/// Demo mapping worker name -> the account it gets paid into. A real pool takes +/// this from the worker's registration instead. +fn payout_account(worker: &str) -> Option { + let secret: [u8; 32] = match worker { + "alice" => [2u8; 32], + "bob" => [3u8; 32], + _ => return None, + }; + Account::create_by_secret_key_value(secret).ok() +} + +fn main() { + let a: Vec = std::env::args().collect(); + let pool_base = a + .get(1) + .cloned() + .unwrap_or_else(|| "http://127.0.0.1:9777".to_string()); + let node = a + .get(2) + .cloned() + .unwrap_or_else(|| "http://127.0.0.1:8088".to_string()); + let pool_base = pool_base.trim_end_matches('/').to_string(); + let node = node.trim_end_matches('/').to_string(); + let total_units: u64 = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(50); // 5.0 HAC + let fee_units: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(5); // 0.5 HAC pool fee + let dust_units: u64 = a.get(5).and_then(|s| s.parse().ok()).unwrap_or(1); + + let client = http_client(); + let pool_acc = Account::create_by_secret_key_value([1u8; 32]).expect("pool account"); + + println!("== pool-payout =="); + println!("pool wallet = {}", pool_acc.readable()); + println!("balance = {}", balance(&client, &node, pool_acc.readable())); + + // 1) live PPLNS counts from the pool server + let stats = get_json(&client, &format!("{pool_base}/stats")); + let rows = stats + .get("workers") + .and_then(|w| w.as_array()) + .cloned() + .unwrap_or_default(); + let counts: Vec<(String, u64)> = rows + .iter() + .filter_map(|r| { + let arr = r.as_array()?; + Some((arr.first()?.as_str()?.to_string(), arr.get(1)?.as_u64()?)) + }) + .collect(); + if counts.is_empty() { + println!("no shares recorded yet — nothing to pay"); + return; + } + println!("\nPPLNS shares: {counts:?}"); + + // 2) exact proportional split (largest remainder, fee off the top, dust dropped) + let split = split_payout(total_units, fee_units, dust_units, &counts); + println!( + "split of {total_units} units (fee {fee_units}, dust {dust_units}) -> {split:?} [1 unit = 0.1 HAC]" + ); + + // 3) one signed transaction paying everyone + let main = Address::from(*pool_acc.address()); + let fee = Amount::from("1:246").expect("tx fee"); // 0.01 HAC + let mut tx = TransactionType2::new_by(main, fee, curtimes()); + let mut paid: Vec<(String, Account, u64)> = Vec::new(); + for (worker, units) in &split { + let Some(acc) = payout_account(worker) else { + println!(" (skip {worker}: no payout address registered)"); + continue; + }; + let to = Address::from_readable(acc.readable()).expect("payout address"); + let amt = Amount::from(&format!("{units}:247")).expect("amount"); + let mut act = HacToTrs::new(); + act.to = AddrOrPtr::from_addr(to); + act.hacash = amt; + tx.push_action(Box::new(act)).expect("push action"); + println!(" -> {worker} {} = {units}:247", acc.readable()); + paid.push((worker.clone(), acc, *units)); + } + if paid.is_empty() { + println!("nothing payable"); + return; + } + + println!("\nbefore:"); + for (w, acc, _) in &paid { + println!(" {w} {} = {}", acc.readable(), balance(&client, &node, acc.readable())); + } + + tx.fill_sign(&pool_acc).expect("fill_sign"); + let body_hex = hex::encode(tx.serialize()); + let resp = post_hex( + &client, + &format!("{node}/submit/transaction?hexbody=true"), + &body_hex, + ); + println!("\n/submit/transaction -> {resp}"); + + // 4) confirm by mining a block that includes the payout tx + let (h, blkresp) = mine_and_submit_block( + &client, + &node, + pool_acc.readable(), + vec![Box::new(tx) as Box], + ); + println!("mined confirming block {h} -> {blkresp}"); + + for _ in 0..12 { + std::thread::sleep(std::time::Duration::from_millis(700)); + if paid + .iter() + .all(|(_, acc, _)| !balance(&client, &node, acc.readable()).starts_with("0:")) + { + break; + } + } + println!("\nafter:"); + for (w, acc, units) in &paid { + println!( + " {w} {} = {} (paid {units}:247)", + acc.readable(), + balance(&client, &node, acc.readable()) + ); + } + println!(" pool wallet = {}", balance(&client, &node, pool_acc.readable())); + println!("\nLOOP CLOSED: shares -> PPLNS -> proportional split -> one signed payout tx -> on-chain."); +} diff --git a/pool-spike/src/server.rs b/pool-spike/src/server.rs new file mode 100644 index 0000000..57eabe3 --- /dev/null +++ b/pool-spike/src/server.rs @@ -0,0 +1,231 @@ +//! Minimal Hacash pool server (spike): serves work, validates shares with +//! pool_core, keeps PPLNS accounting, and submits full blocks to the node. +//! Blocking HTTP on std::net — no async runtime, no node changes. +//! +//! Endpoints: +//! GET /work?worker=NAME -> {height, intro, share_target, network_target, extranonce} +//! GET /share?worker=NAME&height=H&nonce=N -> {ok, kind: share|block|stale|invalid} +//! GET /stats -> {height, accepted_shares, blocks, workers} +//! +//! Usage: pool-server [node_base] [pool_payout_addr] [listen_addr] [share_bits] + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +use pool_spike::pool_core::{self, Pplns}; +use pool_spike::{ + Template, assemble_block, coinbase_with_extranonce, fetch_template, http_client, intro_bytes, + submit_block_bytes, +}; + +use serde_json::json; + +struct Pool { + node: String, + payout: String, + client: reqwest::blocking::Client, + tpl: Template, + share_target: [u8; 32], + network_target: [u8; 32], + workers: HashMap, + next_en: u64, + pplns: Pplns, + accepted: u64, + blocks: u64, +} + +impl Pool { + /// Re-read the tip and rebuild the template (after a block, or when stale). + fn refresh(&mut self) { + self.tpl = fetch_template(&self.client, &self.node, &self.payout); + self.network_target = pool_core::network_target_hash(self.tpl.difficulty); + } + + /// Stable per-worker extranonce -> private search space (coinbase miner_nonce). + fn extranonce_for(&mut self, worker: &str) -> [u8; 32] { + if let Some(en) = self.workers.get(worker) { + return *en; + } + self.next_en += 1; + let mut en = [0u8; 32]; + en[24..32].copy_from_slice(&self.next_en.to_be_bytes()); + self.workers.insert(worker.to_string(), en); + en + } +} + +/// A target requiring `bits` leading zero bits (pool difficulty knob). +fn target_leading_zero_bits(bits: u32) -> [u8; 32] { + let mut t = [0xffu8; 32]; + let full = (bits / 8) as usize; + let rem = bits % 8; + for b in t.iter_mut().take(full.min(32)) { + *b = 0x00; + } + if full < 32 && rem > 0 { + t[full] = 0xffu8 >> rem; + } + t +} + +fn main() { + let a: Vec = std::env::args().collect(); + let node = a + .get(1) + .cloned() + .unwrap_or_else(|| "http://127.0.0.1:8088".to_string()); + let node = node.trim_end_matches('/').to_string(); + let payout = a + .get(2) + .cloned() + .unwrap_or_else(|| "1MzNY1oA3kfgYi75zquj3SRUPYztzXHzK9".to_string()); + let listen = a.get(3).cloned().unwrap_or_else(|| "127.0.0.1:9777".to_string()); + let share_bits: u32 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(8); + + let client = http_client(); + let tpl = fetch_template(&client, &node, &payout); + let network_target = pool_core::network_target_hash(tpl.difficulty); + + println!("== pool-server =="); + println!("node = {node}"); + println!("payout = {payout}"); + println!("listen = {listen}"); + println!("share = {share_bits} leading zero bits"); + println!("height = {} (template)", tpl.height); + + let pool = Arc::new(Mutex::new(Pool { + node, + payout, + client, + tpl, + share_target: target_leading_zero_bits(share_bits), + network_target, + workers: HashMap::new(), + next_en: 0, + pplns: Pplns::new(1024), + accepted: 0, + blocks: 0, + })); + + let listener = TcpListener::bind(&listen).expect("bind"); + println!("listening...\n"); + for stream in listener.incoming() { + match stream { + Ok(s) => { + let p = pool.clone(); + std::thread::spawn(move || handle(s, p)); + } + Err(e) => eprintln!("accept error: {e}"), + } + } +} + +fn handle(mut s: TcpStream, pool: Arc>) { + let Ok(peek) = s.try_clone() else { return }; + let mut reader = BufReader::new(peek); + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + return; + } + let target = line.split_whitespace().nth(1).unwrap_or("/").to_string(); + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p.to_string(), q.to_string()), + None => (target, String::new()), + }; + let params = parse_query(&query); + let body = route(&path, ¶ms, &pool); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = s.write_all(resp.as_bytes()); +} + +fn parse_query(q: &str) -> HashMap { + q.split('&') + .filter(|kv| !kv.is_empty()) + .filter_map(|kv| kv.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn route(path: &str, params: &HashMap, pool: &Arc>) -> String { + match path { + "/work" => { + let worker = params.get("worker").cloned().unwrap_or_else(|| "anon".into()); + let mut p = pool.lock().unwrap(); + let en = p.extranonce_for(&worker); + let cb = coinbase_with_extranonce(&p.tpl, &en); + let intro = intro_bytes(&p.tpl, &cb, 0); + json!({ + "ok": true, + "height": p.tpl.height, + "intro": hex::encode(intro), + "share_target": hex::encode(p.share_target), + "network_target": hex::encode(p.network_target), + "extranonce": hex::encode(en), + }) + .to_string() + } + "/share" => { + let worker = params.get("worker").cloned().unwrap_or_else(|| "anon".into()); + let height: u64 = params.get("height").and_then(|v| v.parse().ok()).unwrap_or(0); + let nonce: u32 = params.get("nonce").and_then(|v| v.parse().ok()).unwrap_or(0); + let mut p = pool.lock().unwrap(); + + if height != p.tpl.height { + return json!({"ok":false,"kind":"stale","height":p.tpl.height}).to_string(); + } + let Some(en) = p.workers.get(&worker).copied() else { + return json!({"ok":false,"kind":"invalid","err":"unknown worker"}).to_string(); + }; + + let cb = coinbase_with_extranonce(&p.tpl, &en); + let intro = intro_bytes(&p.tpl, &cb, nonce); + if !pool_core::meets_target(p.tpl.height, &intro, &p.share_target) { + return json!({"ok":false,"kind":"invalid","err":"above share target"}).to_string(); + } + + p.pplns.record(&worker); + p.accepted += 1; + let is_block = pool_core::meets_target(p.tpl.height, &intro, &p.network_target); + if !is_block { + return json!({"ok":true,"kind":"share","accepted":p.accepted}).to_string(); + } + + // Full network solution: assemble and submit the real block. + let blk = assemble_block(&p.tpl, &cb, nonce); + let submit = submit_block_bytes(&p.client, &p.node, &blk); + p.blocks += 1; + let solved = p.tpl.height; + // Move to the next template once the node has committed it. + for _ in 0..6 { + std::thread::sleep(std::time::Duration::from_millis(300)); + p.refresh(); + if p.tpl.height > solved { + break; + } + } + json!({ + "ok": true, "kind": "block", "solved_height": solved, + "submit": submit, "next_height": p.tpl.height, "blocks": p.blocks + }) + .to_string() + } + "/stats" => { + let p = pool.lock().unwrap(); + json!({ + "height": p.tpl.height, + "accepted_shares": p.accepted, + "blocks": p.blocks, + "share_window": p.pplns.total(), + "workers": p.pplns.counts(), + }) + .to_string() + } + _ => json!({"ok":false,"err":"no such endpoint"}).to_string(), + } +} From d960afc0d1cdd8bb80385cd6a7df264b241066aa Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 14:18:23 +0200 Subject: [PATCH 11/74] feat(pool): real pool wallet from a key file instead of a hardcoded test key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_or_create_wallet(path) reads a 64-hex secp256k1 private key from a file, or generates a fresh random one (system RNG) and persists it on first run. The key never leaves the file — only the address is printed. pool-server now takes a wallet file and derives its coinbase address from it; pool-payout loads the same file to sign settlements. Removes the publicly-derivable demo key ([1u8;32]) from the payout path, which must never hold real funds. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + pool-spike/Cargo.toml | 1 + pool-spike/src/lib.rs | 32 ++++++++++++++++++++++++++++++++ pool-spike/src/payout.rs | 13 +++++++++---- pool-spike/src/server.rs | 15 +++++++++------ 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b4117af..e427801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3766,6 +3766,7 @@ version = "0.1.0" dependencies = [ "basis", "field", + "getrandom 0.3.4", "hex", "mint", "protocol", diff --git a/pool-spike/Cargo.toml b/pool-spike/Cargo.toml index f735c47..f5bd735 100644 --- a/pool-spike/Cargo.toml +++ b/pool-spike/Cargo.toml @@ -34,3 +34,4 @@ x16rs = { path = "../x16rs" } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking"] } serde_json = "1.0" hex = "0.4.3" +getrandom = "0.3.2" diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs index 9a95715..04fa297 100644 --- a/pool-spike/src/lib.rs +++ b/pool-spike/src/lib.rs @@ -67,6 +67,38 @@ pub fn balance(client: &reqwest::blocking::Client, base: &str, addr: &str) -> St find_str(&j, "hacash").unwrap_or_default() } +/// Load the pool wallet from `path` (a file holding a 64-hex secp256k1 private +/// key), creating a fresh random one if the file does not exist. The private key +/// only ever lives in that file — it is never printed or logged; only the +/// address is shown. +pub fn load_or_create_wallet(path: &str) -> Account { + if let Ok(txt) = std::fs::read_to_string(path) { + let key_hex = txt.trim().to_string(); + assert_eq!( + key_hex.len(), + 64, + "wallet file {path} must hold a 64-hex private key" + ); + let acc = Account::create_by(&key_hex).expect("invalid key in wallet file"); + println!("pool wallet {} (from {path})", acc.readable()); + return acc; + } + // No wallet yet: generate one and persist it. + let acc = loop { + let mut key = [0u8; 32]; + getrandom::fill(&mut key).expect("system RNG"); + if let Ok(a) = Account::create_by_secret_key_value(key) { + break a; + } + }; + std::fs::write(path, format!("{}\n", hex::encode(acc.secret_key().serialize()))) + .expect("write wallet file"); + println!("CREATED A NEW POOL WALLET -> {path}"); + println!(" address: {}", acc.readable()); + println!(" BACK UP THAT FILE. Whoever holds it controls the pool's funds."); + acc +} + /// Everything the pool needs to build and verify blocks for the current tip. /// The pool serves one template to all workers; each worker gets its own /// extranonce (the coinbase `miner_nonce`), which changes the merkle root and diff --git a/pool-spike/src/payout.rs b/pool-spike/src/payout.rs index c13c189..d323940 100644 --- a/pool-spike/src/payout.rs +++ b/pool-spike/src/payout.rs @@ -14,7 +14,9 @@ use protocol::transaction::TransactionType2; use sys::*; use pool_spike::pool_core::split_payout; -use pool_spike::{balance, get_json, http_client, mine_and_submit_block, post_hex}; +use pool_spike::{ + balance, get_json, http_client, load_or_create_wallet, mine_and_submit_block, post_hex, +}; /// Demo mapping worker name -> the account it gets paid into. A real pool takes /// this from the worker's registration instead. @@ -42,12 +44,15 @@ fn main() { let total_units: u64 = a.get(3).and_then(|s| s.parse().ok()).unwrap_or(50); // 5.0 HAC let fee_units: u64 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(5); // 0.5 HAC pool fee let dust_units: u64 = a.get(5).and_then(|s| s.parse().ok()).unwrap_or(1); + let wallet_file = a + .get(6) + .cloned() + .unwrap_or_else(|| "pool-wallet.key".to_string()); let client = http_client(); - let pool_acc = Account::create_by_secret_key_value([1u8; 32]).expect("pool account"); - println!("== pool-payout =="); - println!("pool wallet = {}", pool_acc.readable()); + // Same wallet file the pool server mines to; its key signs the payout tx. + let pool_acc = load_or_create_wallet(&wallet_file); println!("balance = {}", balance(&client, &node, pool_acc.readable())); // 1) live PPLNS counts from the pool server diff --git a/pool-spike/src/server.rs b/pool-spike/src/server.rs index 57eabe3..b13ebc5 100644 --- a/pool-spike/src/server.rs +++ b/pool-spike/src/server.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use pool_spike::pool_core::{self, Pplns}; use pool_spike::{ Template, assemble_block, coinbase_with_extranonce, fetch_template, http_client, intro_bytes, - submit_block_bytes, + load_or_create_wallet, submit_block_bytes, }; use serde_json::json; @@ -77,20 +77,23 @@ fn main() { .cloned() .unwrap_or_else(|| "http://127.0.0.1:8088".to_string()); let node = node.trim_end_matches('/').to_string(); - let payout = a + let wallet_file = a .get(2) .cloned() - .unwrap_or_else(|| "1MzNY1oA3kfgYi75zquj3SRUPYztzXHzK9".to_string()); + .unwrap_or_else(|| "pool-wallet.key".to_string()); let listen = a.get(3).cloned().unwrap_or_else(|| "127.0.0.1:9777".to_string()); let share_bits: u32 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(8); + println!("== pool-server =="); + println!("node = {node}"); + // The pool's coinbase + settlement wallet. Key stays in the file. + let wallet = load_or_create_wallet(&wallet_file); + let payout = wallet.readable().to_string(); + let client = http_client(); let tpl = fetch_template(&client, &node, &payout); let network_target = pool_core::network_target_hash(tpl.difficulty); - println!("== pool-server =="); - println!("node = {node}"); - println!("payout = {payout}"); println!("listen = {listen}"); println!("share = {share_bits} leading zero bits"); println!("height = {} (template)", tpl.height); From 914049b53ef2672538fe449281b3e1eadb8c4d2c Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 14:25:24 +0200 Subject: [PATCH 12/74] =?UTF-8?q?feat(pool):=20serve=20the=20standard=20mi?= =?UTF-8?q?ner=20API=20=E2=80=94=20an=20unmodified=20poworker=20mines=20on?= =?UTF-8?q?=20the=20pool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool now exposes /query/miner/pending, /query/miner/notice and /submit/miner/success exactly as a fullnode does, with ONE difference: target_hash carries the POOL's share target instead of the network target. An unmodified worker therefore submits shares, and the pool promotes a submission to a real block whenever it also beats the network target. - pending emits block_intro (89B hex), height (JSON number), target_hash (32B hex), coinbase_body (hex, with the extend present so the worker's own set_mining_nonce is not a no-op) and an empty mkrl_modify_list (coinbase-only) - notice is a real long-poll that never holds the state lock while sleeping - submit rebuilds exactly what the worker hashed (its own coinbase_nonce -> coinbase hash -> merkle root -> intro + block nonce) and validates it - shares are attributed by source IP, since the base protocol carries no worker id - accept_share is now shared by both the standard API and our own /share Verified with the SHIPPED poworker v1.0.9 binary, unmodified: it pulled work at the pool's target, mined ~1.8 MH/s, had 3 submissions accepted and correctly got "stale" for late ones; the pool assembled and submitted the real blocks (chain 10->12) and its wallet earned 3 HAC. Co-Authored-By: Claude Opus 4.8 --- pool-spike/src/lib.rs | 8 ++ pool-spike/src/server.rs | 161 +++++++++++++++++++++++++++++---------- 2 files changed, 129 insertions(+), 40 deletions(-) diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs index 04fa297..c42da5f 100644 --- a/pool-spike/src/lib.rs +++ b/pool-spike/src/lib.rs @@ -174,6 +174,14 @@ pub fn intro_bytes(tpl: &Template, cb: &mint::TransactionCoinbase, nonce: u32) - build_intro(tpl, cb, nonce).serialize() } +/// Hex of the serialized coinbase tx — the `coinbase_body` a worker receives. +/// Its optional `extend` block must be present or the worker's own +/// `set_mining_nonce` becomes a silent no-op (all threads would then share one +/// coinbase hash); `create_coinbase_tx` always emits it. +pub fn coinbase_body_hex(cb: &mint::TransactionCoinbase) -> String { + hex::encode(cb.serialize()) +} + /// Serialized full block for a winning (extranonce, nonce). pub fn assemble_block(tpl: &Template, cb: &mint::TransactionCoinbase, nonce: u32) -> Vec { let mut txs = DynVecTransaction::default(); diff --git a/pool-spike/src/server.rs b/pool-spike/src/server.rs index b13ebc5..6b79ce1 100644 --- a/pool-spike/src/server.rs +++ b/pool-spike/src/server.rs @@ -16,8 +16,8 @@ use std::sync::{Arc, Mutex}; use pool_spike::pool_core::{self, Pplns}; use pool_spike::{ - Template, assemble_block, coinbase_with_extranonce, fetch_template, http_client, intro_bytes, - load_or_create_wallet, submit_block_bytes, + Template, assemble_block, coinbase_body_hex, coinbase_with_extranonce, fetch_template, + http_client, intro_bytes, load_or_create_wallet, submit_block_bytes, }; use serde_json::json; @@ -138,7 +138,12 @@ fn handle(mut s: TcpStream, pool: Arc>) { None => (target, String::new()), }; let params = parse_query(&query); - let body = route(&path, ¶ms, &pool); + // The standard miner API carries no worker id, so attribute by source IP. + let peer = s + .peer_addr() + .map(|a| a.ip().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let body = route(&path, ¶ms, &pool, &peer); let resp = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), @@ -155,8 +160,119 @@ fn parse_query(q: &str) -> HashMap { .collect() } -fn route(path: &str, params: &HashMap, pool: &Arc>) -> String { +/// Validate one submitted solution, record it, and on a network-target hit +/// assemble + submit the real block. Shared by our own protocol and the +/// standard miner API. +fn accept_share( + p: &mut Pool, + worker: &str, + height: u64, + coinbase_nonce: [u8; 32], + block_nonce: u32, +) -> serde_json::Value { + if height != p.tpl.height { + return json!({"ok":false,"kind":"stale","height":p.tpl.height}); + } + // Rebuild exactly what the worker hashed: coinbase carrying ITS miner_nonce, + // merkle root = coinbase hash (coinbase-only block), intro with its nonce. + let cb = coinbase_with_extranonce(&p.tpl, &coinbase_nonce); + let intro = intro_bytes(&p.tpl, &cb, block_nonce); + if !pool_core::meets_target(p.tpl.height, &intro, &p.share_target) { + return json!({"ok":false,"kind":"invalid","err":"above share target"}); + } + p.pplns.record(worker); + p.accepted += 1; + if !pool_core::meets_target(p.tpl.height, &intro, &p.network_target) { + return json!({"ok":true,"kind":"share","accepted":p.accepted}); + } + let blk = assemble_block(&p.tpl, &cb, block_nonce); + let submit = submit_block_bytes(&p.client, &p.node, &blk); + p.blocks += 1; + let solved = p.tpl.height; + for _ in 0..6 { + std::thread::sleep(std::time::Duration::from_millis(300)); + p.refresh(); + if p.tpl.height > solved { + break; + } + } + json!({"ok":true,"kind":"block","solved_height":solved,"submit":submit, + "next_height":p.tpl.height,"blocks":p.blocks}) +} + +fn parse32(s: Option<&String>) -> Option<[u8; 32]> { + let v = hex::decode(s?).ok()?; + if v.len() != 32 { + return None; + } + let mut out = [0u8; 32]; + out.copy_from_slice(&v); + Some(out) +} + +fn route( + path: &str, + params: &HashMap, + pool: &Arc>, + peer: &str, +) -> String { match path { + // ---- standard Hacash miner API: an UNMODIFIED poworker can mine here. + // The only difference from a real node is that target_hash carries the + // POOL's share target, so the worker submits shares, not just blocks. + "/query/miner/pending" => { + let p = pool.lock().unwrap(); + let cb = coinbase_with_extranonce(&p.tpl, &[0u8; 32]); + let intro = intro_bytes(&p.tpl, &cb, 0); + json!({ + "ret": 0, + "height": p.tpl.height, + "block_intro": hex::encode(intro), + "target_hash": hex::encode(p.share_target), + "coinbase_body": coinbase_body_hex(&cb), + "mkrl_modify_list": [], + }) + .to_string() + } + "/query/miner/notice" => { + let want: u64 = params.get("height").and_then(|v| v.parse().ok()).unwrap_or(0); + let wait: u64 = params + .get("wait") + .and_then(|v| v.parse().ok()) + .unwrap_or(45) + .clamp(1, 300); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait); + loop { + // brief lock only — never hold it while sleeping + let h = pool.lock().unwrap().tpl.height; + if h > want || std::time::Instant::now() >= deadline { + return json!({"ret":0,"height":h}).to_string(); + } + std::thread::sleep(std::time::Duration::from_millis(400)); + } + } + "/submit/miner/success" => { + let height: u64 = params.get("height").and_then(|v| v.parse().ok()).unwrap_or(0); + let block_nonce: u32 = params + .get("block_nonce") + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let Some(cn) = parse32(params.get("coinbase_nonce")) else { + return json!({"ret":1,"err":"bad coinbase_nonce"}).to_string(); + }; + let mut p = pool.lock().unwrap(); + let r = accept_share(&mut p, peer, height, cn, block_nonce); + let ok = r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); + let kind = r.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + if ok { + println!("[{peer}] {kind} at height {height}"); + json!({"ret":0,"kind":kind}).to_string() + } else { + json!({"ret":1,"kind":kind,"err":r.get("err")}).to_string() + } + } + + // ---- our own simple protocol (test-miner) ---- "/work" => { let worker = params.get("worker").cloned().unwrap_or_else(|| "anon".into()); let mut p = pool.lock().unwrap(); @@ -178,45 +294,10 @@ fn route(path: &str, params: &HashMap, pool: &Arc>) let height: u64 = params.get("height").and_then(|v| v.parse().ok()).unwrap_or(0); let nonce: u32 = params.get("nonce").and_then(|v| v.parse().ok()).unwrap_or(0); let mut p = pool.lock().unwrap(); - - if height != p.tpl.height { - return json!({"ok":false,"kind":"stale","height":p.tpl.height}).to_string(); - } let Some(en) = p.workers.get(&worker).copied() else { return json!({"ok":false,"kind":"invalid","err":"unknown worker"}).to_string(); }; - - let cb = coinbase_with_extranonce(&p.tpl, &en); - let intro = intro_bytes(&p.tpl, &cb, nonce); - if !pool_core::meets_target(p.tpl.height, &intro, &p.share_target) { - return json!({"ok":false,"kind":"invalid","err":"above share target"}).to_string(); - } - - p.pplns.record(&worker); - p.accepted += 1; - let is_block = pool_core::meets_target(p.tpl.height, &intro, &p.network_target); - if !is_block { - return json!({"ok":true,"kind":"share","accepted":p.accepted}).to_string(); - } - - // Full network solution: assemble and submit the real block. - let blk = assemble_block(&p.tpl, &cb, nonce); - let submit = submit_block_bytes(&p.client, &p.node, &blk); - p.blocks += 1; - let solved = p.tpl.height; - // Move to the next template once the node has committed it. - for _ in 0..6 { - std::thread::sleep(std::time::Duration::from_millis(300)); - p.refresh(); - if p.tpl.height > solved { - break; - } - } - json!({ - "ok": true, "kind": "block", "solved_height": solved, - "submit": submit, "next_height": p.tpl.height, "blocks": p.blocks - }) - .to_string() + accept_share(&mut p, &worker, height, en, nonce).to_string() } "/stats" => { let p = pool.lock().unwrap(); From 9f4988adc45052310e8d27988b6f69888aa46305 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 14:38:49 +0200 Subject: [PATCH 13/74] =?UTF-8?q?feat(pool):=20workers=20announce=20their?= =?UTF-8?q?=20payout=20address=20=E2=80=94=20fully=20automatic=20payouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base miner protocol carries no worker identity, so the pool could only attribute shares by source IP. A worker may now announce where it wants to be paid, and the pool uses that address AS the share-accounting key — which removes the name->address mapping entirely. - poworker: optional `pool_worker =
` config key, appended as `&worker=
` to the pending and submit URLs. Empty (the default) keeps the URLs byte-identical to solo mining against a plain fullnode, so nothing changes for existing solo users. - pool server: credits the announced address when it is a valid PRIVAKEY address, else falls back to the source IP. - pool-payout: pays the PPLNS keys directly; keys that are not addresses (the IP fallback) are skipped with a note, replacing the hardcoded demo name map. Verified end-to-end: poworker announced 1NVYv5jm..., mined 7 shares / 7 blocks (chain 13->20, pool wallet 3 -> 10 HAC), then payout split 7 HAC with a 10% pool fee and sent 6.3 HAC to that exact address — recipient 2.0 -> 8.3 HAC, with no manual mapping anywhere. Co-Authored-By: Claude Opus 4.8 --- app/src/poworker.rs | 25 +++++++++++++++++++---- pool-spike/src/lib.rs | 9 +++++++++ pool-spike/src/payout.rs | 43 ++++++++++++++++------------------------ pool-spike/src/server.rs | 12 +++++++++-- 4 files changed, 57 insertions(+), 32 deletions(-) diff --git a/app/src/poworker.rs b/app/src/poworker.rs index cb7a29d..a6f3561 100644 --- a/app/src/poworker.rs +++ b/app/src/poworker.rs @@ -33,6 +33,10 @@ pub struct PoWorkConf { pub rpcaddr: String, /// Optional fullnode API token (`X-Api-Token`) when server requires auth. pub api_token: String, + /// Optional payout address announced to a POOL as `&worker=
` so it + /// can credit this miner's shares. Empty (default) = solo mining: the URLs + /// stay byte-identical to what a plain fullnode expects. + pub pool_worker: String, pub supervene: u32, // cpu core (configured) pub noncemax: u32, pub noticewait: u64, // new block notice wait @@ -55,6 +59,16 @@ pub struct PoWorkConf { } impl PoWorkConf { + /// `&worker=` suffix appended to pool requests so the pool + /// can credit shares to us. Empty string when solo mining. + pub fn worker_param(&self) -> String { + if self.pool_worker.is_empty() { + String::new() + } else { + format!("&worker={}", self.pool_worker) + } + } + pub fn new(ini: &IniObj) -> PoWorkConf { let sec = &ini_section(ini, "default"); // default = root let sec_gpu = &ini_section(ini, "gpu"); @@ -66,6 +80,7 @@ impl PoWorkConf { let cnf = PoWorkConf { rpcaddr: ini_must(sec, "connect", "127.0.0.1:8081"), api_token: ini_must(sec, "api_token", "").trim().to_string(), + pool_worker: ini_must(sec, "pool_worker", "").trim().to_string(), supervene: configured_supervene, noncemax: ini_must_u64(sec, "nonce_max", u32::MAX as u64) as u32, noticewait: ini_must_u64(sec, "notice_wait", 45), @@ -178,9 +193,10 @@ fn pull_pending_block_stuff(cnf: &PoWorkConf) { // query pending let urlapi_pending = format!( - "http://{}/query/miner/pending?stuff=true&t={}", + "http://{}/query/miner/pending?stuff=true&t={}{}", &cnf.rpcaddr, - sys::curtimes() + sys::curtimes(), + cnf.worker_param() ); let jsdata = match crate::rpc_http::get_text(&HTTP_CLIENT, &urlapi_pending, &cnf.api_token, None) { @@ -270,12 +286,13 @@ fn pull_pending_block_stuff(cnf: &PoWorkConf) { fn push_block_mining_success(cnf: &PoWorkConf, success: &block_mining_runtime::BlockMiningResult) { let urlapi_success = format!( - "http://{}/submit/miner/success?height={}&block_nonce={}&coinbase_nonce={}&t={}", + "http://{}/submit/miner/success?height={}&block_nonce={}&coinbase_nonce={}&t={}{}", &cnf.rpcaddr, success.height, success.head_nonce, success.coinbase_nonce.to_hex(), - sys::curtimes() + sys::curtimes(), + cnf.worker_param() ); // Submitting the winning block is the entire payoff of solo mining, and the // result was already drained from the channel — so a single transient network diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs index c42da5f..a701993 100644 --- a/pool-spike/src/lib.rs +++ b/pool-spike/src/lib.rs @@ -67,6 +67,15 @@ pub fn balance(client: &reqwest::blocking::Client, base: &str, addr: &str) -> St find_str(&j, "hacash").unwrap_or_default() } +/// Is this string a payable Hacash address (normal single-key PRIVAKEY)? +/// Workers announce one as `&worker=
`; the pool then uses the address +/// itself as the share-accounting key, so payouts need no name->address map. +pub fn is_payout_address(s: &str) -> bool { + Address::from_readable(s) + .map(|a| a.is_privakey()) + .unwrap_or(false) +} + /// Load the pool wallet from `path` (a file holding a 64-hex secp256k1 private /// key), creating a fresh random one if the file does not exist. The private key /// only ever lives in that file — it is never printed or logged; only the diff --git a/pool-spike/src/payout.rs b/pool-spike/src/payout.rs index d323940..c5589b7 100644 --- a/pool-spike/src/payout.rs +++ b/pool-spike/src/payout.rs @@ -15,20 +15,10 @@ use sys::*; use pool_spike::pool_core::split_payout; use pool_spike::{ - balance, get_json, http_client, load_or_create_wallet, mine_and_submit_block, post_hex, + balance, get_json, http_client, is_payout_address, load_or_create_wallet, + mine_and_submit_block, post_hex, }; -/// Demo mapping worker name -> the account it gets paid into. A real pool takes -/// this from the worker's registration instead. -fn payout_account(worker: &str) -> Option { - let secret: [u8; 32] = match worker { - "alice" => [2u8; 32], - "bob" => [3u8; 32], - _ => return None, - }; - Account::create_by_secret_key_value(secret).ok() -} - fn main() { let a: Vec = std::env::args().collect(); let pool_base = a @@ -85,20 +75,22 @@ fn main() { let main = Address::from(*pool_acc.address()); let fee = Amount::from("1:246").expect("tx fee"); // 0.01 HAC let mut tx = TransactionType2::new_by(main, fee, curtimes()); - let mut paid: Vec<(String, Account, u64)> = Vec::new(); + // PPLNS keys ARE payout addresses when the worker announced one via + // &worker=
; anything else (an IP fallback) cannot be auto-paid. + let mut paid: Vec<(String, u64)> = Vec::new(); for (worker, units) in &split { - let Some(acc) = payout_account(worker) else { - println!(" (skip {worker}: no payout address registered)"); + if !is_payout_address(worker) { + println!(" (skip {worker}: no payout address announced by that worker)"); continue; - }; - let to = Address::from_readable(acc.readable()).expect("payout address"); + } + let to = Address::from_readable(worker).expect("payout address"); let amt = Amount::from(&format!("{units}:247")).expect("amount"); let mut act = HacToTrs::new(); act.to = AddrOrPtr::from_addr(to); act.hacash = amt; tx.push_action(Box::new(act)).expect("push action"); - println!(" -> {worker} {} = {units}:247", acc.readable()); - paid.push((worker.clone(), acc, *units)); + println!(" -> {worker} = {units}:247"); + paid.push((worker.clone(), *units)); } if paid.is_empty() { println!("nothing payable"); @@ -106,8 +98,8 @@ fn main() { } println!("\nbefore:"); - for (w, acc, _) in &paid { - println!(" {w} {} = {}", acc.readable(), balance(&client, &node, acc.readable())); + for (addr, _) in &paid { + println!(" {addr} = {}", balance(&client, &node, addr)); } tx.fill_sign(&pool_acc).expect("fill_sign"); @@ -132,17 +124,16 @@ fn main() { std::thread::sleep(std::time::Duration::from_millis(700)); if paid .iter() - .all(|(_, acc, _)| !balance(&client, &node, acc.readable()).starts_with("0:")) + .all(|(addr, _)| !balance(&client, &node, addr).starts_with("0:")) { break; } } println!("\nafter:"); - for (w, acc, units) in &paid { + for (addr, units) in &paid { println!( - " {w} {} = {} (paid {units}:247)", - acc.readable(), - balance(&client, &node, acc.readable()) + " {addr} = {} (paid {units}:247)", + balance(&client, &node, addr) ); } println!(" pool wallet = {}", balance(&client, &node, pool_acc.readable())); diff --git a/pool-spike/src/server.rs b/pool-spike/src/server.rs index 6b79ce1..9284e9e 100644 --- a/pool-spike/src/server.rs +++ b/pool-spike/src/server.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use pool_spike::pool_core::{self, Pplns}; use pool_spike::{ Template, assemble_block, coinbase_body_hex, coinbase_with_extranonce, fetch_template, - http_client, intro_bytes, load_or_create_wallet, submit_block_bytes, + http_client, intro_bytes, is_payout_address, load_or_create_wallet, submit_block_bytes, }; use serde_json::json; @@ -260,8 +260,16 @@ fn route( let Some(cn) = parse32(params.get("coinbase_nonce")) else { return json!({"ret":1,"err":"bad coinbase_nonce"}).to_string(); }; + // Credit the announced payout address when the worker sends one + // (&worker=); otherwise fall back to the source IP, which the + // operator would have to map by hand at payout time. + let worker = params + .get("worker") + .filter(|w| is_payout_address(w)) + .cloned() + .unwrap_or_else(|| peer.to_string()); let mut p = pool.lock().unwrap(); - let r = accept_share(&mut p, peer, height, cn, block_nonce); + let r = accept_share(&mut p, &worker, height, cn, block_nonce); let ok = r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); let kind = r.get("kind").and_then(|v| v.as_str()).unwrap_or(""); if ok { From dcae2ad1a88949f92ddff00717b0661a04e206e0 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 15:14:02 +0200 Subject: [PATCH 14/74] =?UTF-8?q?feat(pool):=20off-node=20ASERT=20difficul?= =?UTF-8?q?ty=20=E2=80=94=20the=20pool=20can=20now=20build=20mainnet=20tem?= =?UTF-8?q?plates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reimplements the node's ASERT rule outside the node (mint/src/check/difficulty_asert.rs) so the pool computes the next block's difficulty itself. Every detail is load-bearing and mirrored exactly: i128 truncating division for the exponent, arithmetic-shift split into num_shifts + a 16-bit fraction, the cubic 2^x approximation with its round-half-up term, TWO separate BigUint shifts (fusing them changes the truncation), and the clamp order (zero floor -> 2x ease cap -> LOWEST ceiling). Template now carries BOTH representations, which are not interchangeable: the header's u32 `difficulty` and the exact 32-byte PoW target (more precise than u32_to_hash(num) on the from_big path). The pool mines and validates against the exact target. mine_and_submit_block no longer rolls the timestamp on nonce exhaustion, because under ASERT the difficulty is a function of that timestamp. CONSENSUS-VALIDATED on the local testnet, not merely unit-tested: mined across the testnet ASERT activation height. Node-stored difficulties: h288/h289 = 4294967294 (bootstrap LOWEST), h290 = 3922722815 (= ASERT_START_TARGET_NUM 0xe9cfffff), then h291..h307 tracked the rule down to 3922700247 as blocks arrived faster than the 10s target. Every one of those blocks was assembled off-node and ACCEPTED by the node — a single off-by-one would have stalled the chain at 290. Shares also became properly rarer than blocks (367 vs 287). 5 new unit tests (bootstrap range, activation constant, on-schedule reproduces the anchor, faster/slower direction, 2x ease cap); 12 in the crate. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + pool-spike/Cargo.toml | 1 + pool-spike/src/difficulty.rs | 209 +++++++++++++++++++++++++++++++++++ pool-spike/src/lib.rs | 80 ++++++++------ pool-spike/src/main.rs | 4 +- pool-spike/src/payout.rs | 1 + pool-spike/src/server.rs | 20 +++- pool-spike/src/settle.rs | 1 + 8 files changed, 277 insertions(+), 40 deletions(-) create mode 100644 pool-spike/src/difficulty.rs diff --git a/Cargo.lock b/Cargo.lock index e427801..16e6d4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3769,6 +3769,7 @@ dependencies = [ "getrandom 0.3.4", "hex", "mint", + "num-bigint", "protocol", "reqwest", "serde_json", diff --git a/pool-spike/Cargo.toml b/pool-spike/Cargo.toml index f5bd735..1379673 100644 --- a/pool-spike/Cargo.toml +++ b/pool-spike/Cargo.toml @@ -35,3 +35,4 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls serde_json = "1.0" hex = "0.4.3" getrandom = "0.3.2" +num-bigint = "0.4.6" diff --git a/pool-spike/src/difficulty.rs b/pool-spike/src/difficulty.rs new file mode 100644 index 0000000..827c13a --- /dev/null +++ b/pool-spike/src/difficulty.rs @@ -0,0 +1,209 @@ +//! Off-node reimplementation of the node's next-block difficulty rule, so the +//! pool can build templates the node accepts at REAL (mainnet) heights. +//! +//! This mirrors mint/src/check/difficulty_asert.rs exactly. Every detail below +//! is load-bearing — a value that is off by one means the node rejects the +//! block: +//! * the exponent uses i128 `/` (truncates TOWARD ZERO, not floor) +//! * num_shifts uses an arithmetic shift (floor) and the fraction is derived +//! from it, so it is always in [0, 65535] +//! * the polynomial adds (1<<47) BEFORE the >>48 truncation (round-half-up) +//! * the target is shifted TWICE, separately (>> -num_shifts, then >> 16); +//! fusing them changes the truncation +//! * clamp order: zero-floor, then the 2x ease cap, then the LOWEST ceiling +//! +//! It returns BOTH representations, which are NOT interchangeable: the block +//! header must carry the u32 `num`, while the PoW comparison uses the exact +//! 32-byte target hash (which, on the from_big path, is more precise than +//! u32_to_hash(num)). + +use basis::difficulty::*; +use num_bigint::BigUint; + +const ASERT_START_TARGET_NUM: u32 = 0xe9cf_ffff; +const ASERT_HALF_LIFE: i128 = 10800; +const ASERT_RADIX: i128 = 1 << 16; +const ASERT_POLY_1: u128 = 195_766_423_245_049; +const ASERT_POLY_2: u128 = 971_821_376; +const ASERT_POLY_3: u128 = 5_127; +const ASERT_POLY_TERM_SHIFT: u32 = 48; +const ASERT_EASING_MAX_SCALE: u32 = 2; + +/// The chain parameters the difficulty rule depends on. +#[derive(Clone, Debug)] +pub struct ChainParams { + /// Height at which ASERT activates and which is also its anchor. + pub asert_height: u64, + /// `[mint] each_block_target_time` (mainnet 300s, testnet 10s). + pub target_time: u64, + /// Heights <= this use the bootstrap LOWEST_DIFFICULTY (testnet only). + pub bootstrap_max: u64, +} + +impl ChainParams { + pub fn mainnet() -> Self { + Self { + asert_height: 738654, + target_time: 300, + bootstrap_max: 0, + } + } + /// Non-mainnet: ASERT anchors at window+2 and heights <= window+1 bootstrap. + pub fn testnet(adjust_blocks: u64, target_time: u64) -> Self { + Self { + asert_height: adjust_blocks + 2, + target_time, + bootstrap_max: adjust_blocks + 1, + } + } + pub fn from_name(name: &str) -> Self { + match name { + "mainnet" => Self::mainnet(), + _ => Self::testnet(288, 10), + } + } + /// Does computing this height's difficulty need the anchor block's timestamp? + pub fn needs_anchor(&self, height: u64) -> bool { + height > self.asert_height + } +} + +/// Next block's difficulty as (header `difficulty` u32, PoW target hash). +pub fn next_difficulty( + p: &ChainParams, + height: u64, + timestamp: u64, + prev_difficulty: u32, + anchor_time: u64, +) -> (u32, [u8; 32]) { + if height <= p.bootstrap_max { + let t = DifficultyTarget::from_num(LOWEST_DIFFICULTY); + return (t.num, t.hash); + } + if height == p.asert_height { + // Activation block: fixed start target, no parent cap. + let t = DifficultyTarget::from_num(ASERT_START_TARGET_NUM); + return (t.num, t.hash); + } + assert!( + height > p.asert_height, + "height {height} is in the pre-ASERT (legacy/LWMA) range, which this \ + off-node builder does not implement — a pool only mines at the tip" + ); + + let time_delta = timestamp as i128 - anchor_time as i128; + let height_delta = height as i128 - p.asert_height as i128; + // i128 division truncates toward zero. Multiply by the radix FIRST. + let exponent = + ((time_delta - p.target_time as i128 * height_delta) * ASERT_RADIX) / ASERT_HALF_LIFE; + let num_shifts = exponent >> 16; // arithmetic shift == floor + let frac = (exponent - (num_shifts << 16)) as u128; // always 0..=65535 + let frac2 = frac * frac; + let frac3 = frac2 * frac; + let factor = (((ASERT_POLY_1 * frac + + ASERT_POLY_2 * frac2 + + ASERT_POLY_3 * frac3 + + (1u128 << (ASERT_POLY_TERM_SHIFT - 1))) + >> ASERT_POLY_TERM_SHIFT) + + 65536) as u64; + + let anchor_target = u32_to_biguint(ASERT_START_TARGET_NUM); + let ease_target = u32_to_biguint(prev_difficulty) * BigUint::from(ASERT_EASING_MAX_SCALE); + let max_target = u32_to_biguint(LOWEST_DIFFICULTY); + + let mut next = anchor_target * BigUint::from(factor); + if num_shifts < 0 { + next >>= (-num_shifts) as usize; + } else if num_shifts > 0 { + next <<= num_shifts as usize; + } + next >>= 16usize; + + if next == BigUint::from(0u8) { + let t = DifficultyTarget::from_big(BigUint::from(1u8)); + return (t.num, t.hash); + } + if next > ease_target { + next = ease_target; // never more than 2x easier than the parent + } + if next > max_target { + let t = DifficultyTarget::from_num(LOWEST_DIFFICULTY); + return (t.num, t.hash); + } + let t = DifficultyTarget::from_big(next); + (t.num, t.hash) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bootstrap_heights_use_lowest_difficulty() { + let p = ChainParams::testnet(288, 10); + let (num, hash) = next_difficulty(&p, 1, 1_000, 0, 0); + assert_eq!(num, LOWEST_DIFFICULTY); + assert_eq!(hash, DifficultyTarget::from_num(LOWEST_DIFFICULTY).hash); + // the last bootstrap height is window+1 + assert_eq!(next_difficulty(&p, 289, 1_000, 0, 0).0, LOWEST_DIFFICULTY); + } + + #[test] + fn activation_height_uses_the_fixed_start_target() { + let p = ChainParams::testnet(288, 10); + let (num, hash) = next_difficulty(&p, 290, 9_999, LOWEST_DIFFICULTY, 0); + assert_eq!(num, ASERT_START_TARGET_NUM); + assert_eq!(hash, DifficultyTarget::from_num(ASERT_START_TARGET_NUM).hash); + // mainnet anchors at 738654 + let m = ChainParams::mainnet(); + assert_eq!( + next_difficulty(&m, 738654, 9_999, 0, 0).0, + ASERT_START_TARGET_NUM + ); + } + + #[test] + fn on_schedule_reproduces_the_anchor_target() { + // Exactly on schedule => exponent 0 => factor 65536 => target == anchor. + let p = ChainParams::testnet(288, 10); + let anchor_time = 1_000_000u64; + let height = p.asert_height + 5; + let timestamp = anchor_time + 5 * p.target_time; // perfectly on schedule + let (num, _) = next_difficulty(&p, height, timestamp, ASERT_START_TARGET_NUM, anchor_time); + assert_eq!(num, ASERT_START_TARGET_NUM); + } + + #[test] + fn faster_blocks_make_it_harder_slower_makes_it_easier() { + let p = ChainParams::testnet(288, 10); + let anchor_time = 1_000_000u64; + let height = p.asert_height + 100; + let on_time = anchor_time + 100 * p.target_time; + let base = DifficultyTarget::from_num( + next_difficulty(&p, height, on_time, ASERT_START_TARGET_NUM, anchor_time).0, + ); + // ahead of schedule (mined too fast) -> smaller target (harder) + let fast = DifficultyTarget::from_num( + next_difficulty(&p, height, on_time - 600, ASERT_START_TARGET_NUM, anchor_time).0, + ); + // behind schedule -> larger target (easier), capped at 2x the parent + let slow = DifficultyTarget::from_num( + next_difficulty(&p, height, on_time + 600, ASERT_START_TARGET_NUM, anchor_time).0, + ); + assert!(fast.big < base.big, "faster blocks must tighten the target"); + assert!(slow.big > base.big, "slower blocks must ease the target"); + } + + #[test] + fn easing_is_capped_at_twice_the_parent_target() { + let p = ChainParams::testnet(288, 10); + let anchor_time = 1_000_000u64; + let height = p.asert_height + 10; + // absurdly far behind schedule -> would explode, must clamp to 2x parent + let prev = ASERT_START_TARGET_NUM; + let (_, hash) = next_difficulty(&p, height, anchor_time + 10_000_000, prev, anchor_time); + let cap = u32_to_biguint(prev) * BigUint::from(2u32); + let got = DifficultyTarget::from_num(hash_to_u32(&hash)).big; + assert!(got <= cap, "must never ease past 2x the parent target"); + } +} diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs index a701993..53ce578 100644 --- a/pool-spike/src/lib.rs +++ b/pool-spike/src/lib.rs @@ -3,8 +3,11 @@ //! coinbase plus optional extra transactions. Targets a fresh local testnet //! (bootstrap LOWEST_DIFFICULTY); does not reproduce mainnet ASERT difficulty. +pub mod difficulty; pub mod pool_core; +use difficulty::ChainParams; + use basis::difficulty::*; use basis::interface::*; use field::*; @@ -117,34 +120,55 @@ pub struct Template { pub height: u64, pub prevhash: Hash, pub timestamp: u64, + /// Header `difficulty` field (u32) — must equal what the node recomputes. pub difficulty: u32, + /// The exact PoW target for this block. NOT interchangeable with + /// u32_to_hash(difficulty): on the from_big path it is more precise. + pub target: [u8; 32], pub coinbase_addr: Address, } -/// Read the chain tip and build a template for the next block. +/// Read the chain tip and build a template for the next block, computing the +/// next difficulty off-node with the same rule the node will validate against. pub fn fetch_template( client: &reqwest::blocking::Client, base: &str, coinbase_addr: &str, + params: &ChainParams, ) -> Template { let latest = get_json(client, &format!("{base}/query/latest")); let prev_hei = find_u64(&latest, "height").expect("no 'height' in /query/latest"); let height = prev_hei + 1; - let (prevhash, prev_ts) = if prev_hei == 0 { - (mint::genesis::genesis_block_hash(), 1549250700u64) + let (prevhash, prev_ts, prev_diff) = if prev_hei == 0 { + (mint::genesis::genesis_block_hash(), 1549250700u64, 0u32) } else { let ij = get_json(client, &format!("{base}/query/block/intro?height={prev_hei}")); let ph = find_str(&ij, "hash").expect("no 'hash' in block intro"); ( Hash::from_hex(ph.as_bytes()).expect("bad prevhash hex"), find_u64(&ij, "timestamp").unwrap_or(0), + find_u64(&ij, "difficulty").unwrap_or(0) as u32, ) }; + let timestamp = std::cmp::max(curtimes(), prev_ts.saturating_add(1)); + // ASERT anchors on the activation block's timestamp; only needed above it. + let anchor_time = if params.needs_anchor(height) { + let aj = get_json( + client, + &format!("{base}/query/block/intro?height={}", params.asert_height), + ); + find_u64(&aj, "timestamp").expect("anchor block timestamp") + } else { + 0 + }; + let (diff_num, target) = + difficulty::next_difficulty(params, height, timestamp, prev_diff, anchor_time); Template { height, prevhash, - timestamp: std::cmp::max(curtimes(), prev_ts.saturating_add(1)), - difficulty: LOWEST_DIFFICULTY, + timestamp, + difficulty: diff_num, + target, coinbase_addr: Address::from_readable(coinbase_addr).expect("bad coinbase address"), } } @@ -223,27 +247,10 @@ pub fn mine_and_submit_block( base: &str, coinbase_addr: &str, extra_txs: Vec>, + params: &ChainParams, ) -> (u64, String) { - let latest = get_json(client, &format!("{base}/query/latest")); - let prev_hei = find_u64(&latest, "height").expect("no 'height' in /query/latest"); - let next_hei = prev_hei + 1; - - let (prevhash, prev_ts) = if prev_hei == 0 { - (mint::genesis::genesis_block_hash(), 1549250700u64) - } else { - let ij = get_json(client, &format!("{base}/query/block/intro?height={prev_hei}")); - let ph = find_str(&ij, "hash").expect("no 'hash' in block intro"); - ( - Hash::from_hex(ph.as_bytes()).expect("bad prevhash hex"), - find_u64(&ij, "timestamp").unwrap_or(0), - ) - }; - - let diff: u32 = LOWEST_DIFFICULTY; - let next_ts = std::cmp::max(curtimes(), prev_ts.saturating_add(1)); - - let adr = Address::from_readable(coinbase_addr).expect("bad coinbase address"); - let cbtx = mint::create_coinbase_tx(next_hei, Fixed16::default(), adr); + let tpl = fetch_template(client, base, coinbase_addr, params); + let cbtx = mint::create_coinbase_tx(tpl.height, Fixed16::default(), tpl.coinbase_addr.clone()); let mut trshxs: Vec = vec![cbtx.hash_with_fee()]; let mut transactions = DynVecTransaction::default(); @@ -257,30 +264,35 @@ pub fn mine_and_submit_block( let mut intro = BlockIntro { head: BlockHead { version: Uint1::from(1), - height: BlockHeight::from(next_hei), - timestamp: Timestamp::from(next_ts), - prevhash, + height: BlockHeight::from(tpl.height), + timestamp: Timestamp::from(tpl.timestamp), + prevhash: tpl.prevhash.clone(), mrklroot: calculate_mrklroot(&trshxs), transaction_count: Uint4::from(count), }, meta: BlockMeta { nonce: Uint4::default(), - difficulty: Uint4::from(diff), + difficulty: Uint4::from(tpl.difficulty), witness_stage: Fixed2::default(), }, }; - let target = DifficultyTarget::from_num(diff).hash; let mut nonce: u32 = 0; loop { intro.meta.nonce = Uint4::from(nonce); - let ph = x16rs::block_hash(next_hei, &intro.serialize()); - if !hash_bigger_than(&ph, &target) { + let ph = x16rs::block_hash(tpl.height, &intro.serialize()); + if !hash_bigger_than(&ph, &tpl.target) { break; } nonce = nonce.wrapping_add(1); if nonce == 0 { - intro.head.timestamp = Timestamp::from(curtimes()); + // Never roll the timestamp here: under ASERT the difficulty is a + // function of this block's own timestamp, so changing it would make + // the header's difficulty field wrong. Ask for a fresh template. + return ( + tpl.height, + "{\"ok\":false,\"err\":\"nonce space exhausted; re-fetch template\"}".to_string(), + ); } } @@ -290,5 +302,5 @@ pub fn mine_and_submit_block( &format!("{base}/submit/block?hexbody=true"), &hex::encode(block.serialize()), ); - (next_hei, resp) + (tpl.height, resp) } diff --git a/pool-spike/src/main.rs b/pool-spike/src/main.rs index e1e04db..d04b911 100644 --- a/pool-spike/src/main.rs +++ b/pool-spike/src/main.rs @@ -7,6 +7,7 @@ use std::env; +use pool_spike::difficulty::ChainParams; use pool_spike::{balance, http_client, mine_and_submit_block}; fn main() { @@ -25,8 +26,9 @@ fn main() { println!("node = {base}"); println!("payout = {payout}"); + let params = ChainParams::from_name(&args.get(3).cloned().unwrap_or_else(|| "testnet".into())); let client = http_client(); - let (h, resp) = mine_and_submit_block(&client, &base, &payout, vec![]); + let (h, resp) = mine_and_submit_block(&client, &base, &payout, vec![], ¶ms); println!("mined + submitted block {h} -> {resp}"); std::thread::sleep(std::time::Duration::from_millis(900)); diff --git a/pool-spike/src/payout.rs b/pool-spike/src/payout.rs index c5589b7..e4f2f12 100644 --- a/pool-spike/src/payout.rs +++ b/pool-spike/src/payout.rs @@ -117,6 +117,7 @@ fn main() { &node, pool_acc.readable(), vec![Box::new(tx) as Box], + &pool_spike::difficulty::ChainParams::from_name("testnet"), ); println!("mined confirming block {h} -> {blkresp}"); diff --git a/pool-spike/src/server.rs b/pool-spike/src/server.rs index 9284e9e..6b41fe9 100644 --- a/pool-spike/src/server.rs +++ b/pool-spike/src/server.rs @@ -14,6 +14,7 @@ use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; +use pool_spike::difficulty::ChainParams; use pool_spike::pool_core::{self, Pplns}; use pool_spike::{ Template, assemble_block, coinbase_body_hex, coinbase_with_extranonce, fetch_template, @@ -26,6 +27,7 @@ struct Pool { node: String, payout: String, client: reqwest::blocking::Client, + params: ChainParams, tpl: Template, share_target: [u8; 32], network_target: [u8; 32], @@ -39,8 +41,9 @@ struct Pool { impl Pool { /// Re-read the tip and rebuild the template (after a block, or when stale). fn refresh(&mut self) { - self.tpl = fetch_template(&self.client, &self.node, &self.payout); - self.network_target = pool_core::network_target_hash(self.tpl.difficulty); + self.tpl = fetch_template(&self.client, &self.node, &self.payout, &self.params); + // Use the template's EXACT target, not u32_to_hash(difficulty). + self.network_target = self.tpl.target; } /// Stable per-worker extranonce -> private search space (coinbase miner_nonce). @@ -83,6 +86,8 @@ fn main() { .unwrap_or_else(|| "pool-wallet.key".to_string()); let listen = a.get(3).cloned().unwrap_or_else(|| "127.0.0.1:9777".to_string()); let share_bits: u32 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(8); + let chain = a.get(5).cloned().unwrap_or_else(|| "testnet".to_string()); + let params = ChainParams::from_name(&chain); println!("== pool-server =="); println!("node = {node}"); @@ -91,17 +96,22 @@ fn main() { let payout = wallet.readable().to_string(); let client = http_client(); - let tpl = fetch_template(&client, &node, &payout); - let network_target = pool_core::network_target_hash(tpl.difficulty); + let tpl = fetch_template(&client, &node, &payout, ¶ms); + let network_target = tpl.target; println!("listen = {listen}"); + println!("chain = {chain} (ASERT at height {})", params.asert_height); println!("share = {share_bits} leading zero bits"); - println!("height = {} (template)", tpl.height); + println!( + "height = {} (template, difficulty {})", + tpl.height, tpl.difficulty + ); let pool = Arc::new(Mutex::new(Pool { node, payout, client, + params, tpl, share_target: target_leading_zero_bits(share_bits), network_target, diff --git a/pool-spike/src/settle.rs b/pool-spike/src/settle.rs index 0cd420c..dd63e5d 100644 --- a/pool-spike/src/settle.rs +++ b/pool-spike/src/settle.rs @@ -85,6 +85,7 @@ fn main() { &base, sender.readable(), vec![Box::new(tx) as Box], + &pool_spike::difficulty::ChainParams::from_name("testnet"), ); println!("mined confirming block {h} (coinbase+transfer) -> {blkresp}"); From 3717828c96b9ad8c6aeb8e674fe10d9dcc0a6ff2 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 15:24:30 +0200 Subject: [PATCH 15/74] =?UTF-8?q?feat(pool):=20harden=20for=20real=20miner?= =?UTF-8?q?s=20=E2=80=94=20dedupe,=20persistence,=20reorg-aware,=20auto-se?= =?UTF-8?q?ttle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four protections that matter once other people's hashrate is at stake: 1. DUPLICATE SHARES REJECTED. accept_share now keys every solution by (height, coinbase_nonce, block_nonce) and refuses replays before crediting anything. Without this a miner could resubmit one solution N times and steal N times its fair share of the payout. Verified: replaying an accepted share twice returns {"kind":"duplicate"} and accepted_shares stays put. 2. ACCOUNTING PERSISTED. Pplns gained snapshot()/restore(); the server writes the window plus counters to .state.json on every accepted share and reloads it at startup, so a restart never erases credited work. Verified across a real restart: 1 share / worker "dup-test" survived. 3. REORG-AWARE BLOCK COUNTING. A submitted block is parked in `submitted` and only counted once the chain still holds OUR hash at that height; a mismatch is reported as orphaned instead of being paid for. /stats now separates blocks_confirmed / blocks_pending / blocks_orphaned. 4. AUTOMATIC SETTLEMENT. A timer thread pays every miner their PPLNS share of the spendable balance (keeping a fee reserve) in ONE signed transaction, using the pool wallet from the key file. Interval is a CLI arg. Also: /stats reports the live difficulty, and pool_core gained hash_of/beats so a solution is hashed once and compared against both targets. Co-Authored-By: Claude Opus 4.8 --- pool-spike/src/pool_core.rs | 42 ++++- pool-spike/src/server.rs | 356 ++++++++++++++++++++++++++++-------- 2 files changed, 325 insertions(+), 73 deletions(-) diff --git a/pool-spike/src/pool_core.rs b/pool-spike/src/pool_core.rs index 4919119..4a6d5b9 100644 --- a/pool-spike/src/pool_core.rs +++ b/pool-spike/src/pool_core.rs @@ -36,9 +36,19 @@ pub fn network_target_hash(network_difficulty: u32) -> [u8; 32] { DifficultyTarget::from_num(network_difficulty).hash } +/// The PoW hash of a serialized 89-byte block header. +pub fn hash_of(height: u64, header: &[u8]) -> [u8; 32] { + x16rs::block_hash(height, header) +} + +/// Does an already-computed hash meet `target`? +pub fn beats(hash: &[u8; 32], target: &[u8; 32]) -> bool { + !hash_bigger_than(hash, target) +} + /// True if the solved 89-byte block header meets `target` (x16rs hash <= target). pub fn meets_target(height: u64, header: &[u8], target: &[u8; 32]) -> bool { - !hash_bigger_than(&x16rs::block_hash(height, header), target) + beats(&hash_of(height, header), target) } /// PPLNS accounting over a rolling window of the last `window` accepted shares. @@ -79,6 +89,21 @@ impl Pplns { self.order.len() as u64 } + /// The raw window (oldest first) — enough to persist and restore accounting + /// so a pool restart never loses a miner's credited work. + pub fn snapshot(&self) -> Vec { + self.order.iter().cloned().collect() + } + + /// Rebuild from a snapshot produced by [`Pplns::snapshot`]. + pub fn restore(window: usize, order: Vec) -> Self { + let mut p = Self::new(window); + for w in order { + p.record(&w); + } + p + } + /// worker -> share count in the current window, descending by count then id. pub fn counts(&self) -> Vec<(String, u64)> { let mut v: Vec<(String, u64)> = @@ -175,6 +200,21 @@ mod tests { assert_eq!(a, 1); } + #[test] + fn pplns_survives_a_snapshot_restore_round_trip() { + let mut p = Pplns::new(8); + for w in ["a", "b", "a", "c", "a"] { + p.record(w); + } + let restored = Pplns::restore(8, p.snapshot()); + assert_eq!(restored.total(), p.total()); + assert_eq!(restored.counts(), p.counts()); + assert_eq!( + restored.counts().iter().find(|(w, _)| w == "a").unwrap().1, + 3 + ); + } + #[test] fn split_is_proportional_and_exact() { let counts = vec![("a".to_string(), 3u64), ("b".to_string(), 1u64)]; diff --git a/pool-spike/src/server.rs b/pool-spike/src/server.rs index 6b41fe9..d533b89 100644 --- a/pool-spike/src/server.rs +++ b/pool-spike/src/server.rs @@ -1,31 +1,52 @@ -//! Minimal Hacash pool server (spike): serves work, validates shares with -//! pool_core, keeps PPLNS accounting, and submits full blocks to the node. -//! Blocking HTTP on std::net — no async runtime, no node changes. +//! Hacash pool server: serves work, validates shares, keeps PPLNS accounting, +//! submits full blocks, and settles payouts. Blocking HTTP on std::net — no +//! async runtime, no node changes. //! -//! Endpoints: -//! GET /work?worker=NAME -> {height, intro, share_target, network_target, extranonce} -//! GET /share?worker=NAME&height=H&nonce=N -> {ok, kind: share|block|stale|invalid} -//! GET /stats -> {height, accepted_shares, blocks, workers} +//! Speaks the STANDARD miner API (so an unmodified poworker can mine here) with +//! one difference: `target_hash` carries the pool's SHARE target. A submission +//! is promoted to a real block whenever it also beats the network target. //! -//! Usage: pool-server [node_base] [pool_payout_addr] [listen_addr] [share_bits] +//! Protections that matter once other people's hashrate is involved: +//! * duplicate shares are rejected (a resubmitted solution cannot inflate a +//! miner's PPLNS credit at everyone else's expense) +//! * accounting is persisted, so a restart never erases credited work +//! * a submitted block only counts once the chain still holds OUR hash at that +//! height — orphans are detected and not paid for +//! * settlement runs automatically on a timer +//! +//! Endpoints: /work, /share, /stats (own protocol) and /query/miner/pending, +//! /query/miner/notice, /submit/miner/success (standard API). +//! +//! Usage: pool-server [node] [wallet_file] [listen] [share_bits] [chain] [settle_secs] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::{BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use basis::interface::*; +use field::*; +use protocol::action::HacToTrs; +use protocol::transaction::TransactionType2; +use sys::curtimes; use pool_spike::difficulty::ChainParams; -use pool_spike::pool_core::{self, Pplns}; +use pool_spike::pool_core::{self, Pplns, split_payout}; use pool_spike::{ - Template, assemble_block, coinbase_body_hex, coinbase_with_extranonce, fetch_template, - http_client, intro_bytes, is_payout_address, load_or_create_wallet, submit_block_bytes, + Template, assemble_block, balance, coinbase_body_hex, coinbase_with_extranonce, fetch_template, + find_str, get_json, http_client, intro_bytes, is_payout_address, load_or_create_wallet, + post_hex, submit_block_bytes, }; use serde_json::json; +const PPLNS_WINDOW: usize = 4096; + struct Pool { node: String, payout: String, + state_file: String, client: reqwest::blocking::Client, params: ChainParams, tpl: Template, @@ -36,14 +57,53 @@ struct Pool { pplns: Pplns, accepted: u64, blocks: u64, + orphaned: u64, + /// Solutions already credited for the current template — rejects replays. + seen: HashSet<(u64, [u8; 32], u32)>, + /// Blocks we submitted, awaiting confirmation that they stuck. + submitted: Vec<(u64, [u8; 32])>, } impl Pool { - /// Re-read the tip and rebuild the template (after a block, or when stale). fn refresh(&mut self) { + let before = self.tpl.height; self.tpl = fetch_template(&self.client, &self.node, &self.payout, &self.params); // Use the template's EXACT target, not u32_to_hash(difficulty). self.network_target = self.tpl.target; + if self.tpl.height != before { + // A share is only valid against the template it was mined for. + self.seen.clear(); + } + self.confirm_submitted(); + } + + /// Count a submitted block only once the chain still holds OUR hash at that + /// height. Anything else lost a reorg and must not be paid for. + fn confirm_submitted(&mut self) { + let tip = self.tpl.height.saturating_sub(1); + let mut pending = Vec::new(); + for (h, ours) in std::mem::take(&mut self.submitted) { + if h > tip { + pending.push((h, ours)); + continue; + } + let j = get_json( + &self.client, + &format!("{}/query/block/intro?height={h}", self.node), + ); + match find_str(&j, "hash") { + Some(chain_hash) => { + if chain_hash == hex::encode(ours) { + self.blocks += 1; + } else { + self.orphaned += 1; + println!("[reorg] our block {h} was orphaned (chain holds {chain_hash})"); + } + } + None => pending.push((h, ours)), // node has not stored it yet + } + } + self.submitted = pending; } /// Stable per-worker extranonce -> private search space (coinbase miner_nonce). @@ -57,9 +117,51 @@ impl Pool { self.workers.insert(worker.to_string(), en); en } + + fn save_state(&self) { + if self.state_file.is_empty() { + return; + } + let body = json!({ + "window": PPLNS_WINDOW, + "order": self.pplns.snapshot(), + "accepted": self.accepted, + "blocks": self.blocks, + "orphaned": self.orphaned, + }); + let _ = std::fs::write(&self.state_file, body.to_string()); + } + + fn load_state(&mut self) { + let Ok(txt) = std::fs::read_to_string(&self.state_file) else { + return; + }; + let Ok(j) = serde_json::from_str::(&txt) else { + return; + }; + let order: Vec = j + .get("order") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + self.pplns = Pplns::restore(PPLNS_WINDOW, order); + self.accepted = j.get("accepted").and_then(|v| v.as_u64()).unwrap_or(0); + self.blocks = j.get("blocks").and_then(|v| v.as_u64()).unwrap_or(0); + self.orphaned = j.get("orphaned").and_then(|v| v.as_u64()).unwrap_or(0); + println!( + "restored accounting: {} shares in window, {} blocks, {} orphaned", + self.pplns.total(), + self.blocks, + self.orphaned + ); + } } -/// A target requiring `bits` leading zero bits (pool difficulty knob). +/// A target requiring `bits` leading zero bits (the pool difficulty knob). fn target_leading_zero_bits(bits: u32) -> [u8; 32] { let mut t = [0xffu8; 32]; let full = (bits / 8) as usize; @@ -73,6 +175,24 @@ fn target_leading_zero_bits(bits: u32) -> [u8; 32] { t } +/// A "mantissa:unit" balance expressed in units of 0.1 HAC (unit 247). +fn balance_units(bal: &str) -> u64 { + let Some((m, u)) = bal.split_once(':') else { + return 0; + }; + let (Ok(m), Ok(u)) = (m.trim().parse::(), u.trim().parse::()) else { + return 0; + }; + if u < 247 { + return 0; // below our accounting granularity + } + let exp = (u - 247) as u32; + if exp > 18 { + return u64::MAX; + } + m.saturating_mul(10u64.pow(exp)) +} + fn main() { let a: Vec = std::env::args().collect(); let node = a @@ -84,14 +204,17 @@ fn main() { .get(2) .cloned() .unwrap_or_else(|| "pool-wallet.key".to_string()); - let listen = a.get(3).cloned().unwrap_or_else(|| "127.0.0.1:9777".to_string()); + let listen = a + .get(3) + .cloned() + .unwrap_or_else(|| "127.0.0.1:9777".to_string()); let share_bits: u32 = a.get(4).and_then(|s| s.parse().ok()).unwrap_or(8); let chain = a.get(5).cloned().unwrap_or_else(|| "testnet".to_string()); + let settle_secs: u64 = a.get(6).and_then(|s| s.parse().ok()).unwrap_or(300); let params = ChainParams::from_name(&chain); println!("== pool-server =="); println!("node = {node}"); - // The pool's coinbase + settlement wallet. Key stays in the file. let wallet = load_or_create_wallet(&wallet_file); let payout = wallet.readable().to_string(); @@ -102,14 +225,16 @@ fn main() { println!("listen = {listen}"); println!("chain = {chain} (ASERT at height {})", params.asert_height); println!("share = {share_bits} leading zero bits"); + println!("settle = every {settle_secs}s"); println!( "height = {} (template, difficulty {})", tpl.height, tpl.difficulty ); - let pool = Arc::new(Mutex::new(Pool { - node, + let mut pool = Pool { + node: node.clone(), payout, + state_file: format!("{wallet_file}.state.json"), client, params, tpl, @@ -117,10 +242,27 @@ fn main() { network_target, workers: HashMap::new(), next_en: 0, - pplns: Pplns::new(1024), + pplns: Pplns::new(PPLNS_WINDOW), accepted: 0, blocks: 0, - })); + orphaned: 0, + seen: HashSet::new(), + submitted: Vec::new(), + }; + pool.load_state(); + let pool = Arc::new(Mutex::new(pool)); + + // Automatic settlement on a timer. + { + let p = pool.clone(); + let wf = wallet_file.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_secs(settle_secs)); + settle_once(&p, &wf); + } + }); + } let listener = TcpListener::bind(&listen).expect("bind"); println!("listening...\n"); @@ -135,44 +277,68 @@ fn main() { } } -fn handle(mut s: TcpStream, pool: Arc>) { - let Ok(peek) = s.try_clone() else { return }; - let mut reader = BufReader::new(peek); - let mut line = String::new(); - if reader.read_line(&mut line).is_err() { +/// Pay every miner their PPLNS share of the pool's spendable balance, in ONE +/// signed transaction submitted to the node's mempool. +fn settle_once(pool: &Arc>, wallet_file: &str) { + let (node, counts) = { + let p = pool.lock().unwrap(); + (p.node.clone(), p.pplns.counts()) + }; + if counts.is_empty() { + return; + } + let acc = load_or_create_wallet(wallet_file); + let client = http_client(); + let bal = balance(&client, &node, acc.readable()); + let units = balance_units(&bal); + // Keep a reserve so the wallet can always pay tx fees. + let reserve = 5u64; // 0.5 HAC + if units <= reserve + 1 { + return; + } + let distributable = units - reserve; + let fee_units = (distributable / 10).max(1); // 10% pool fee + let split = split_payout(distributable, fee_units, 1, &counts); + let payable: Vec<(String, u64)> = split + .into_iter() + .filter(|(w, _)| is_payout_address(w)) + .collect(); + if payable.is_empty() { return; } - let target = line.split_whitespace().nth(1).unwrap_or("/").to_string(); - let (path, query) = match target.split_once('?') { - Some((p, q)) => (p.to_string(), q.to_string()), - None => (target, String::new()), - }; - let params = parse_query(&query); - // The standard miner API carries no worker id, so attribute by source IP. - let peer = s - .peer_addr() - .map(|a| a.ip().to_string()) - .unwrap_or_else(|_| "unknown".to_string()); - let body = route(&path, ¶ms, &pool, &peer); - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = s.write_all(resp.as_bytes()); -} -fn parse_query(q: &str) -> HashMap { - q.split('&') - .filter(|kv| !kv.is_empty()) - .filter_map(|kv| kv.split_once('=')) - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() + let main = Address::from(*acc.address()); + let fee = Amount::from("1:246").expect("fee"); + let mut tx = TransactionType2::new_by(main, fee, curtimes()); + for (addr, u) in &payable { + let to = Address::from_readable(addr).expect("payout address"); + let amt = Amount::from(&format!("{u}:247")).expect("amount"); + let mut act = HacToTrs::new(); + act.to = AddrOrPtr::from_addr(to); + act.hacash = amt; + if tx.push_action(Box::new(act)).is_err() { + break; + } + } + if tx.fill_sign(&acc).is_err() { + println!("[settle] signing failed"); + return; + } + let body = hex::encode(tx.serialize()); + let resp = post_hex( + &client, + &format!("{node}/submit/transaction?hexbody=true"), + &body, + ); + println!( + "[settle] paid {} miner(s) from {} units -> {resp}", + payable.len(), + distributable + ); } /// Validate one submitted solution, record it, and on a network-target hit -/// assemble + submit the real block. Shared by our own protocol and the -/// standard miner API. +/// assemble + submit the real block. Shared by both protocols. fn accept_share( p: &mut Pool, worker: &str, @@ -183,29 +349,42 @@ fn accept_share( if height != p.tpl.height { return json!({"ok":false,"kind":"stale","height":p.tpl.height}); } - // Rebuild exactly what the worker hashed: coinbase carrying ITS miner_nonce, - // merkle root = coinbase hash (coinbase-only block), intro with its nonce. + // Reject replays BEFORE any crediting: the same solution must never be + // counted twice, or a miner could inflate its share of the payout. + let key = (height, coinbase_nonce, block_nonce); + if p.seen.contains(&key) { + return json!({"ok":false,"kind":"duplicate"}); + } + // Rebuild exactly what the worker hashed. let cb = coinbase_with_extranonce(&p.tpl, &coinbase_nonce); let intro = intro_bytes(&p.tpl, &cb, block_nonce); - if !pool_core::meets_target(p.tpl.height, &intro, &p.share_target) { + let hash = pool_core::hash_of(p.tpl.height, &intro); + if !pool_core::beats(&hash, &p.share_target) { return json!({"ok":false,"kind":"invalid","err":"above share target"}); } + + p.seen.insert(key); p.pplns.record(worker); p.accepted += 1; - if !pool_core::meets_target(p.tpl.height, &intro, &p.network_target) { + p.save_state(); + + if !pool_core::beats(&hash, &p.network_target) { return json!({"ok":true,"kind":"share","accepted":p.accepted}); } + let blk = assemble_block(&p.tpl, &cb, block_nonce); let submit = submit_block_bytes(&p.client, &p.node, &blk); - p.blocks += 1; let solved = p.tpl.height; + // Counted only after confirm_submitted() sees it stick. + p.submitted.push((solved, hash)); for _ in 0..6 { - std::thread::sleep(std::time::Duration::from_millis(300)); + std::thread::sleep(Duration::from_millis(300)); p.refresh(); if p.tpl.height > solved { break; } } + p.save_state(); json!({"ok":true,"kind":"block","solved_height":solved,"submit":submit, "next_height":p.tpl.height,"blocks":p.blocks}) } @@ -220,6 +399,41 @@ fn parse32(s: Option<&String>) -> Option<[u8; 32]> { Some(out) } +fn handle(mut s: TcpStream, pool: Arc>) { + let Ok(peek) = s.try_clone() else { return }; + let mut reader = BufReader::new(peek); + let mut line = String::new(); + if reader.read_line(&mut line).is_err() { + return; + } + let target = line.split_whitespace().nth(1).unwrap_or("/").to_string(); + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p.to_string(), q.to_string()), + None => (target, String::new()), + }; + let params = parse_query(&query); + // The standard miner API carries no worker id, so attribute by source IP. + let peer = s + .peer_addr() + .map(|a| a.ip().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let body = route(&path, ¶ms, &pool, &peer); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = s.write_all(resp.as_bytes()); +} + +fn parse_query(q: &str) -> HashMap { + q.split('&') + .filter(|kv| !kv.is_empty()) + .filter_map(|kv| kv.split_once('=')) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + fn route( path: &str, params: &HashMap, @@ -227,9 +441,7 @@ fn route( peer: &str, ) -> String { match path { - // ---- standard Hacash miner API: an UNMODIFIED poworker can mine here. - // The only difference from a real node is that target_hash carries the - // POOL's share target, so the worker submits shares, not just blocks. + // ---- standard Hacash miner API: an UNMODIFIED poworker mines here ---- "/query/miner/pending" => { let p = pool.lock().unwrap(); let cb = coinbase_with_extranonce(&p.tpl, &[0u8; 32]); @@ -251,14 +463,13 @@ fn route( .and_then(|v| v.parse().ok()) .unwrap_or(45) .clamp(1, 300); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait); + let deadline = Instant::now() + Duration::from_secs(wait); loop { - // brief lock only — never hold it while sleeping - let h = pool.lock().unwrap().tpl.height; - if h > want || std::time::Instant::now() >= deadline { + let h = pool.lock().unwrap().tpl.height; // brief lock only + if h > want || Instant::now() >= deadline { return json!({"ret":0,"height":h}).to_string(); } - std::thread::sleep(std::time::Duration::from_millis(400)); + std::thread::sleep(Duration::from_millis(400)); } } "/submit/miner/success" => { @@ -270,9 +481,7 @@ fn route( let Some(cn) = parse32(params.get("coinbase_nonce")) else { return json!({"ret":1,"err":"bad coinbase_nonce"}).to_string(); }; - // Credit the announced payout address when the worker sends one - // (&worker=); otherwise fall back to the source IP, which the - // operator would have to map by hand at payout time. + // Credit the announced payout address when the worker sends one. let worker = params .get("worker") .filter(|w| is_payout_address(w)) @@ -283,10 +492,10 @@ fn route( let ok = r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); let kind = r.get("kind").and_then(|v| v.as_str()).unwrap_or(""); if ok { - println!("[{peer}] {kind} at height {height}"); + println!("[{worker}] {kind} at height {height}"); json!({"ret":0,"kind":kind}).to_string() } else { - json!({"ret":1,"kind":kind,"err":r.get("err")}).to_string() + json!({"ret":1,"kind":kind}).to_string() } } @@ -321,8 +530,11 @@ fn route( let p = pool.lock().unwrap(); json!({ "height": p.tpl.height, + "difficulty": p.tpl.difficulty, "accepted_shares": p.accepted, - "blocks": p.blocks, + "blocks_confirmed": p.blocks, + "blocks_pending": p.submitted.len(), + "blocks_orphaned": p.orphaned, "share_window": p.pplns.total(), "workers": p.pplns.counts(), }) From 88012464264d110af60f22b6c6bc21356f7aa2cb Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 15:29:50 +0200 Subject: [PATCH 16/74] =?UTF-8?q?feat(pool):=20asert-check=20=E2=80=94=20v?= =?UTF-8?q?alidate=20off-node=20difficulty=20against=20real=20chain=20hist?= =?UTF-8?q?ory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A go/no-go tool before pointing a pool at any chain: for the last N blocks it recomputes each block's difficulty from that block's own timestamp, its parent's difficulty and the anchor timestamp, then compares against what the chain actually stored. MAINNET RESULT (real synced fullnode at height 766,891): 20 matched, 0 mismatched — h=766872..766891 reproduced EXACTLY. PASS: the off-node ASERT reproduces real chain difficulty exactly. Live mainnet pool run on the back of that: pool-server against the real node served work at height 766892 with difficulty 3585604039 (computed by our own ASERT) and its own share target; an unmodified poworker mined at ~290 KH/s and had 26 shares accepted. No block was found, as expected — a mainnet block needs ~2^42.8 hashes, which is hashrate/luck, not correctness. Co-Authored-By: Claude Opus 4.8 --- pool-spike/Cargo.toml | 4 ++ pool-spike/src/asert_check.rs | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 pool-spike/src/asert_check.rs diff --git a/pool-spike/Cargo.toml b/pool-spike/Cargo.toml index 1379673..09446c1 100644 --- a/pool-spike/Cargo.toml +++ b/pool-spike/Cargo.toml @@ -36,3 +36,7 @@ serde_json = "1.0" hex = "0.4.3" getrandom = "0.3.2" num-bigint = "0.4.6" + +[[bin]] +name = "asert-check" +path = "src/asert_check.rs" diff --git a/pool-spike/src/asert_check.rs b/pool-spike/src/asert_check.rs new file mode 100644 index 0000000..a57dc45 --- /dev/null +++ b/pool-spike/src/asert_check.rs @@ -0,0 +1,79 @@ +//! Validate the off-node ASERT reimplementation against REAL chain history. +//! +//! For each of the last N blocks it recomputes the difficulty from that block's +//! own timestamp, its parent's difficulty and the anchor block's timestamp, then +//! compares against what the chain actually stored. A mismatch anywhere means +//! the pool would build blocks the node rejects, so this is the go/no-go check +//! before pointing a pool at mainnet. +//! +//! Usage: asert-check [node_base] [count] [chain] + +use pool_spike::difficulty::{ChainParams, next_difficulty}; +use pool_spike::{find_u64, get_json, http_client}; + +fn main() { + let a: Vec = std::env::args().collect(); + let node = a + .get(1) + .cloned() + .unwrap_or_else(|| "http://127.0.0.1:8080".to_string()); + let node = node.trim_end_matches('/').to_string(); + let count: u64 = a.get(2).and_then(|s| s.parse().ok()).unwrap_or(10); + let chain = a.get(3).cloned().unwrap_or_else(|| "mainnet".to_string()); + let params = ChainParams::from_name(&chain); + + let client = http_client(); + let tip = find_u64( + &get_json(&client, &format!("{node}/query/latest")), + "height", + ) + .expect("no chain tip"); + + println!("== asert-check =="); + println!("node = {node}"); + println!("chain = {chain} (ASERT anchor at height {})", params.asert_height); + println!("tip = {tip}"); + + let anchor_time = find_u64( + &get_json( + &client, + &format!("{node}/query/block/intro?height={}", params.asert_height), + ), + "timestamp", + ) + .expect("anchor block timestamp (is the node synced past the anchor?)"); + println!("anchor ts = {anchor_time}\n"); + + let first = tip.saturating_sub(count - 1).max(params.asert_height + 1); + let mut ok = 0u64; + let mut bad = 0u64; + for h in first..=tip { + let b = get_json(&client, &format!("{node}/query/block/intro?height={h}")); + let (Some(ts), Some(stored)) = (find_u64(&b, "timestamp"), find_u64(&b, "difficulty")) + else { + println!("h={h} (missing block data, skipped)"); + continue; + }; + let pb = get_json(&client, &format!("{node}/query/block/intro?height={}", h - 1)); + let Some(prev_diff) = find_u64(&pb, "difficulty") else { + println!("h={h} (missing parent, skipped)"); + continue; + }; + let (ours, _target) = + next_difficulty(¶ms, h, ts, prev_diff as u32, anchor_time); + if ours as u64 == stored { + ok += 1; + println!("h={h} OK difficulty={stored}"); + } else { + bad += 1; + println!("h={h} MISMATCH ours={ours} chain={stored}"); + } + } + + println!("\n{ok} matched, {bad} mismatched"); + if bad == 0 && ok > 0 { + println!("PASS: the off-node ASERT reproduces real chain difficulty exactly."); + } else { + println!("FAIL: do NOT point a pool at this chain until this matches."); + } +} From d95660141d31fe789edf1bda6e0428e4bb4ecc74 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 15:32:16 +0200 Subject: [PATCH 17/74] =?UTF-8?q?feat(panel):=20pool=20payouts=20need=20no?= =?UTF-8?q?=20extra=20step=20=E2=80=94=20the=20wallet=20address=20is=20ann?= =?UTF-8?q?ounced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Pool mode the panel now writes the address the user already typed as `pool_worker` in poworker.config.ini, so the pool credits and pays that miner automatically. Solo mode leaves it empty, keeping the worker's requests byte-identical to a plain fullnode's — no behaviour change for solo users. This closes the last manual step in the pool path: pick a pool from the directory, type your address (already required), press Start. Test: pool mode writes the address, solo stays empty. 58 panel tests pass. Co-Authored-By: Claude Opus 4.8 --- miner-panel/src/config.rs | 34 ++++++++++++++++++++++++++++++++++ miner-panel/src/main.rs | 7 +++++++ 2 files changed, 41 insertions(+) diff --git a/miner-panel/src/config.rs b/miner-panel/src/config.rs index a896161..f3fe136 100644 --- a/miner-panel/src/config.rs +++ b/miner-panel/src/config.rs @@ -41,6 +41,10 @@ pub struct PanelSettings { pub nonce_max: u32, /// poworker: seconds to wait for a new-block notice (default 45). pub notice_wait: u64, + /// Payout address announced to a pool (`pool_worker`). Set only in Pool + /// mode; empty for solo so the worker's requests stay identical to a plain + /// fullnode's. + pub pool_worker: String, } const BENCHMARK_BACKUP_PRESENT: &str = "HACASH_MINER_PANEL_AUTOTUNE_BACKUP_V1:PRESENT\n"; @@ -447,6 +451,7 @@ connect = {connect} supervene = {sv} nonce_max = {nonce_max} notice_wait = {notice_wait} +pool_worker = {pool_worker} {efficiency} [gpu] @@ -468,6 +473,7 @@ debug = 0 sv = s.cpu.supervene, nonce_max = s.nonce_max, notice_wait = s.notice_wait, + pool_worker = s.pool_worker, // Thermal cap must be below full load; half of WG (min 1) actually reduces heat. efficiency = efficiency_section( s, @@ -586,6 +592,7 @@ mod write_tuning_tests { unit_size: us, nonce_max: u32::MAX, notice_wait: 45, + pool_worker: String::new(), } } @@ -722,6 +729,32 @@ mod write_tuning_tests { assert!(raw.contains("use_opencl = true"), "{raw}"); } + #[test] + fn pool_mode_announces_the_payout_address_and_solo_stays_empty() { + let gpu = gpu_presets() + .into_iter() + .find(|g| g.slug == "rx9070xt") + .unwrap(); + let mut s = panel_with_wg(&gpu, 64, 64); + let path = + std::env::temp_dir().join(format!("hacash-panel-poolw-{}.ini", std::process::id())); + + // Pool mode: the address the user already typed is announced to the pool + // so it can credit and pay this miner automatically. + s.pool_worker = "1NVYv5jmr9JRF3usPZJQmJFJhbQhrPESTP".to_string(); + write_poworker_config(&path, &s).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + assert!(raw.contains("pool_worker = 1NVYv5jmr9JRF3usPZJQmJFJhbQhrPESTP")); + + // Solo: left empty, so the worker's URLs stay identical to a plain node. + s.pool_worker = String::new(); + write_poworker_config(&path, &s).unwrap(); + let raw = std::fs::read_to_string(&path).unwrap(); + let _ = std::fs::remove_file(path); + assert!(raw.contains("pool_worker =")); + assert!(!raw.contains("pool_worker = 1")); + } + #[test] fn hacd_config_is_strictly_cpu_only() { let gpu = gpu_presets() @@ -830,6 +863,7 @@ impl PanelSettings { unit_size: self.unit_size, nonce_max: self.nonce_max, notice_wait: self.notice_wait, + pool_worker: self.pool_worker.clone(), } } } diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs index 886601c..267288e 100644 --- a/miner-panel/src/main.rs +++ b/miner-panel/src/main.rs @@ -535,6 +535,13 @@ impl MinerApp { unit_size: self.unit_size, nonce_max: self.nonce_max, notice_wait: self.notice_wait, + // In Pool mode the address the user already typed doubles as the + // pool payout address, so payouts need no extra step. Solo leaves it + // empty, keeping worker requests identical to a plain fullnode's. + pool_worker: match self.connect_mode { + ConnectMode::Pool => self.wallet.trim().to_string(), + ConnectMode::Solo => String::new(), + }, } } From 0f92e908e793383186f3b2f090e4d6abd489f1a7 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 15:45:01 +0200 Subject: [PATCH 18/74] feat(panel): simple view for newcomers, three steps and one button The settings page now opens in a Simple view: three numbered steps (what to mine, where to connect, where your coins go) and one Start button. Everything else, GPU tuning, power limits, worker knobs, hosting a shared node, fleet, sits behind an Advanced switch at the top right. The choice is remembered in panel-ui.json, and a first-time user starts simple. New theme components rather than ad-hoc widgets, so it looks like the rest of the app: step_card (gold numbered badge, title, quiet hint, then its controls), segmented (the Simple/Advanced control), btn_primary_large and note. The shared controls were extracted into connect_mode_row, connect_target_block, wallet_field and action_row, and BOTH views call them, so the simple and advanced pages can never drift apart. Step 1 also shows the detected graphics card with a Detect button when none is found, so a beginner can see the GPU will actually be used. 58 panel tests pass. Co-Authored-By: Claude Opus 4.8 --- miner-panel/src/main.rs | 34 +++++ miner-panel/src/theme.rs | 132 ++++++++++++++++ miner-panel/src/ui_settings_tab.rs | 232 +++++++++++++++++++++++++---- 3 files changed, 371 insertions(+), 27 deletions(-) diff --git a/miner-panel/src/main.rs b/miner-panel/src/main.rs index 267288e..d58a20c 100644 --- a/miner-panel/src/main.rs +++ b/miner-panel/src/main.rs @@ -37,6 +37,27 @@ use config::{ use connect::{ ConnectMode, PoolInfo, SOLO_DEFAULT, connect_port, load_pool_directory, normalize_connect, }; + +/// Small UI preference file kept next to the panel, so the view a user picked +/// is still there next time they open it. +fn ui_prefs_path(work_dir: &std::path::Path) -> std::path::PathBuf { + work_dir.join("panel-ui.json") +} + +fn load_simple_mode(work_dir: &std::path::Path) -> bool { + std::fs::read_to_string(ui_prefs_path(work_dir)) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|v| v.get("simple_mode").and_then(|b| b.as_bool())) + .unwrap_or(true) // a first-time user starts in the simple view +} + +fn save_simple_mode(work_dir: &std::path::Path, simple: bool) { + let _ = std::fs::write( + ui_prefs_path(work_dir), + format!("{{\n \"simple_mode\": {simple}\n}}\n"), + ); +} use currency::{Currency, load_currency, save_currency}; use eframe::egui; use hacash_config::{ @@ -116,6 +137,8 @@ struct MinerApp { /// Result text of the last "Test connection" / "Test upstream" reachability probe. connect_test_status: String, upstream_test_status: String, + /// Newcomer view: three steps and one button. Remembered across restarts. + simple_mode: bool, max_temp_c: u32, pause_unprofitable: bool, work_groups: u32, @@ -292,6 +315,7 @@ impl MinerApp { } let connect_mode = ConnectMode::for_connect(&connect); let pool_directory = load_pool_directory(&work_dir); + let simple_mode = load_simple_mode(&work_dir); let mut app = Self { work_dir, config_path, @@ -328,6 +352,7 @@ impl MinerApp { notice_wait: 45, connect_test_status: String::new(), upstream_test_status: String::new(), + simple_mode, max_temp_c, pause_unprofitable, work_groups, @@ -744,6 +769,15 @@ impl MinerApp { } } + /// Switch between the newcomer view and the full settings, and remember it. + fn set_simple_mode(&mut self, simple: bool) { + if self.simple_mode == simple { + return; + } + self.simple_mode = simple; + save_simple_mode(&self.work_dir, simple); + } + fn set_connect_mode(&mut self, mode: ConnectMode) { self.connect_mode = mode; if mode == ConnectMode::Solo { diff --git a/miner-panel/src/theme.rs b/miner-panel/src/theme.rs index 784ce81..46d8afa 100644 --- a/miner-panel/src/theme.rs +++ b/miner-panel/src/theme.rs @@ -159,6 +159,138 @@ pub fn field_label(ui: &mut Ui, text: &str) { ui.label(egui::RichText::new(text).color(TEXT).size(13.5)); } +/// A numbered step: gold badge, title, a quiet one line explanation, then the +/// controls for that step. Used to turn the settings page into a short, +/// readable sequence for people who have never mined before. +pub fn step_card(ui: &mut Ui, num: u8, title: &str, hint: &str, content: impl FnOnce(&mut Ui)) { + Frame::none() + .fill(BG_CARD) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .rounding(Rounding::same(14.0)) + .inner_margin(Margin::symmetric(20.0, 18.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::splat(32.0), Sense::hover()); + let c = rect.center(); + ui.painter().circle_filled( + c, + 15.0, + Color32::from_rgba_premultiplied(255, 122, 0, 32), + ); + ui.painter() + .circle_stroke(c, 15.0, Stroke::new(1.4, ACCENT_DIM)); + ui.painter().text( + c, + egui::Align2::CENTER_CENTER, + num.to_string(), + FontId::new(15.0, FontFamily::Proportional), + ACCENT, + ); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(title).strong().color(TEXT).size(16.0)); + if !hint.is_empty() { + ui.add_space(3.0); + ui.label(egui::RichText::new(hint).color(TEXT_MUTED).size(12.5)); + } + }); + }); + ui.add_space(14.0); + content(ui); + }); + ui.add_space(12.0); +} + +/// Compact segmented control, e.g. "Simple | Advanced". Returns the index the +/// user picked, if any. +pub fn segmented(ui: &mut Ui, options: &[&str], selected: usize) -> Option { + let mut picked = None; + Frame::none() + .fill(Color32::from_rgba_premultiplied(8, 8, 8, 190)) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .rounding(Rounding::same(11.0)) + .inner_margin(Margin::same(4.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for (i, label) in options.iter().enumerate() { + let on = i == selected; + let (rect, resp) = + ui.allocate_exact_size(Vec2::new(108.0, 30.0), Sense::click()); + if ui.is_rect_visible(rect) { + let hover = resp.hovered(); + let fill = if on { + Color32::from_rgba_premultiplied(255, 122, 0, 52) + } else if hover { + Color32::from_rgba_premultiplied(104, 48, 4, 80) + } else { + Color32::TRANSPARENT + }; + let text = if on { + TEXT + } else if hover { + ACCENT + } else { + TEXT_MUTED + }; + ui.painter().rect_filled(rect, Rounding::same(8.0), fill); + if on { + ui.painter().rect_stroke( + rect, + Rounding::same(8.0), + Stroke::new(1.2, ACCENT), + ); + } + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + *label, + FontId::new(13.5, FontFamily::Proportional), + text, + ); + } + if resp.clicked() { + picked = Some(i); + } + } + }); + }); + picked +} + +/// The one obvious action on a page. +pub fn btn_primary_large(ui: &mut Ui, label: &str) -> egui::Response { + ui.add_sized( + [210.0, 46.0], + egui::Button::new( + egui::RichText::new(label) + .color(BG_DEEP) + .strong() + .size(15.5), + ) + .fill(GREEN) + .stroke(Stroke::new(1.0, GREEN_DIM)) + .rounding(Rounding::same(12.0)), + ) +} + +/// A quiet framed note, for the one thing a beginner must understand on a page. +pub fn note(ui: &mut Ui, accent: Color32, text: &str) { + Frame::none() + .fill(Color32::from_rgba_premultiplied(20, 12, 4, 150)) + .stroke(Stroke::new(1.0, BORDER_SOFT)) + .rounding(Rounding::same(10.0)) + .inner_margin(Margin::symmetric(14.0, 10.0)) + .show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + let (bar, _) = ui.allocate_exact_size(Vec2::new(3.0, 16.0), Sense::hover()); + ui.painter().rect_filled(bar, Rounding::same(2.0), accent); + ui.add_space(8.0); + ui.label(egui::RichText::new(text).color(TEXT_MUTED).size(12.5)); + }); + }); +} + #[derive(Clone, Copy, PartialEq, Eq)] pub enum TabIcon { Settings, diff --git a/miner-panel/src/ui_settings_tab.rs b/miner-panel/src/ui_settings_tab.rs index 6bcb4af..677328e 100644 --- a/miner-panel/src/ui_settings_tab.rs +++ b/miner-panel/src/ui_settings_tab.rs @@ -88,7 +88,42 @@ impl MinerApp { ui.add_space(12.0); } + // Simple by default: a newcomer sees three steps and one button. + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(if self.simple_mode { + "Set up mining" + } else { + "All settings" + }) + .strong() + .color(theme::colors::TEXT) + .size(17.0), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(i) = theme::segmented( + ui, + &["Simple", "Advanced"], + if self.simple_mode { 0 } else { 1 }, + ) { + self.set_simple_mode(i == 0); + } + }); + }); + ui.add_space(14.0); + ui.add_enabled_ui(!settings_locked, |ui| { + if self.simple_mode { + self.ui_settings_simple(ui); + } else { + self.ui_settings_full(ui); + } + }); + } + + /// Every setting, for people who want the knobs. + fn ui_settings_full(&mut self, ui: &mut egui::Ui) { + let t = self.t(); ui.label( egui::RichText::new(t.settings_intro) .color(theme::colors::TEXT_MUTED) @@ -261,25 +296,7 @@ impl MinerApp { .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; - let local_label = if self.mining_kind == MiningKind::Hacd { - "Local full node" - } else { - t.connect_solo - }; - let remote_label = if self.mining_kind == MiningKind::Hacd { - "LAN / remote full node" - } else { - t.connect_pool - }; - if ui.selectable_label(solo, local_label).clicked() { - self.set_connect_mode(ConnectMode::Solo); - } - if ui.selectable_label(!solo, remote_label).clicked() { - self.set_connect_mode(ConnectMode::Pool); - } - }); + self.connect_mode_row(ui); ui.end_row(); theme::field_label( @@ -290,7 +307,20 @@ impl MinerApp { t.connect_pool }, ); - ui.vertical(|ui| { + self.connect_target_block(ui); + ui.end_row(); + }); + }); + + self.ui_settings_advanced_tail(ui); + } + + /// Where the miner connects: the pool picker (HAC pool mode), the address + /// box, a reachability test and the selected pool's guidance. Shared by the + /// simple and advanced views so they can never drift apart. + fn connect_target_block(&mut self, ui: &mut egui::Ui) { + let t = self.t(); + ui.vertical(|ui| { let hac_pool = self.connect_mode == ConnectMode::Pool && self.mining_kind == MiningKind::Hac; if hac_pool { @@ -392,10 +422,153 @@ impl MinerApp { ); } } - }); - ui.end_row(); - }); }); + } + + /// Solo or pool, worded for the mining type in play. + fn connect_mode_row(&mut self, ui: &mut egui::Ui) { + let t = self.t(); + ui.horizontal(|ui| { + let solo = self.connect_mode == ConnectMode::Solo; + let local_label = if self.mining_kind == MiningKind::Hacd { + "Local full node" + } else { + t.connect_solo + }; + let remote_label = if self.mining_kind == MiningKind::Hacd { + "LAN / remote full node" + } else { + t.connect_pool + }; + if ui.selectable_label(solo, local_label).clicked() { + self.set_connect_mode(ConnectMode::Solo); + } + ui.add_space(8.0); + if ui.selectable_label(!solo, remote_label).clicked() { + self.set_connect_mode(ConnectMode::Pool); + } + }); + } + + /// The reward address box plus the hint for the current mining type. + fn wallet_field(&mut self, ui: &mut egui::Ui) { + let t = self.t(); + ui.add( + egui::TextEdit::singleline(&mut self.wallet) + .desired_width(420.0) + .hint_text("1LCY6uQS3iNGy2mKSmhFVU2dHgBQLf74Fx") + .margin(egui::Margin::symmetric(10.0, 8.0)), + ); + ui.add_space(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), + ); + } + + /// Three steps and one button. Everything else lives under Advanced. + fn ui_settings_simple(&mut self, ui: &mut egui::Ui) { + let t = self.t(); + + theme::step_card( + ui, + 1, + "What do you want to mine?", + "HAC uses your graphics card. HACD (diamonds) runs on the CPU through a full node.", + |ui| { + ui.horizontal(|ui| { + if ui + .selectable_label(self.mining_kind == MiningKind::Hac, t.mining_hac) + .clicked() + { + self.set_mining_kind(MiningKind::Hac); + } + ui.add_space(8.0); + if ui + .selectable_label(self.mining_kind == MiningKind::Hacd, t.mining_hacd) + .clicked() + { + self.set_mining_kind(MiningKind::Hacd); + } + }); + if self.mining_kind == MiningKind::Hac { + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("Graphics card:") + .size(12.5) + .color(theme::colors::TEXT_MUTED), + ); + let usable = self.opencl_status.has_usable_device(); + ui.label( + egui::RichText::new(self.opencl_status.device_summary()) + .size(12.5) + .strong() + .color(if usable { + theme::colors::GREEN + } else { + theme::colors::GOLD + }), + ); + if !usable && ui.small_button("Detect").clicked() { + self.request_opencl_probe(OpenClAction::AutoDetect); + } + }); + } + }, + ); + + theme::step_card( + ui, + 2, + "Where do you connect?", + "A pool pays you small amounts often. Solo pays only when you find a whole block yourself.", + |ui| { + self.connect_mode_row(ui); + ui.add_space(12.0); + self.connect_target_block(ui); + }, + ); + + theme::step_card( + ui, + 3, + "Where should your coins go?", + "Paste your HAC address. In pool mode this is also the address the pool pays.", + |ui| { + self.wallet_field(ui); + }, + ); + + if self.connect_mode == ConnectMode::Pool { + theme::note( + ui, + theme::colors::ACCENT, + "Your address is sent to the pool automatically so it can credit your work and pay you. There is nothing else to set up.", + ); + ui.add_space(14.0); + } + + self.action_row(ui); + ui.add_space(10.0); + ui.label( + egui::RichText::new( + "Want GPU tuning, power limits or to host a shared node? Switch to Advanced at the top.", + ) + .size(11.5) + .color(theme::colors::TEXT_MUTED), + ); + } + + /// The sections only an experienced user needs: worker tuning knobs, hosting + /// a shared node, the reward address card, fleet settings and the actions. + fn ui_settings_advanced_tail(&mut self, ui: &mut egui::Ui) { + let t = self.t(); // Everything a different pool might need, editable from the GUI so the // user never has to open poworker.config.ini. Defaults suit every pool; @@ -711,17 +884,22 @@ NAT/CGNAT often blocks it). This panel cannot verify external reachability - tes self.fleet.show_settings(ui); ui.add_space(18.0); + self.action_row(ui); + ui.add_space(8.0); + } + + /// Save and Start: the two things every view ends with. + fn action_row(&mut self, ui: &mut egui::Ui) { + let t = self.t(); 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() { + ui.add_space(10.0); + if theme::btn_primary_large(ui, t.btn_start_mining).clicked() { self.start_mining(); self.tab = 1; } }); - ui.add_space(8.0); - }); // end add_enabled_ui(!settings_locked) } } From 8adf7d7bbd788bcac8a81e85c6af2583bbe22e14 Mon Sep 17 00:00:00 2001 From: Moskyera Date: Thu, 23 Jul 2026 16:17:31 +0200 Subject: [PATCH 19/74] =?UTF-8?q?fix(pool,panel,cuda):=20address=20the=20a?= =?UTF-8?q?dversarial=20review=20=E2=80=94=20money,=20concurrency,=20secur?= =?UTF-8?q?ity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pool server / lib (pool-spike): - balance_units: FLOOR sub-0.1-HAC balances to whole 0.1-HAC units instead of returning 0. Node amounts are normalized ("49:246" = 4.9 HAC), so the old code discarded the whole wallet after any fee-paying settle and froze all payouts. (high) - Block submit no longer holds the pool Mutex across blocking node HTTP + sleeps. Split into credit_share (locked, no I/O, assembles the block bytes) and handle_submission (submits OFF the lock). A background thread now keeps the template current with the chain tip and confirms our blocks off-lock, so one submission can no longer stall every miner, and work advances when the NETWORK finds a block, not only when we do. (high) - fetch_template returns Option instead of panicking on transient node errors; refresh keeps the old template on failure, so a node blip can no longer poison the pool Mutex and crash the whole pool. (high) - Wallet key file is created owner-only (0600 on Unix) with create_new, and a non-NotFound read error aborts instead of overwriting a possibly-present key. (high/medium) - handle(): socket read/write timeouts + a 16 KiB read cap, closing an unauthenticated slow-loris / unbounded-read DoS. (high) - Template is replaced only when the height advances, so refreshing no longer invalidates in-flight shares by bumping the same-height timestamp. (regression fix found while re-testing) - Settlement is now idempotent (skips while a prior payout has not drained the confirmed balance), the timer loop catches panics, and state is written at most every 16 shares instead of on every share under the lock. (low) Panel: - Simple view now shows the HACD bid-password step, so choosing diamonds no longer dead-ends at Start with a hidden required field. (medium) - Auto Tune writes an empty pool_worker into the benchmark config (it only measures local hashrate; no payout address should be announced). (low) CUDA build.rs: - Windows toolkit picked by parsed (major, minor) version, not a byte-wise string sort that ranked v9.2 above v12.4. (low) - compute_86 / compute_89 gencode flags gated by nvcc version (>= 11.1 / >= 11.8) with a compute_75 PTX fallback, so an older toolkit builds instead of failing on an unsupported-arch error. (low) pool_core gained Pplns snapshot/restore + hash_of/beats; 13 crate tests pass. Re-tested end-to-end on a fresh testnet: shares credited, duplicates rejected, blocks submitted off-lock and confirmed by the background thread, chain advanced. Co-Authored-By: Claude Opus 4.8 --- miner-panel/src/config.rs | 3 + miner-panel/src/ui_settings_tab.rs | 19 ++ pool-spike/src/lib.rs | 106 ++++++++---- pool-spike/src/server.rs | 267 ++++++++++++++++++++--------- x16rs-cuda/build.rs | 68 ++++++-- 5 files changed, 331 insertions(+), 132 deletions(-) diff --git a/miner-panel/src/config.rs b/miner-panel/src/config.rs index f3fe136..bab6be6 100644 --- a/miner-panel/src/config.rs +++ b/miner-panel/src/config.rs @@ -535,6 +535,9 @@ pub fn write_poworker_benchmark_config( let mut bench = s.clone_settings(); bench.benchmark_seconds = seconds; bench.benchmark_fine_sweep = seconds >= 60; + // Auto Tune only measures local hashrate; it must not announce a payout + // address to any pool (the wallet may be unvalidated mid-edit). + bench.pool_worker = String::new(); write_poworker_config(path, &bench)?; // Allocate for the full safe range of this preset. The worker will record diff --git a/miner-panel/src/ui_settings_tab.rs b/miner-panel/src/ui_settings_tab.rs index 677328e..67b27b6 100644 --- a/miner-panel/src/ui_settings_tab.rs +++ b/miner-panel/src/ui_settings_tab.rs @@ -545,6 +545,25 @@ impl MinerApp { }, ); + // HACD diamond mining also needs the bid account password; without it + // Start would dead-end. Ask for it here instead of hiding it in Advanced. + if self.mining_kind == MiningKind::Hacd { + theme::step_card( + ui, + 4, + "Diamond bid password", + "Diamond mining bids from your full node account. Enter its password. The bid amounts use safe defaults, which you can change under Advanced.", + |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.bid_password) + .password(true) + .desired_width(420.0) + .margin(egui::Margin::symmetric(10.0, 8.0)), + ); + }, + ); + } + if self.connect_mode == ConnectMode::Pool { theme::note( ui, diff --git a/pool-spike/src/lib.rs b/pool-spike/src/lib.rs index 53ce578..998ce37 100644 --- a/pool-spike/src/lib.rs +++ b/pool-spike/src/lib.rs @@ -84,31 +84,57 @@ pub fn is_payout_address(s: &str) -> bool { /// only ever lives in that file — it is never printed or logged; only the /// address is shown. pub fn load_or_create_wallet(path: &str) -> Account { - if let Ok(txt) = std::fs::read_to_string(path) { - let key_hex = txt.trim().to_string(); - assert_eq!( - key_hex.len(), - 64, - "wallet file {path} must hold a 64-hex private key" - ); - let acc = Account::create_by(&key_hex).expect("invalid key in wallet file"); - println!("pool wallet {} (from {path})", acc.readable()); - return acc; - } - // No wallet yet: generate one and persist it. - let acc = loop { - let mut key = [0u8; 32]; - getrandom::fill(&mut key).expect("system RNG"); - if let Ok(a) = Account::create_by_secret_key_value(key) { - break a; + match std::fs::read_to_string(path) { + Ok(txt) => { + let key_hex = txt.trim(); + if key_hex.len() != 64 { + panic!("wallet file {path} must hold a 64-hex private key"); + } + let acc = Account::create_by(key_hex).expect("invalid key in wallet file"); + println!("pool wallet {} (from {path})", acc.readable()); + acc } - }; - std::fs::write(path, format!("{}\n", hex::encode(acc.secret_key().serialize()))) - .expect("write wallet file"); - println!("CREATED A NEW POOL WALLET -> {path}"); - println!(" address: {}", acc.readable()); - println!(" BACK UP THAT FILE. Whoever holds it controls the pool's funds."); - acc + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // No wallet yet: generate one and persist it owner-only. + let acc = loop { + let mut key = [0u8; 32]; + getrandom::fill(&mut key).expect("system RNG"); + if let Ok(a) = Account::create_by_secret_key_value(key) { + break a; + } + }; + match write_key_file(path, &hex::encode(acc.secret_key().serialize())) { + Ok(()) => {} + // Lost a create race with another instance: use the winner's key. + Err(e2) if e2.kind() == std::io::ErrorKind::AlreadyExists => { + return load_or_create_wallet(path); + } + Err(e2) => panic!("cannot write wallet file {path}: {e2}"), + } + println!("CREATED A NEW POOL WALLET -> {path}"); + println!(" address: {}", acc.readable()); + println!(" BACK UP THAT FILE. Whoever holds it controls the pool's funds."); + acc + } + // Never generate-and-overwrite on a non-NotFound error: a locked or + // transiently-unreadable key file must not be silently replaced. + Err(e) => panic!("cannot read wallet file {path}: {e} (refusing to overwrite it)"), + } +} + +/// Write the private key to a NEW file, owner-only (0600) on Unix. `create_new` +/// means it never clobbers an existing key. +fn write_key_file(path: &str, key_hex: &str) -> std::io::Result<()> { + use std::io::Write; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(path)?; + writeln!(f, "{key_hex}") } /// Everything the pool needs to build and verify blocks for the current tip. @@ -130,24 +156,29 @@ pub struct Template { /// Read the chain tip and build a template for the next block, computing the /// next difficulty off-node with the same rule the node will validate against. +/// +/// Returns `None` on any transient node/HTTP problem instead of panicking, so a +/// caller holding a lock (the pool server) can skip the cycle and retry rather +/// than poisoning its mutex and taking the whole pool down. pub fn fetch_template( client: &reqwest::blocking::Client, base: &str, coinbase_addr: &str, params: &ChainParams, -) -> Template { +) -> Option