Skip to content

CUDA verified end to end, pool safety fixes, and a node config that does not stall - #4

Merged
Moskyera merged 74 commits into
mainfrom
feat/pool-directory-cuda-ptx-panel
Jul 27, 2026
Merged

CUDA verified end to end, pool safety fixes, and a node config that does not stall#4
Moskyera merged 74 commits into
mainfrom
feat/pool-directory-cuda-ptx-panel

Conversation

@Moskyera

Copy link
Copy Markdown
Owner

107 commits. main currently ships fast_sync = true, which builds a chain that
cannot be extended, so anyone cloning the default branch today gets the defect
that started this work.

The ones that matter

fast_sync = true corrupts the chain. A node synced with it stops dead at a
block whose state it never wrote (diamond status HTAKES not found) and nothing
retries, so it sits there answering its RPC and looking healthy. A clean sync with
it off reached the tip in seven minutes. Removed from every config we ship.

The pool accepted shares that cost nothing. The guard bounded a ratio, not the
work behind it, so a chain at 22 leading zero bits with share_bits 24 saturated
to the all-ones ceiling: credit measured HTTP round trips. Both bounds now apply.

The panel mined on unsynced nodes. "The RPC answered" was treated as ready. It
now waits for the tip and shows progress, because a stalled sync and a ready panel
were indistinguishable.

CUDA verified on real hardware. On a Colab T4 against mainnet at a block cost
of 2^42: 99.76% of the PPLNS window on 99.697% of the hashrate, against a CPU
control in the same window.

Also: list_opencl no longer panics when no driver is present, which is exactly
when people are told to run it; the deploy image builds on the 2 to 8 GB machines
it targets; the release workflow itself is repaired; and the HBIT pool ships as a
deployable package.

Everything here was found by running the product, not by reading it.

Moskyera and others added 30 commits July 22, 2026 01:33
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 <noreply@anthropic.com>
…ility

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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
…g.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…l 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 <noreply@anthropic.com>
…d by node

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 <noreply@anthropic.com>
…ctional amounts

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 <noreply@anthropic.com>
…ayout split

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 <noreply@anthropic.com>
…oportional payout

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 <noreply@anthropic.com>
…est key

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 <noreply@anthropic.com>
…es on the pool

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 <noreply@anthropic.com>
…ayouts

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 = <address>` config key, appended as
  `&worker=<address>` 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 <noreply@anthropic.com>
…t templates

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 <noreply@anthropic.com>
…, auto-settle

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 <wallet>.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 <noreply@anthropic.com>
…hain history

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 <noreply@anthropic.com>
…announced

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ency, security

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 <noreply@anthropic.com>
Second adversarial review (concurrency, money, regressions, consensus, input,
panel) found 8 confirmed issues, all addressed:

Settlement (the money-critical ones):
- Idempotency is now keyed on the payout TRANSACTION HASH, polled via
  /query/transaction, not on an absolute wallet-balance snapshot. This fixes two
  real bugs at once: (1) a lost or timed-out submit ACK used to leave the marker
  unset and double-pay every miner next cycle; (2) coinbase income to the same
  wallet could keep the balance above the marker forever and freeze all payouts.
  The tx hash is recorded BEFORE submit, so a lost ACK still blocks a resubmit;
  it clears only once the node reports the tx confirmed or gone. Verified: our
  tx.hash() matches the node's returned hash exactly, and settlement paid once
  across many cycles with zero double pay.
- The phantom 10 percent fee is removed. split_payout subtracted a fee that was
  never paid to anyone and just got redistributed to miners the next cycle, so
  the operator earned nothing anyway. It is now an honest zero-fee community
  pool; the reserve covers the tiny tx fee.

Denial of service / robustness:
- Live connections are capped (1024), so unauthenticated long-poll or slow
  clients cannot spawn unbounded threads.
- The anonymous /work worker map is capped at 100k; past that it hands out a
  deterministic name-derived extranonce instead of growing memory.
- The wallet key is written via a temp file plus atomic rename, so a concurrent
  reader can never see an empty or half-written key.
- Panel HACD bid step placeholder fixed from "0:5" (parsed to 0) to "0.5".

Also: every block the pool mines now carries the coinbase message "HBIT pool",
tagging it as ours (verified on-chain).

Known scope limit, documented not fixed: the pool mines coinbase-only blocks, so
its own settlement tx confirms when any network miner includes it (it pays a
fee). A minority-hashrate community pool is unaffected; a dominant-hashrate pool
would want its own blocks to include mempool txs, a later enhancement.

pool-spike 13 tests, panel 58 tests, all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the full production audit (2 Critical, 5 High, 19 Medium, 20 Low) plus
the earlier community bug list, across the miner core (app), the pool
(pool-spike) and the stratum/RPC bridge (miner-pool). Builds on default,
OpenCL and CUDA features; the full test suite passes.

Critical:
* panic=unwind + catch_unwind and mutex-poison recovery, so a request or
  mining-thread panic becomes an error/skip instead of killing the process
* pool wallet key restricted owner-only on Windows (icacls), not just unix 0600

High:
* winning-block submit retries transient HTTP 5xx / non-JSON 200 instead of
  dropping the block (rpc_http surfaces retryable status; poworker retries)
* payout chunked to <=190 actions (node TX_ACTIONS_MAX=200), acceptance checked
* settlement guard persisted (idempotent across restart); payout has a
  persisted pending-tx ledger and is dry-run by default
* stratum: bounded line framing (no OOM), connection-cap semaphore, idle timeout
* pool server: per-IP connection cap and a separate long-poll budget

Medium / Low (highlights):
* share target derived relative to network difficulty so PPLNS credit tracks
  hashrate, not batch cadence
* pool split over payable addresses only; unpayable IP-fallback shares rejected
  with a clear "set pool_worker=<address>" message
* atomic state writes + corrupt-file preservation; connection-slot Drop guard;
  x16rs share hashing moved off the global lock; cached pending response
* CUDA honors the thermal cap and adds OOM/error work-group backoff; GPU nonce
  holes and same-height reorg handling fixed (template epoch)
* stratum accept-error and malformed-line resilience; correct notify fields
  (target_hash + coinbase_body); constant-time token compare; upstream URL
  normalization and hex-only nonce validation (query-injection)
* required explicit chain arg (no silent testnet default); settle-spike guarded
  to testnet only; many correctness/robustness fixes

See HBIT-AUDIT-REPORT.html for the full itemized list and per-finding fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* efficiency: default supervene_min = 1 (was 2), so an explicit supervene=1
  is not silently bumped to two
* diaworker: HACD hashrate divides by the number of parallel workers, not the
  batch count, so sequential batches no longer overcount the displayed rate
* poworker: percent-encode pool_worker in the request query string
* diaworker: use a saturating nonce range end to avoid a u64 overflow edge in
  the CPU diamond loop
* diaworker: warn clearly when a config still carries GPU keys, since HACD
  mining is CPU / full-node only and those keys are ignored

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The device-property fix declared cudaDeviceGetName, which is a CUDA driver-API
symbol absent from cudart.lib, so linking poworker with the cuda feature failed
(LNK2019 unresolved external). Read the name from cudaGetDeviceProperties (name
is at offset 0, safe with an oversized struct) and keep cudaDeviceGetAttribute
(a real runtime symbol) for the compute capability and MP count, which were the
fields the fragile byte-pad misread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fat LTO (lto=true) combined with panic=unwind crashed rustc with an access
violation while optimizing the diaworker binary on Windows/MSVC. Thin LTO
avoids the crash, builds far faster and with much less memory, and has
negligible runtime impact for this workload (the hot path is x16rs and the GPU
kernels, not the Rust glue).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-contained CUDA validation on a free Colab T4 (or any Linux NVIDIA box):
detects the GPU + CUDA toolkit, installs rustup if needed, forces a fast
non-LTO profile, and runs the x16rs-cuda tests (genesis byte-vector + CPU/GPU
parity + batch) with a heartbeat so a free session does not look dead. Writes a
PASS/FAIL summary. Lets us prove the CUDA kernels on a real NVIDIA GPU by
cloning the fork and running one script, no local card required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…y operation

A second exhaustive production audit (18 focused finders, then adversarial
verification of every finding) produced 43 confirmed findings plus 7 the
verifiers could not reach. Fixing them surfaced 6 further real defects. All
103 items are now resolved, or consciously declined with the reason recorded.

Money correctness
- Pool settlement and the manual payout tool now share one on-disk ledger and
  one exclusive OS lock on the wallet, so they can no longer both pay out the
  same PPLNS window. Hashes left in the old private ledger are adopted on
  upgrade so an in-flight payout cannot be lost.
- Coinbase income is held back until it is buried 16 confirmations deep, so an
  orphaned block is no longer paid out of the operator's own funds. The
  hold-back is persisted, so a restart cannot forget it.
- The settlement guard now fails closed: an inconclusive answer from the node
  keeps the guard instead of clearing it and paying a second time.
- Share replay across a same-height reorg no longer double-credits PPLNS.
- A missing [miner] reward key used to fall back to a hardcoded example
  address, which sent every coinbase to a wallet the operator does not
  control. Reward addresses and bid amounts are now required and the error
  names the exact section and key.

Security
- Wallet key permissions are derived from the process token SID instead of
  USERNAME, the grant is always issued in the same call as /inheritance:r, the
  resulting DACL is read back, and every failure is now fatal.
- Optional encryption at rest for the pool wallet key, using Argon2id and
  AES-256-GCM with the same parameters as the existing keystore.
- The well known bid password 123456 is refused by both the node and the panel.
- The ini parser no longer cuts a value at the first ; or # found anywhere in
  the line, which silently turned a bid password or an api token into a
  different, shorter secret.
- The release workflow pins every action to a commit SHA and publishes build
  provenance attestation, and dependabot keeps those pins current.

Stability
- Every block and diamond mining loop iteration now runs inside a shared panic
  firewall (app/src/mining_guard.rs), so a single panic can no longer end all
  submissions and payouts while the miner still looks alive.
- Result channels are bounded and never drop a result that meets its target.
- Connections are accepted with thread::Builder and backpressure instead of a
  spawn whose failure took down the whole pool, and every request now has an
  absolute 5s deadline rather than a per-syscall timeout.
- The stratum bridge retries found-block submits, expires stale jobs, and
  signals workers when the upstream node has gone stale so they stop burning
  power on frozen work.

Node
- The mining template is invalidated when its parent is no longer the chain
  tip, so a reorg can no longer serve work built on an orphaned block.
- A diamond bid can no longer exceed the configured dmer_bid_max.
- An unparseable chain_id is a hard startup failure instead of silently
  meaning mainnet.

CI now builds and tests pool-spike, the crate that handles real money and that
was never built in CI before.

Verified: 2504 tests pass across the workspace, and cargo check is clean for
the default, ocl and cuda feature sets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…two regressions found verifying them

Brings in the mainnet diamond consensus work, the block miner correctness pass,
the reorg-aware job handling and the GPU batch hardening, then fixes what
verifying that work turned up.

Mainnet diamond and consensus
- Diamond format back to the mainnet DMD_L=10 / DMD_M=16, diamond mint gated on
  height % 5 == 0 on both the validation and the block building side, and the
  miner reads the diamond name from check_diamond_hash_result.
- Mainnet config templates under mainnet-configs/ with a full [diamondminer]
  section. The diamond keys are read only when enable = true, so the shipped
  template starts cleanly with reward and bid_password left commented out.

Block miner
- Every distinct (height, coinbase, nonce) winner is submitted, the target
  comparison is equal-inclusive to match the node's own rule, the nonce advances
  by what was actually mined so a GPU error no longer leaves holes, and results
  carry an epoch so a template switch cannot submit work from the previous job.
- A GPU miner that was explicitly requested and failed to initialise is now
  fatal instead of falling back to the CPU, which would burn GPU-rig power at
  CPU speed. A CPU-only configuration is unaffected.
- A pending template whose JSON height disagrees with the height in its own
  block_intro is rejected: the JSON height picks the x16rs repeat count while
  consensus reads the header, so a mismatch mines at the wrong repeat.

Reorg handling
- The miner installs a template whenever the height differs, including a reorg
  to a lower tip, records the last intro only after a successful install, and
  re-fetches pending after every notice long-poll so a same-height tip rewrite
  is picked up.
- Stratum job ids carry a block_intro fingerprint so a same-height reorg
  produces a new job and clients get a mining.notify.

GPU
- CUDA re-verifies the GPU's best result and falls back to a bounded CPU
  recovery, and refuses a device whose batch kernel cannot launch the 256-thread
  block its reduction structurally requires, rather than launching a smaller
  block and silently corrupting the reduction.
- OpenCL enforces the 89-byte block intro and disables a card for the session
  after 20 consecutive failures at the floor.
- The diamond OpenCL kernel pads SHA3 at the true message end (61 or 93 bytes)
  instead of always assuming 93. This is the diamond-only entry point; the block
  path's sha3_256_hash is untouched, so block hashing is unchanged.

Two regressions found while verifying the above
- The stratum job id gained a reorg tag, but the submit path still parsed the
  height with strip_prefix('h').parse(), which no longer matches. It silently
  fell back to the CURRENT height, so a solution found just before the tip moved
  was billed to the wrong height and rejected, defeating the node's own
  multi-template grace window. Height parsing now lives in job::job_height,
  accepts both the tagged and the older bare form, and is covered by a
  round-trip test.
- The job id tag was built by byte-slicing block_intro, which comes from
  upstream JSON. A non-ASCII value would have landed mid-codepoint and panicked
  the poll task. The tag is built char-wise now.

Also: the miner-api test fixture built a template with height N in its JSON and
height 0 in its intro. A real node never does that, and the new consistency
check correctly rejected it, so the fixture is now self-consistent.

Verified: 2529 tests pass across the workspace; cargo check is clean for the
default and ocl feature sets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… change set

An audit of the previous commit's 46 changes verified 26 of them and found 19
that were present but flawed, plus 44 defects no claim mentioned. Five were
critical. None of them were caught by the 2529-test suite, and the reason for
that is fixed here too.

The miner was throwing away most of its own work
- The fullnode re-serializes a pending template on EVERY /query/miner/pending:
  it bumps the coinbase nonce, recomputes the merkle root, and the merkle root
  lives inside the serialized block_intro. Two polls for the same job therefore
  never return the same bytes.
- The miner decided "the template changed" by comparing those raw bytes, so it
  reinstalled on every poll and bumped the mining epoch every time. A pre-send
  gate then returned before the result was built, silently destroying a finished
  batch and any winning nonce in it. Re-fetching pending after every notice
  long-poll turned that from once per block into once per notice window: about
  7% of all mined work at the default settings, about 98% at notice_wait=3, and
  everything against an upstream that answers the notice immediately.
- A new job is now decided from (height, parent hash), which the node's
  per-request refresh leaves untouched, and the epoch only moves when that pair
  really moves. A finished batch is always sent; a job switch stops new work
  instead of voiding work already done. A winning result is discarded only when
  the live template sits at the same height with a different parent, which is the
  one case where the node has genuinely evicted it. Everything else is submitted
  and the node decides, because it keeps several recent heights.
- The 200 ms anti-spin floor on the notice cycle is back. Without it, an upstream
  answering instantly (which is exactly what a saturated pool bridge does) drew
  about 80 requests per second from each miner and made the overload worse.

Why the tests did not see it: the miner-api simulator returned one constant
block_intro, unlike the real node. It now mirrors the node exactly, and its
coinbase carries the extend block, without which set_mining_nonce was a silent
no-op and half the search space was invisible to every sim test. A new
poworker_template_churn_sim test polls a churning template and proves the miner
still submits.

A GPU could be lost for the whole session
- The session disable was a write-once latch with no path back: 20 consecutive
  failures, which a failing card reaches in 30 to 60 seconds, well inside a TDR
  storm, a driver update or a brief thermal excursion. On gfx1201 the floor is
  one step below the cap, so a single error already satisfied the "at floor"
  precondition. CUDA had no such precondition at all. An operator asleep lost the
  card until someone restarted the process.
- It is now a time-based quarantine with exponential backoff and re-probing,
  applied the same way to OpenCL and CUDA, and it is announced rather than
  silent. Work groups can also ramp back after a transient error instead of
  staying at the floor for the rest of the session.
- GPU init failure retries for about 110 seconds before it is fatal, so a driver
  that is still loading after a boot, a resume or a device reset no longer
  permanently stops an unattended rig.

The shipped config mined to a stranger
- The mainnet template shipped [miner] enable = true together with a valid
  third-party reward address. Anyone who copied it as the instructions say paid
  every block reward to a wallet they do not control, irreversibly and silently.
  This is exactly the failure the strict loader was built to prevent, arriving
  through the config instead of through the code. The address is gone, mining is
  off by default, and every key that must be the operator's own is commented out
  so the loader fails loudly and names it.
- The release copy of poworker.config.ini carried the developer's absolute paths
  and username. Removed.
- .gitignore's blanket *.ini was swallowing the very templates the docs tell
  people to copy, so mainnet-configs and hac-pool.example.ini existed only on one
  machine. They are versioned now, and the packaging script ships them.

The stratum reorg tag discriminated nothing
- The job id folded in the LAST 16 hex characters of block_intro, which for the
  89-byte layout are the low bytes of the template nonce and are identical across
  a reorg. Two different templates at one height produced the same job id, so the
  mining.notify that the tag exists for never fired and clients kept hashing an
  orphaned parent for up to a full block interval. The tag is now a fingerprint
  of the parent hash, which is the field that actually differs, and the height
  still round-trips out of the id.

Also: the HACD workers no longer keep hashing a stale (number, prev_hash) after a
reorg, a queue.finish() driver error no longer discards an already-verified
diamond, the diamond kernel's ranking now matches the CPU acceptance rule that is
the authority, and a CUDA rig gets CPU-assist threads (they were only spawned on
the OpenCL arm, so a CUDA card hitting the disable path left the rig with no work
at all).

Verified: 2537 tests pass; cargo check is clean for default, ocl and cuda (cuda
built under vcvars64, since nvcc needs cl.exe and recompiles the shared .cl
kernels).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by running the miner against a real node on real hardware, not by reading
the code: 90 seconds on an AMD gfx1201 produced 3 accepted blocks and 1729
rejected submits, and the GPU then stopped hashing altogether.

A height holds exactly one block, and mint/src/api/miner_success.rs drops the
template the moment it accepts one, so every later winner for that template is
answered "pending block height N not found". At low difficulty a fast card finds
hundreds of those per template, and each one cost a blocking HTTP round trip.
The pipeline jammed in order: the submit queue filled, the overflow path
submitted inline on the drain thread, the drain stopped, the bounded result
channel filled, and the workers blocked because a result meeting its target must
never be dropped. The rig spent its last twenty seconds draining dead height-3
solutions while already holding height-4 work.

The previous audit's rule was right and is kept: never discard a winner locally,
let the node decide. What was missing is that a second winner for a template the
node has already settled is not a winner the node might accept - it is provably
dead.

So submissions are now gated per template, keyed by the (height, prevhash) pair
the result already carries. The first winner for a template is always submitted;
that is the money guarantee and it does not change. While one is in flight, later
winners for the same template are dropped without an HTTP call. Once the node
gives a verdict that settles the template - accepted, or an error proving the
template is gone - every later winner for it is dropped too. A pure transport
failure re-opens the template, so a network hiccup can never silence a height,
and a panic inside a submit re-opens it as well. The map is pruned to a window of
recent heights so it cannot grow for the life of the process. Redundant winners
no longer reach the queue at all, which makes the inline blocking fallback
unreachable in the case that used to trigger it.

Operator output was itself part of the problem at 1729 lines, so there is now one
line per template, printed once the miner has moved on and the count is final:
"[Mining] height 3 settled (accepted), suppressed 634 redundant winners."

Measured on the same rig after the change, same config, same 90 seconds:

  submits          1732 -> 4
  rejected         1729 -> 0
  blocks               3 -> 4
  hashrate         77.59 MH/s -> 98-190 MH/s sustained, 212.93 peak
  miner log      12997 lines -> 1394

The hashrate nearly doubled because the card is no longer waiting behind HTTP.

Also verified live end to end: the same miner through the hac-pool relay mined 3
blocks with 3 submits and 0 failures, and pool-server refuses to start a payout
while it holds the wallet's settle lock, which is the double-pay guard from the
earlier audit doing its job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-template submit gate from 059e082 learned only the fullnode's reply
shapes. Running a real GPU into pool-spike's pool-server showed it misreads a
pool in two ways, one active and one dormant but far worse.

A pool answers with a "kind" field and no "err" at all, so the gate's fullnode
error-text rules never matched. 180 seconds of one card produced 526 submits, of
which 519 were {"kind":"stale","ret":1}: every stale reply was read as a fault in
that one submission, which re-opened the template and let the next winner try
again, and the next. 98.7 percent of the traffic was spent re-offering work to a
template the pool had already retired.

The dormant one is the money defect. A credited SHARE also answers ret 0, which
the gate read as "block accepted" and used to settle the template, dropping every
later share locally. On a pool a share IS payable work, so that is systematic
underpayment of the miner. It stayed invisible on the test chain because at
bootstrap difficulty the share target and the block target coincide, so the pool
never returned kind "share" once. On mainnet, where share_bits makes a share 2^24
easier than a block, "share" is nearly every reply. A pre-fix simulation submitted
1 of 200 shares and suppressed 199.

So the classifier now understands both dialects. ret 0 with kind "block", and the
fullnode's "mining":"success", settle the template. ret 0 with kind "share" is a
new verdict that never settles, is never counted as a redundant winner, and
latches the template open so every later share is submitted; the latch is
inherited by new templates, because an upstream that pays per share keeps doing
so. ret 1 with kind "stale" settles, which is what kills the 519. "busy" means the
pool is rate limiting, so that template pauses for two seconds rather than being
hammered or killed. "duplicate" does not settle. "invalid" re-opens, as before. A
body with no numeric ret stays a transport non-verdict and re-opens, so a network
hiccup can still never silence a height. Fullnode behaviour is unchanged, asserted
by a test.

Measured on the same rig after the change, one GPU into pool-server across the
ASERT transition into real difficulty:

  submits 333: 281 share, 15 block, 37 stale, 0 duplicate, 0 busy
  (before: 526 submits, 519 stale, 7 block, 0 share)

The full money cycle then ran for real, which is the first time it has been
observed rather than reasoned about: shares credited to two workers by PPLNS,
blocks found, coinbase held back with "[settle] holding back 160 unit(s) of block
income that is not yet buried 16 deep", three of the pool's own blocks correctly
detected as orphaned and not paid, and finally "[settle] submitted payout tx
71d3d97ff367c20d paying 2 miner(s) 35 units".

Not fixed here, and it needs its own investigation: that payout transaction never
reached the node. The node reports "[TxPool] tx count: 0(0), 1(0)" and every block
mined afterwards still carries txs 0, yet the pool logged "settlement done" and
moved on. A pool that believes it paid when nobody was paid is worse than one that
knows it failed. Filed as the next thing to chase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Moskyera and others added 29 commits July 27, 2026 14:47
… comment

The previous commit justified counting leading zero bits by claiming the
published bootstrap target is fffffeffff..., and that the earlier fffffd guess
was the bug. That is backwards. mint/src/api/common.rs right_00_to_ff decrements
the last non-zero byte and fills the tail with ff, so u32_to_hash(0xFFFFFFFE),
which is FF FF FE 00.., is published as fffffdffff... The original prefix was
right.

The code change stands on its own merits and is unchanged: counting leading zero
bits tests the same condition the pool tests, min(24, N) >= 18, instead of a proxy
for it, and it prints the derived factor at each sample. Only the reason written
beside it was wrong, and a wrong fact in a comment about difficulty encoding is
the kind that gets believed later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 1 repointed the worker config at the node, then restored a snapshot taken
as the file was found. If the cell died anywhere in the warm-up, that left the
config on the node address, and the next run would snapshot THAT and faithfully
restore it. Phase 2 would then mine solo while the pool served nobody: the stats
would read zero shares for both workers, Cell 6 would call the share list broken,
and the actual cause would be a leftover from the previous attempt.

The config is now normalised to the pool address before the snapshot is taken, so
the restore is the same whatever state the file was left in, and an assertion
checks the restore took effect before the pool run begins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ight

The run reached phase 2 correctly, the warm-up lifted the target to 22 zero bits
and the pool started, and then the rival guard fired again. It was not crying
wolf this time: the rival really did come up as a CUDA worker.

The cause was the isolation I added for it. sys/src/config.rs resolves the
default config against the EXECUTABLE's directory, not the working directory, and
current_exe() reads /proc/self/exe, which follows symlinks. So the symlinked
poworker inside cpurival/ resolved back to the release directory and loaded the
GPU config: one card, two CUDA miners, both paid to the same address, and no
rival to measure against.

The comment that justified those symlinks was also wrong on its own terms. It
claimed poworker ignores a config path on the command line. It does not:
resolve_config_path_from takes args[1] when exactly one argument is present. That
was always the correct mechanism, so the rival now passes an absolute config path
and runs the real binary, with its directory kept only to separate the stats file.

Added the check that should have been there first. poworker prints the canonical
path of the config it loaded, so the guard now asserts on that line instead of
inferring from behaviour. This is worth the belt and braces: a config that cannot
be read is not fatal, load_config_path prints an error and returns an empty map,
and the rival would then run on defaults and look entirely plausible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssage

The previous commit wrote the guard's failure message through a shell heredoc and
the escape did not survive: the string literal ended up split across two lines, so
Cell 4 would not parse at all. Rewritten without the escape, and every python cell
in this document now parses under ast.parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The run reached phase 2, the warm-up lifted the target to 22 zero bits, and then
the pool died on EADDRINUSE. A pool from the PREVIOUS attempt was still holding
18082, because two separate defects combined.

First, the cleanup never matched it. The kernel stores comm in TASK_COMM_LEN
bytes, 16 including the NUL, so a 16-character binary name is only ever visible
as 15. "hbit-pool-server" is exactly 16, so `pkill hbit-pool-server` matched
nothing, every time. "fullnode" and "poworker" are 8 characters, which is why
those were cleaned up correctly and only the pool ever leaked. Now killed by
exact comm, both spellings.

Second, nothing tore down on failure. The rival guard raised SystemExit before
the teardown line, so the previous run left the node, the pool and two miners
running. The whole cell is now wrapped in try/finally, so every process dies on
any exit path, and the pkill is repeated at the end.

Also added the checks that would have named this immediately: both ports are
probed before anything starts, with a message saying what to run to find the
survivor, and the node must actually answer on 18080 rather than the loop simply
falling through after 60 failures.

-x rather than -f is deliberate. -f matches the full command line, which for this
cleanup includes the shell running it, so the first pkill would kill the shell
and the remaining ones would never run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad ratio

check_share_target had a hole wide enough to lose money through, and it is the
condition the function was written to refuse.

The guard bounded achieved_share_factor, a RATIO: how much easier a share is than
a block. It said nothing about what the share itself costs. On a chain whose
network target has 22 leading zero bits, share_bits=24 saturates the derivation to
the all-0xff ceiling. The achieved factor then reads 22, sails past
MIN_SHARE_FACTOR of 18, the pool starts, and every hash in existence beats the
share target. Credit measures HTTP round trips, and the fastest submitter takes
the 4096-share window from miners doing more work. That is verbatim the failure
the original guard describes in its own doc comment.

It caught only the LOWEST_DIFFICULTY case, where the ratio collapses to 0 as well.
Anything between MIN_SHARE_FACTOR and share_bits passed. That is not a corner: it
is exactly the ASERT activation target, 0xe9cfffff, which every non-mainnet chain
lands on at height difficulty_adjust_blocks + 2 and, at one block per second
against a 10800 second half-life, stays at for hours.

Both bounds are now applied. pool_core::share_cost_bits reports the leading zero
bits of the target actually served, which is what one share costs as a power of
two, and MIN_SHARE_COST_BITS requires 2^16. Below that even a single CPU thread
produces more than one share per second and the ordering stops being about work.
Real mainnet difficulty clears it at any legal share_bits, so the bound only bites
on a chain too easy to account on.

The two failures also need opposite advice, so they are reported separately. Since
leading_zero_bits(network) == achieved + cost, lowering share_bits moves work from
the ratio into the cost: where the chain has room the message names the highest
share_bits that would work, and where it does not it says so and stops, instead of
sending the operator to retune a value that cannot help.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nswer this

The local-chain premise was false and every run built on it was void. Off mainnet
the ASERT anchor sits at difficulty_adjust_blocks + 2 and its target is the fixed
constant 0xe9cfffff, which u32_to_hash gives exactly 22 leading zero bits. The
chain does not climb to that value, it is pinned there, and afterwards ASERT's
half-life is 10800 seconds of wall clock while blocks cannot arrive faster than
one per second, so the difficulty gains one bit per twenty minutes. With the
lowest legal share_bits of 18, the most a share could ever cost on that chain is
sixteen hashes. Cell 3's claim that shrinking the adjustment window reaches real
difficulty in minutes was wrong, and the pool now refuses the regime outright.

So the node syncs the real chain, and share_bits is computed from the difficulty
actually in force rather than typed, leaving each share about 2^20 hashes: tens
per second for the card, well under one for a CPU thread, which is the spread
being measured.

The audit that found this also found the harness would have reported PASS without
measuring anything, so:

- rate_hps could not match the miner's own kilohash unit. rates_to_show emits an
  uppercase K with no space ("938.14KH/s") and the pattern accepted only
  lowercase, so it returned no match at all. A single CPU thread lives in that
  band, so the control's hashrate read as zero, the expected share collapsed to
  100%, and the proportionality check silently became a copy of the window check.
- Two checks were written as "<input missing> or <real test>", which reports PASS
  precisely when the input could not be read. Every check now requires its own
  measurement, and a new one requires the control to have run at all: a rival that
  died at minute two and a perfect result were previously the same numbers.
- Nothing deleted the logs or final_stats.json, and nothing checked their
  freshness, so any abort left Cells 6 and 7 reprinting the previous run's verdict
  byte for byte. They are removed up front, stamped with a run id, and asserted.
- The template divisor counted drain ticks that lagged the tip rather than
  templates, with no dedupe, so it inflated exactly when the share list works
  best. Counts distinct heights now.
- Cell 1 hid build failures behind a pipe to tail, whose exit status is always 0,
  and never updated an existing checkout, so the run could measure a binary older
  than the fix. Fixed with pipefail and an explicit fetch/reset.
- The pool log was printed as the last 800 bytes, a window that starts after the
  startup summary and therefore hides the line stating the share factor really
  served. Printed in full, and a capped factor now aborts.
- Corrected three pieces of guidance that contradicted the source: SUBMIT FAILED
  is the catch-all arm so it tracks stale on any pooled run and cannot be zero;
  an EMPTY template gate section is the healthy pooled result rather than a
  missing one; and [miner] enable starts no node-side hasher, so the closing
  advice to let "the node's own CPU miner" confirm a payout could never work.
- UNDERSAMPLING is throttled to one line per 30s and carries the session total
  beside the per-batch figure. The report took the wrong number and told the
  operator to raise share_bits, which makes shares easier and overflows sooner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lem worse

share_bits is how many powers of two EASIER a share is than a block, so raising it
makes shares easier, makes more nonces per batch payable, and overflows the share
list sooner. The warning printed when that list overflows said "ask the operator
to raise share_bits". An operator following it would watch their lost income grow
and have no way to tell why.

The remedy is the opposite: lower share_bits so each share is harder. Says so now,
and says which direction does what, because the value's name does not make that
obvious.

Also states that the line is rate limited to one per thirty seconds. Without that,
the number of warnings looks like the number of overflowing batches, when it is
capped at about twenty for a ten minute run, and the per-batch count looks like
the loss when the session total is sitting in the same sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2.7 GB, taken from a completed sync of the same chain rather than estimated, so
the operator knows before starting the long cell that Colab's disk is not the
constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r exists

Cell 3 said the worker config's connect line is rewritten later. It was, by the
local-chain warm-up, which is gone: the difficulty now comes from the real chain,
so nothing touches the file after it is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Run 20260727T154114 on a Colab T4 against the real chain at a block cost of 2^42:
2,493 submissions to the CPU control's 6, taking 99.76% of the PPLNS window on
99.697% of the hashrate. The window snapshot and the whole-sample submission split
agree exactly, and all five checks passed with no stale, no failures and no share
list overflow. The CUDA share list pays the card for what it mines.

Two notes beside the numbers so they are not misread.

6.92 MH/s is not a regression against the 86 MH/s this card showed before.
block_hash_repeat is height / 50000 + 1 capped at 16, so mainnet heights hash at
repeat 16 while a chain at genesis uses repeat 1. That factor of sixteen is the
whole difference, and this is the mainnet-representative figure.

The control produced 6 shares, so Poisson noise on it is about plus or minus 2.4
and the measurable window share spans 99.64% to 99.84%. The expected value falls
inside, so the agreement holds, but its precision is set by that count. Also
recorded: only one height change was seen, so template rolling and the stale path
were barely exercised, and the share list never overflowed, so the undersampling
path was reached only by the Cell 2 unit test that targets it directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The compose file asked for share_bits 24 and the operator guide called it "the
right answer unless you have measured otherwise". It has now been measured: a
block on the live chain costs 2^42, so 24 leaves each share at 2^18. Two things
follow, and both argue for 20.

Margin. The pool now refuses to start when a share would cost less than 2^16, so
24 sat two bits above a hard refusal. A fourfold drop in network difficulty would
have taken this pool offline at its next restart, which is a poor property for a
service that holds other people's balances.

Payout memory, which matters more. PPLNS pays on the last 4096 shares, so easier
shares make that window span LESS time. At 2^18 an ordinary card produces about
26 shares a second and a ten-miner pool turns the entire window over in roughly
fifteen seconds: a miner that drops off for half a minute loses everything it was
owed. At 2^22 the same pool keeps about four minutes of history. That is the
difference between a payout scheme and a lottery on connection stability.

The operator guide also still described the old single check and repeated its
advice that lowering share_bits cannot help. That is now only true for one of the
two failure modes. Rewritten to explain both, why a healthy ratio does not imply
a share that costs anything, and that the two want opposite remedies. The error
table gains the second message and stops recommending 24.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Building this image on a 32 core host killed the Docker engine mid-compile. The
whole application went down with it and the only trace left was
"rpc error: code = Unavailable ... EOF", which says nothing about the cause.
Cargo defaults to one job per core and each rustc on this tree can hold well over
a gigabyte, so 32 of them exhausted the engine VM.

That is worth fixing rather than working around, because the machines this image
is FOR are far smaller than the one that failed. On a 2 to 8 GB VPS or mini PC an
unbounded build is not merely slow, it is certain to be killed, and the operator
sees a signal number rather than a memory message.

CARGO_BUILD_JOBS is now 4 by default, which keeps the peak near 6 to 8 GB, and is
exposed as a build arg so a builder with the memory can raise it at roughly 2 GB
per job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The existing patterns match a directory named target, so they missed
.codex-target-security-20260719-reaudit completely: 3.5 GB of compiler output
from an earlier audit, uploaded to the daemon on every single build. The measured
context was 3.94 GB and took 198 seconds to transfer before any work started,
which is 55 times what this image actually needs.

Widened to anything with "target" in the name, wherever it sits, plus .codex-*.
Verified safe: no tracked file in this repository has "target" anywhere in its
path, and the only directories the pattern matches are the three build outputs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssing driver

Found by running the packaged Linux release rather than by reading it.
./list_opencl and ./diagnose_opencl both exited 101, a Rust panic, with nothing on
stdout:

  thread 'main' panicked at ocl-0.19.7/src/standard/platform.rs:49:38:
  Platform::list: Error retrieving platform list:
  ApiWrapper(GetPlatformIdsPlatformListUnavailable(10))

Platform::list() panics when the ICD loader is installed but no vendor driver is
registered, which is every fresh Linux install, every container and every WSL
session. That is precisely the machine whose owner was told by README-LINUX to
run ./list_opencl to check whether their OpenCL driver is present. The one tool
whose job is to diagnose a missing driver was the one that died on it, and it
died showing a path inside a crates.io dependency.

platform_list() now enumerates through ocl::core::get_platform_ids(), which
returns a Result, and treats "none" as an answer rather than a failure. The scan
adds a warning naming what to install and where the .icd file belongs, and says
that HACD is CPU-only so diamond mining is unaffected.

The same call sat in the miner's GPU init, where it was worse: a panic there took
the miner down instead of letting it fall back to the CPU. It now reports the
absence and returns no devices, which the caller already handles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seventy commits since v0.4.1, and the release carries a behaviour change rather
than only fixes: a pool that started before may now refuse, because the share
target guard checks what a share costs and not merely how it compares to a block.
That is a minor bump, not a patch.

Cargo.lock moves with it so the workflow's --locked builds still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The v0.5.0 run failed on Windows and on Linux, for unrelated reasons, and
published nothing. Two earlier release runs had failed the same Windows way.

Windows: the HACD CPU-only check looked for dumpbin under
"Microsoft Visual Studio\2022\...". Visual Studio no longer installs there; on
this machine it is "Microsoft Visual Studio\18\BuildTools". Worse, GitHub runs
pwsh with ErrorActionPreference = Stop, so Get-ChildItem threw on the missing
directory before the script's own "dumpbin.exe not found" message could say
anything useful. It now asks vswhere where Visual Studio actually is and falls
back to globbing every year and edition under both Program Files roots, with the
lookup errors suppressed so the intended message is the one that prints. It still
refuses to continue if dumpbin cannot be found: HACD must be PROVEN CPU-only
before release, not assumed.

Linux: a doctest in app/src/opencl_gpu/resources.rs. A four-space indent inside a
/// comment is a Rust code block to rustdoc, so it tried to compile the prose
"lambda = batch seconds * shares per second" and stopped the build. Fenced as
text.

Worth recording why the local suite missed it: `cargo test --workspace` was run
without --features ocl, and the module is gated behind that feature, so the
doctest was never collected. CI runs `-p app --features ocl`. Matching the CI
flags, not approximating them, is the check that would have caught this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Windows key-file hardening verified its own DACL and rejected ANY principal
other than the current account. On a machine where the OS keeps its own ACE it
therefore failed closed, and for a pool failing closed on the wallet means it does
not start and nobody gets paid. The release build caught it:

  write and secure the key file: "...wallet.key.tmp is still accessible to
  `NT AUTHORITY\SYSTEM`"

Excluding LocalSystem or the local Administrators group buys nothing. Anything
running as either can take ownership of the file, read this process's memory, or
load a driver, so a key they cannot reach through the DACL is one they can reach
another way moments later. What the DACL actually protects against is other
ordinary accounts on a shared machine, and that is untouched by leaving these two
in. The trade being made was availability for no security at all.

Both are now accepted, and the pool prints who else can read the key rather than
passing over it silently.

Matched by SID, not by name. icacls prints LOCALISED account names, so comparing
against "NT AUTHORITY\SYSTEM" or "BUILTIN\Administrators" would quietly stop
working on a German or Greek Windows and start rejecting the exact principals
this is meant to allow.

Honest limit: the new branch cannot be exercised on a machine whose ACL is already
clean, which is the case here. It is the CI runner that reproduces the condition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for gating the mining start. The panel treats "the RPC answered" as
readiness, and on a node still downloading the chain that is not readiness: it is
a promise the miner cannot keep, and it fails quietly while looking like success.

Seen on a real machine today. The node sat at height 70,000 of a chain past
800,000, the miner started anyway, and it reported 215 MH/s. That is HIGHER than
a synced node produces, because block_hash_repeat is height / 50000 + 1 capped at
16, so down at 70,000 each hash costs an eighth of what it costs at the tip. The
operator saw a large number, concluded it was mining, and every solution the card
found was for a block the network had settled years earlier.

Progress is measured in chain TIME rather than in blocks, because the height of
the real tip is precisely what an unsynced node does not know. The timestamp of
the block it does have, against the clock, says how far back it is standing and
needs nobody else to tell it.

The arithmetic is separated from the fetching so it can be tested without a node.
The cases covered are the ones that would otherwise divide by zero or show a bar
running backwards at a user: a tip ahead of the clock, a genesis at or after now,
and a body that cannot be parsed, which must degrade to "unknown, keep waiting"
rather than to zero, since zero would read as genesis and look plausible.

Both endpoint bodies in the tests are captured verbatim from the running node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for showing the operator what is actually happening. Every child the
panel starts is currently spawned with CREATE_NO_WINDOW, so a full node
downloading the chain does its work entirely out of sight. That is how a node
stuck part way through a sync came to look, from the panel, exactly like one that
was ready to mine on.

The trade is stated in the code rather than discovered later: a process with its
own console is not piping its output back, so the panel cannot quote its log in
an error message. Status is unaffected, because it comes from polling the RPC and
from the child's exit code, neither of which needs the pipe. The detail moves to
a window the user can see.

Windows only, and the comment says why: on Linux and macOS a GUI-launched child
has no terminal to attach to, and conjuring one means choosing a terminal
emulator that may not be installed. The piped log stays the answer there.

Not yet wired to the spawn sites; that lands with the sync progress bar, so the
two arrive together rather than leaving a window open above a panel that still
claims to be ready.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel started mining the moment the node's RPC answered. On a node still
downloading the chain that produced the worst kind of failure: the miner hashed
against blocks the network settled years ago, every solution was rejected, and
because block_hash_repeat is height / 50000 + 1 capped at 16, the reported
hashrate was HIGHER than normal. It read as success.

A solo start now waits. The 45 second deadline still governs getting the node UP,
but stops applying once it answers, because catching up legitimately takes an hour
and abandoning a node that is working perfectly well would be its own bug.

Progress goes in the FOOTER, visible from every tab, because the failure being
replaced is a user concluding the thing is broken. A stalled sync and a ready
panel used to look identical; now the bar moves, names the height, and says how
many blocks are left. It also asks for a repaint, since egui otherwise redraws
only on input and a frozen bar would imply the very hang this disproves.

A probe that cannot answer counts as "keep waiting", never as "caught up". A node
mid-batch is briefly unresponsive, and treating that silence as readiness is the
bug being fixed. The probe runs on its own thread; three blocking HTTP requests
must never sit on the UI thread.

Strings in all nine languages. The script asserted the block count and refused to
write when it found nine rather than the seven assumed, which is why Thai and
Russian are not missing here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…walk

A node behind the tip had only one recovery path once it was past height zero:
ask a peer for `unstable_block` block hashes. That request exists to find the
common ancestor of a SHALLOW fork. Used on a real backlog it advances four blocks
per announcement, which on mainnet is four blocks every five minutes, so a node
three thousand blocks short of the tip needed days and looked completely idle the
whole time.

Both places that could reach it now start a batch sync instead when the gap is
deeper than `unstable_block`: the status handler, and the arriving-block path,
where the peer that just sent a future block demonstrably has the newer chain.

The message there was misleading too. "ignore future block ... during history
sync" was printed once per arriving block while nothing advanced, and read as a
node refusing work when it was really asking for four hashes and getting nowhere.
It now reports the gap and the catch-up.

HONEST LIMIT, because the run that prompted this found something else. On the
stalled node the actual halt is a single
`[Block Sync Warning] insert 446201 failed: diamond status HTAKES not found`,
after which the sync stops and never retries. That is a STATE error, not a
network one, and this commit does not address it: chain/src/sync.rs aborts the
batch on an insert error and nothing above it tries again. The prime suspect is
`fast_sync = true` skipping state that a later block needs, which would also
explain the head rolling back from 765,065 to 446,200 across a restart. Not yet
proven, and it is the next thing to test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… extended

Proven on hardware today, not reasoned about.

A node synced with fast_sync = true stopped dead and stayed stopped:

  [Block Sync Warning] insert 446201 failed: diamond status HTAKES not found

One line, then silence. chain/src/sync.rs aborts the batch on an insert error and
nothing above it retries, so the node sits at that height indefinitely while
answering its RPC and looking healthy. Turning the flag off afterwards does NOT
repair it: the state was never written. The head had also rolled back from 765,065
to 446,200 across a restart, which is the same damage showing from the other side.

A clean sync of the same chain with fast_sync = false reached the real tip,
768,129, in about seven minutes with zero errors, and then held there. That is the
whole basis for this change.

It was shipped in every path a user can take: the config the panel writes for
them, the deployment node config, the mainnet example, and the Colab document. So
anyone who followed our instructions built a chain that would stall, and on a
stalled node solo mining hashes against blocks the network settled years ago while
reporting a HIGHER than normal hashrate. That is how this was found.

What is NOT fixed here: why fast_sync skips that state, and why a failed insert
never retries. Either would be a better fix than avoiding the flag. Both are open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last piece the operator asked for, and it lands now rather than earlier for a
reason: a console opening above a panel that still claimed "ready" on an unsynced
node would have been worse than no window at all. With the sync gate in place the
window and the panel finally say the same thing.

Both HAC and HACD get it, and so does the node. start_mining_after_opencl
branches on connect mode, not on mining kind, so every path goes through the same
spawn and the same gate.

Windows only, and the trade is the one already documented on
configure_visible_command: a child with its own console pipes nothing back, so
the channel returned here stays empty and the panel quotes no log. Status is
unaffected, coming from the RPC and the exit code. On Linux and macOS a
GUI-launched child has no terminal to attach to, so the piped path stays, and its
imports are now cfg-gated so neither target builds dead code or warns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gating Stdio to non-Windows fixed the release build and broke the test build.
Caught only because the verification command piped cargo into grep, which returns
0 when it finds the word "error", so a failing test compile read as success and
the commit went through anyway. Same masking that hid a Docker failure and a
release failure earlier; the exit code now comes from PIPESTATUS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes to v0.5.0, all found by running the product rather than reading it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Packaged APART from the miner in both jobs, and that separation is the point. The
pool is server software that holds a wallet and pays other people; putting it
inside an archive aimed at gaming PCs would hand a money-handling binary to
everyone who only wanted to mine.

It ships with no wallet, no config and no address. The operator supplies all
three and the pool refuses to start without them, so nothing in this archive can
pay a stranger by default.

The Windows ZIP validation now counts `hacash-miner-*.zip` rather than every zip
in dist. It asserted exactly two, so a third package would have failed the build
for a reason that had nothing to do with the miner.

Written with literal file edits rather than a shell script, after the previous
attempt sent `\target\release` through a bash heredoc, then Python, then YAML, and
arrived as a TAB and a CR. Paths in the new PowerShell use Join-Path and forward
slashes so there is nothing left to escape. Verified: the workflow parses, both
jobs carry the step, and the file contains no tab or carriage return at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first pool archive shipped hbit-pool-server and hbit-pool-payout and nothing
else. That is not a deployable package: the pool takes its templates from a full
node and submits blocks to one, so an operator who downloaded it got software
that cannot start. A packaging bug, not something to paper over in a README.

The archive now carries the node binary, a mainnet config template, the systemd
units, the readiness script, the deployment notes, and a first-time setup script.

That script checks and then stops. It does NOT invent a reward address, does NOT
write a passphrase, and does NOT start anything: it refuses to continue while the
reward field is empty, while fast_sync is true, or while no wallet passphrase is
configured, and it says why each one matters in the terms of what it costs. Every
one of those could have been "helpfully" defaulted, and every default would have
been worth less than what it risks, since this is software that holds other
people's money.

It also refuses on under ten gigabytes free, because the measured mainnet chain
is 2.7 GB and growing, and a sync that dies on a full disk fails late and messily.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No code change since v0.5.1. This exists so the pool package is rebuilt with the
full node inside it, which the previous archive was missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Moskyera
Moskyera merged commit b63eb6c into main Jul 27, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant