Skip to content

DECE expose fresh full-node sync capability - #6

Draft
Moskyera wants to merge 26 commits into
mainfrom
codex/fullnode-sync-capability
Draft

DECE expose fresh full-node sync capability#6
Moskyera wants to merge 26 commits into
mainfrom
codex/fullnode-sync-capability

Conversation

@Moskyera

Copy link
Copy Markdown
Owner

Adds an explicit, measured sync capability to the HPAY-compatible full node. The API reports tip time, observed time, tip age and a bounded freshness decision. Includes stale, future-skew and fresh-tip tests. No mining or consensus behavior is changed. This draft is the pinned full-node prerequisite for the HPAY Fast Pay Hub mainnet pilot.

Moskyera and others added 25 commits August 1, 2026 19:01
An RX 9070 XT went from 19.13 to 28.45 MH/s in live mainnet mining. Nothing in
the hashing changed: the win is entirely in the launch shape, and it is proven
byte identical against the CPU oracle before it was believed.

The tools came first, because none of the numbers below would have been
trustworthy without them.

**A gate that can fail.** app/src/x16rs_gate.rs compares every GPU hash against
x16rs::block_hash, not one per batch, by borrowing the pool share list with an
all-ones target so an entire window comes off the card. Three faults injected
into copies of the kernel tree prove it catches what it claims: a shabal counter
off by one (it named shabal), one bit flipped in blake's IV (it named blake), and
the barrier at x16rs.cl:269 deleted (mismatches across seven algorithms, a race
signature). A gate that never fails is not a gate.

**A measurement that survives the card.** Within one process the baseline holds
to 0.3%; across processes on identical work it moves 2.6%, because the card
settles into a clock state for the life of a process and a 38 second warm-up does
not fix it. So comparisons run both kernel trees inside ONE process with the
order swapped, and anything under about 1% measured across processes is treated
as noise rather than a result.

**The shape.** 48 x 256 x 48 was the worst of the plausible choices: 32 compute
units host 2 work groups each, so odd multiples of 32 leave a half empty
scheduling tail, and a sweep dips at exactly 48 and 96 and nowhere else. The
kernel is latency bound here, not busy (256 VGPRs, 3,256 bytes per work item
spilled to scratch), so it wants nonces in flight, and unit_size buys those about
11% more cheaply than work_groups at a matched batch. 64 x 256 x 192 wins.

The work-group cap was never the constraint. ArchLimits::max_unit_size was, and
it is gfx1201 only. panel_tuning gained an explicit table for that card because
the generic tier scaling produced both bad counts: Profit shipped 48, and raising
the ceiling moved Max onto 96.

**Real board power.** app/src/gpu_temp_adl.rs already loaded atiadlxx.dll for
temperature; board power is index 73 of the same sensor block, in whole watts,
identified the way the temperatures were and cross checked against neighbours
(index 40 = 4 and 41 = 16, i.e. PCIe gen 4 x16 on this slot). 16 W idle, 90 W
light, 334 W mining. No new dependency, nothing bundled.

That measurement immediately earned its place. The configured guess put the card
at 280 W and the panel displayed 196 W, so Net/day read +0.06 EUR. The card
really draws 335 W, and the true figure is -0.40 EUR/day at 0.13/kWh with HAC at
0.1944. The estimate was hiding a loss. should_pause_for_profit now decides on
the same reading it reports, so an operator can never be shown one cost and
paused on another.

**An auto-tuner that measures the right workload.** The old one benchmarked at
height 1, x16rs repeat 1, while production runs repeat 16; its own comment said
so. app/src/autotune16.rs replaces it: every candidate hashes exactly the same
multiset of (header, nonce) pairs, proved by a test that expands both tilings and
compares element by element rather than by comment, because x16rs picks its next
algorithm from the previous result and a candidate handed a cheaper mix looks
faster while being slower. Finalists re-run in alternating order so the last is
not flattered by a hotter card, the winner soaks until thermals are flat, p95
batch latency is a constraint rather than a footnote, and scoring uses measured
watts. A candidate that fails the gate is refused with the config untouched.

**Thermal, now that the sensor exists.** max_temp_c was a dead setting on this
card: nothing answered, so no limit could act. It answers now. A bench soak read
89 to 90 C hotspot; real mining reads 93 to 95, because the bench does not also
run a full node on the same machine. The shipped guard is set against the higher
figure.

Reverted along the way: compile-time kernel specialisation. It produced byte
identical device binaries (same md5, 529,200 bytes) while changing the source
fingerprint, which would have forced every installed miner to recompile its
kernel for nothing. The register pressure is not the loop bound; fifteen of the
sixteen algorithms inline into one 420 KB function, so the worst sets the budget
for all.

Also corrected: watts_measured tested cpu_w == 0.0, so an operator setting
cpu_watts_per_thread = 0 with assist threads running would see a total labelled
"measured" with the CPU draw missing from it. It tests the thread count now.

Independent re-measurement put the shape gain at +9.2% and +4.3% kH/J over
64 x 64, not the +12.0% and +8.0% first reported. The winner reproduces; the size
of the win was overstated by about a third, and the smaller figure is the one in
the comments.

cargo test --locked --workspace exits 0. No em dashes. x16rs kernels are
untouched, so HACD carries no new unverified consensus surface.

**And the tuner it replaced only worked on the card it was written on.** Caught
by asking a question neither earlier review had: not "does the search space
generalise" (it does) but "can the tune finish". It could not. The corpus was the
l.c.m. of the whole candidate universe, so it scaled with max_work_groups, and
gfx1201 is the one preset held at 64 work groups. Every other card needed 53 to
107 MH/s before a tune could produce any output, against the one measured rate of
28.8. They got seventeen to forty minutes of sweeping, then nothing, then advice
that could not work. The tuner it replaced measured the wrong workload but it
finished, so this shipped as a regression for everyone but us.

Sizing the corpus from a bounded reference instead drops the requirement to 1.75
to 1.86 MH/s across the table: fifteen times headroom on the hardest preset
instead of a 3.7x shortfall. Verified three independent ways that had to agree
(the real planner, a from-scratch reimplementation, and closed-form algebra) over
152 preset and CU combinations, with the shared-work guarantee broken on purpose
twice to prove the tests catch it. A tune that cannot fit its budget now says so
in one to three seconds instead of after forty minutes.

Two silent no-ops went with it. On NVIDIA and Intel every efficiency mode ranked
identically, because power was AMD-only, so scoring collapsed onto throughput and
an operator who chose Eco was handed Max; nvidia-smi reports power.draw and is
now read. On Intel the temperature refusal did nothing at all, because an absent
sensor counted as satisfied.

One more thing this exposed: `ocl` is not a default feature, so
`cargo test -p app` never compiled `mod device` and the gate was green all day
without touching tune(), probe_rate, Sampler, run_corpus or soak_until_settled.
`cargo test --locked -p app --features ocl` is the gate now: 259 passing.
…e closed

Colab and every NVIDIA owner run the CUDA path, and it had no byte-equivalence
proof at all: the whole gate was behind `#[cfg(feature = "ocl")]`. Measuring a
hashrate there would have been the mistake this project spent a day avoiding on
AMD, a number that looks like a result and proves nothing.

`x16rs_gate equiv --backend cuda` now compares the CUDA kernel's hashes against
`x16rs::block_hash` the way the OpenCL backend does: the CPU as oracle, never GPU
against GPU, every hash in a window rather than the best one per batch.

The fault trees really do reach CUDA, which is not obvious and was checked rather
than assumed. `nvcc` compiles `x16rs-cuda/cuda/block_miner.cu` for
`-arch=compute_75`, so each tree was compiled and the PTX diffed: fault A moves
`xor.b32 %r, %r, 1` to `2` in both kernels, fault B removes exactly one
`bar.sync 0;` (11 to 10), fault C changes one byte of the blake constant. A diff
in a `.cl` file would have proved nothing about CUDA.

## Two holes in the gate itself, both demonstrated on the card

**A caught defect was reported as a device error.** Six checks report a broken
kernel by returning `Err`: the production count threshold, the best-hash
reduction not being the window minimum, and four kinds of bad window dump. Every
`Err` became exit 4, and the Colab wrapper maps exit 4 to "the gate could not
open the device. Nothing was compared."

So the gate caught the fault it exists to catch and the operator was told nothing
was compared. Proven with fault tree A:

    [gate] ERROR: production shape ... the CPU says exactly 16 of the 589824
    hashes are <= 0001965d..., the kernel counted 22.
    exit 4

It matters most where it hurts most. The exhaustive shapes are 1x256x4 up to
4x256x1; production is 48x256x48. A defect in the cross-workgroup minimum, in
global indexing at large offsets, or anything that only appears at 48 work groups
is visible ONLY through one of those `Err` paths.

Detections carry an explicit `DETECTED` tag at the source now and exit 3 with the
rest of the mismatches, so exit 4 means only what the wrapper always claimed it
meant. Tagged, not matched by phrase: renaming a message must not be able to turn
a caught defect back into "no GPU attached". Same fault now:

    [detected] production shape ... the CPU says exactly 16 ..., the kernel counted 22
    RESULT: FAIL
    exit 3

**A single flag deleted the byte-for-byte pass and the gate still said PASS.**
`--headers 0` or `--batches 0` makes the exhaustive loop body never run. The
device is still opened, the production pass satisfies `compared > 0` and every
algorithm count, and the verdict was PASS with zero bytes compared exhaustively.

Production-only is a legitimate mode, for isolating a defect that lives at
48x256x48, so it stays, behind `--production-only`. Without that flag the
degenerate combination is refused outright, and with it the verdict says what was
skipped instead of printing a bare PASS:

    RESULT: PASS (production shape only; NO exhaustive byte-for-byte comparison was run)

The report also records what was ASKED for, not only what ran, so a requested
pass that executed zero batches fails with a reason rather than a bare FAIL.

My first attempt at this fix was wrong in an instructive way: it treated
`--headers 0` as "the caller did not ask for an exhaustive pass", which turns a
typo into a valid intention. The test caught it.

`cargo test --locked -p app --features ocl` and `--workspace` both exit 0. No em
dashes.

Still unknown, and stated rather than glossed: nothing here has run against a
real NVIDIA device. This machine has an RX 9070 XT and nvcc but no CUDA GPU, so
the harness is compiled, PTX-diffed and exercised through the OpenCL backend on
the same code paths. Whether the CUDA kernel agrees with the CPU is exactly what
the Colab run is for.
…ng guesses

A Colab Tesla T4 measured, at repeat 16 on a fixed corpus, warmed to a steady
state where consecutive runs agreed to 0.57%:

    work_groups 256, local_size 256, unit_size  64  ->  7.54 MH/s
    work_groups 256, local_size 256, unit_size  96  ->  7.19 MH/s
    work_groups 256, local_size 256, unit_size 128  ->  7.06 MH/s

On the RX 9070 XT the ordering is REVERSED: there 192 beats 64 by about 9%.

The reason is in nvidia-smi rather than in the kernel. The T4 sat at 66 to 67 W
against a 70 W cap with its SM clock swinging 1140 to 1305 MHz. It is power
capped. The 9070 XT is latency bound and underfed, so more nonces in flight buy
it something; a card already at its power limit gains nothing from a bigger
batch, it just stays at the limit longer.

Two cards, opposite optima, and that is the whole argument for a tuner that works
on hardware nobody has measured.

## What changed

**The tuner can measure through CUDA at all.** app/src/autotune16.rs reached the
card only through crate::opencl_gpu, and its device module is
#[cfg(feature = "ocl")], so a --features cuda build had no tuner: poworker
printed "[autotune] No OpenCL devices" and returned, which reads like a hardware
fault on a working machine. It now says what is actually true, in words about
CUDA.

**NVIDIA launch shapes are derived rather than invented.** New
app/src/nvidia_launch.rs carries the derivation line by line: above the floor
that fills the card, work_groups is a wave count and nothing else, and unit_size
is the only axis that changes what a resident block does. Every rung of the
ladder lands on unit_size 64, which is the smallest value anyone has MEASURED to
win, from one T4, and the comment says exactly that rather than dressing a guess
as a recommendation.

**A Colab cell that runs the real tuner and can report no gain.** It runs
autotune16 the way an operator does, then checks the result independently:
x16rs_gate baseline measures the chosen shape and the shipped preset on identical
fixed work, and because a CUDA binary holds one kernel build there is no
in-process A/B, so the bar is the 2.6% between-process spread and a difference
under it prints NO GAIN SHOWN. A tuner that always finds an improvement is
worthless.

The cell refuses to install anything if a candidate failed the CPU oracle, if the
soak never settled, or if the "Applied" line names a shape the ini does not
actually hold. That last one matters: poworker exits 0 even when the tune was
refused, so the cell reads the file rather than trusting the exit code.

Its logic was exercised against fake poworker and x16rs_gate binaries over eight
cases: a clean win, a gain inside the noise, a loss to the preset, a candidate
failing the oracle, a soak that never settled, a refused session, an Applied line
whose file was not patched, and a missing config. All eight refused or installed
as intended.

Two lessons from today are written into it. %%bash swallows exit codes, so the
code is printed on its own line. And warmup is measured in BATCHES: a 900 batch
warmup at 25M nonces each cost 37 minutes of blank screen, so the cell prints its
time estimate before starting any work and kills itself if the plan does not fit
the session.

The cost is dominated by the CPU, not the GPU. A free Colab has two vCPUs, the
tuner keeps one for the oracle, and it proves candidates at about 60 kH/s while
the card hashes at 7.54 MH/s: the verifier is a hundred times slower than the
thing it verifies.

cargo test --locked -p app --features ocl and --workspace both exit 0. No em
dashes. Nine languages intact.

Not run on an NVIDIA device: every number the cell prints, x16rs_gate baseline
--backend cuda itself, and the preset probe. The byte-equivalence gate HAS run on
a T4 and passed with 311,571 hashes compared and all three deliberately broken
kernels caught, so what is unverified here is the tuning, not the hashing.
Cell 5b is too long to copy into a notebook reliably, and a truncated paste
fails in ways that look like a tuner fault rather than a copy fault. Extracted
verbatim from the same block so the two cannot drift.
Running the cell twice put two tuners on one T4 and one oracle core. The numbers
stayed valid, because each still proved its own candidates against the CPU, and
became meaningless, because each was timing the other's contention. Measured the
moment it happened:

    one tuner   64x256x32   6.22 MH/s   p95  86ms
    two tuners  64x256x32   3.66 MH/s   p95 190ms

Half the rate, twice the latency, and nothing on screen said why. This is the
third time today the same shape has cost us: four agents timing kernels on one
GPU, then a shell that matched its own command line while hunting processes, now
this. What ends it is not more care, it is a measuring tool that declines to
measure when it is not alone.

Tested both ways: it returns normally on a quiet machine, and names the pids
when it does not.
…tion

A full tune on a Colab T4 confirmed the derived ladder and corrected the reason
given for it. Two claims in nvidia_launch.rs were wrong and are replaced by what
the tuner measured at steady state with finalists re-run in alternating order.

The first was mine. A hand sweep gave unit_size 64 -> 7.54 MH/s, 96 -> 7.19,
128 -> 7.06, and I concluded smaller wins. That sweep ran the three shapes back
to back in one loop on a passively cooled card climbing from 63 to 74 C, so it
may have measured nothing but the order they ran in. At 256 work groups the
tuner measured 32 -> 6.62, 64 -> 6.91, 128 -> 7.29, the opposite ordering.

64 survives as the partner of a high work-group count rather than as a small
value that wins alone. At a matched batch of 8.39M nonces, 512x256x64 gives 7.48
and 256x256x128 gives 7.29: same nonces, two arrangements, the wider one wins.
NVIDIA wants width, the RX 9070 XT wanted depth, and that is why one table
cannot serve both.

The second was the claim that a higher tier is not faster. On this card it is:
6.91 at 256 work groups, 7.29 at 384, 7.48 at 512, about +8% and well outside
the 2.6% between-process spread, at 66 W against a 70 W cap throughout. Same
power spread across more of the chip, not more power buying more hashes.

Three caveats travel with that number in the comment, because the tune stated
all three itself rather than crowning a winner and going quiet: the top two
finalists were 0.14% apart while one shape's own repeats spanned 2.35%, so it
called them a coin toss; the soak made 36 passes in 123s with the hashrate still
drifting 0.35%, so it refused to write anything into a config; and it is one
card in one session.

The ladder needed no numeric change. nvidia_performance already sat at 512x64,
which is what the tune picked.

cargo test --locked -p app --features ocl and --workspace both exit 0.
…ere it ships

X16RS_RUN_REPEAT_LOOP put a full double-fence work-group barrier after EVERY
hash. It was preventing nothing. `local_order` is a bijection onto the group's
slots: the scatter pass writes each slot index exactly once, the hash pass reads
position `(local_size * h) + local_id` which covers that range exactly once, so
inside the hash pass every slot is touched by one work item in one iteration. The
barrier was holding threads in lockstep for no reason.

It moves to the end of the round body, where it IS needed, and the reasoning is
written at the barrier rather than here.

## Why it was held back until now, and what released it

The macro is also invoked twice by x16rs_diamond.cl, and the gate has zero
diamond coverage, so this looked like unverified HACD consensus surface.

It is not, and the proof is doubled. Build: release.yml compiles diaworker with
no features and CI hard-fails via dumpbin and ldd if hacash or diaworker ever
imports OpenCL. Source: diaworker.rs:88 hardcodes `useopencl: false` in the only
constructor of DiaWorkConf, no ini key or argument can set it, and
`legacy_hacd_gpu_config_is_forced_to_cpu_only` enforces that. Even
`cargo build --features ocl --bin diaworker` yields CPU HACD: the code compiles
and never runs. No shipped or from-source artifact computes a diamond hash on a
GPU.

## The gain is smaller than first reported, and shape dependent

The first measurement said +8.96%. Paired A/B on a gfx1201, both trees
alternating inside one process with the order swapped, every run also reporting
byte identical output:

    64x256x12    +45.84%
    64x256x48     +8.20%
    48x256x48     +8.14%     <- where 8.96 actually came from
    64x256x64     +6.54%     <- the shipped poworker.config.ini
    64x256x96     +4.10%
    64x256x192    +1.41%

Barriers are a fixed cost per hash and the work between them is not, so the more
unit_size does per round the less they matter. The kernel comment said "+8.96% at
the shipped shape (unit_size 12)" and both halves were wrong together. The honest
figure for an operator is +6.5%.

## Zero barriers is wrong, and this card says so out loud

A no-trailing-barrier tree failed the gate 5 times out of 5, and visibly as a
race rather than a miscompute: run 1 diverged at the 256-count threshold, runs 2
to 5 at the 16-count threshold, on identical work. `local_hashes` aliases GLOBAL
memory at every call site, and the reads after the final round (two in
x16rs_main.cl, one in x16rs_diamond.cl, two in block_miner.cu) would be unfenced.

The barrier that remains costs -0.09% against a 0.79% resolution, so it is free.
The whole gain was the per-hash barriers; going to zero buys nothing and is
incorrect.

## The change broke the tool that proves the gate can fail

`scripts/x16rs_gate_trees.py` anchored on the barrier in its old position. Once
it moved, `replace_once` matched zero and raised before writing anything, taking
faults A and C down with B. Nobody could build any fault tree, so the gate would
still have run and we would no longer have been able to show it catches
anything.

Fixed by anchoring on the barrier line plus the macro terminator, because
x16rs.cl holds a SECOND identical barrier line earlier in the same macro and
matching on the text alone would have silently patched the wrong one. All three
faults now exit 3 again, each caught by the exhaustive byte pass on its own.

Equivalence at the shipped shapes: 1,836,108 hashes byte-for-byte at 64x256x192
and 2,164,338 at 64x256x64, zero mismatches, all 16 algorithms over 21,000 rounds
each.

cargo test --locked -p app --features ocl and --workspace both exit 0.
…oday

`x16rs::check_diamond_difficulty` is `step1(number, sha3hx) && step2(number,
x16rshx)`. Step 1 reads only the sha3 hash and can only return false, so a nonce
failing it can never mint a diamond no matter what the x16rs chain produces.

The loop paid for that chain anyway. x16rs_hash is 97.3% of an attempt (12,943 ns
of 13,304 at diamond 133,700, repeat 17), and at that number step 1 rejects 89.1%
of nonces. So nearly nine tenths of HACD's work was computing hashes whose answer
was already known.

Worse, the loop never even reached step 1: `check_diamond_hash_result` is first
in the `&&` and needs ten leading zero characters, about 5e-13, so
check_diamond_difficulty ran on 0 of 20,000 attempts.

Measured, paired, alternating in one process on identical nonce windows:

    number   repeat    old H/s     new H/s   speedup   gate pass
     41,999       6     150,351     153,966     1.03x   100.000%
     42,000       6     156,224     317,404     2.01x    49.837%
    133,700      17      59,878     475,027     7.84x    10.941%
    210,000      26      38,065     961,876    25.02x     3.123%
    300,000      37      28,533   1,632,395    55.91x     0.980%

Below 42,000 the gate passes everything and costs nothing measurable. Above it
the gain rises with the diamond number, because step 1 constrains one more byte
every 42,000 diamonds, in the same direction the repeat count grows.

## The proof is by construction, and it was written first

The gate lives in app/src/hash_util.rs beside `diamond_name_is_valid`, so x16rs/
is untouched and therefore the node is untouched and the accepted set cannot
move. Four tests:

- the implication itself, quantified over the x16rs hash rather than sampling it:
  a rejected (number, sha3) must stay rejected for twelve arbitrary x16rs hashes
  including [0;32], which is the one that clears step 2 at any difficulty
- the converse, against the real function, so a typo in either MODIFFBITS table
  cannot hide
- that the gate is EQUAL to step 1 rather than merely sufficient: with
  x16rshx = [0;32] step 2 always succeeds, so check_diamond_difficulty collapses
  to exactly step 1. This is the tripwire if x16rs/src/diamond.rs ever changes
- that `diamond_name_is_valid` really does agree with
  `check_diamond_hash_result` on diamond_hash output. That claim was inherited
  from opencl_dia.rs:149 and is only CONDITIONALLY true: the two differ on
  arbitrary bytes, and agree only because diamond_hash indexes the alphabet
  directly. Both halves are now asserted.

An adversarial pass swept 16,777,222 diamond numbers for the two hoisted terms,
145,555,275 implication checks, and 202,000 nonces for byte identity against
x16rs::mine_diamond. Zero divergence anywhere. Nine deliberate breaks of the gate
were all caught.

## The proof did not reach the code, and that nearly shipped

The same pass broke the hot loop five ways and the entire 306-test suite stayed
green: the nonce splice off by one each way, the splice deleted, a little-endian
nonce, and worst, `if !gate.passes` inverted to `if gate.passes`. That last one
computes x16rs ONLY for nonces that provably cannot mint. It runs at 8x, prints a
plausible hashrate, and finds a diamond never.

The cause: nothing called `do_diamond_group_mining`. Every test re-implemented
the loop inside itself, so they pinned the idea and not the code.

`audit_2e_the_real_mining_function_reports_a_nonce_that_reproduces_its_hash`
closes it by calling the real function and holding it to its own output: the
nonce it reports, handed back to x16rs::mine_diamond, must reproduce exactly the
dia_str it reported, and must be the best over gate-passing nonces. Verified both
ways before this commit: it passes on the tree, and with the gate polarity
inverted it FAILS with exit 101 while the older test still passes.

## What changes visibly, and it is display only

`most.dia_str`, the "best so far" console string, now reflects only the ~11% of
nonces that pass the gate. Traced: it reaches the two diamond_better comparisons,
the flush line and emit_from_batch_aggregate, and never is_success,
check_diamer_success or the submit path. Over 30 old-loop versus new-loop
windows every success decision was identical; dia_str differed in 22, and in
every one the old winner was a gate-rejected nonce that could not have been
submitted.

The reported hashrate legitimately jumps about 8x, because 8x more nonces really
are decided per second. Anyone comparing against a stored benchmark will think
something broke.

Two options measured at exactly zero and are not here: build flags
(target-cpu=native is a 1.4% LOSS) and midstate precomputation (zero of the 16
algorithms are eligible, provably).

cargo test --locked -p app --features ocl: 307 passed. --workspace and
-p miner-panel both exit 0. Nine languages, no em dashes.
…ad of build

v0.5.7 is bb59c85 and everything before it. Exactly one commit sits after that
tag: 232a881, the diamond prefilter. It skips the x16rs chain for nonces the
sha3 has already disqualified, measured at 7.84x on the current diamond number
and rising with it.

Since v0.5.7 went out, anyone who cloned the branch mined diamonds at the new
speed and anyone who downloaded the release mined them at the old one. Closing
that is the whole content of this version.

No code changed here. Cargo.toml and Cargo.lock only, and cargo metadata
--locked still exits 0, which every release build depends on because they all
pass --locked.
… is readable

Reflow, trailing commas and import ordering only. No token in any of these files
means anything different afterwards; `cargo fmt --check` was already clean on the
result before this was staged, and both suites pass either way.

It is a commit of its own because it landed in twenty-two files that the change
beside it does not touch. Mixed in, it would have buried the money-path diff in
line wrapping.
…' money

Every fix below was found by reading the code that handles money, and every one
is pinned by a test that was PROVEN to fail when the fix is reverted. Where a
test passed against the broken code, that is recorded too: it happened four
times, and each time the test was the thing that was wrong.

## What could have cost a block

A found block was submitted ONCE, and block_submit_refused returned true on any
answer it could not parse. A timeout, a proxy HTML error page and an empty body
were all reported to the operator as "that block's entire reward is lost", while
the node may never have seen the bytes; the serialized block then went out of
scope on the next line. Now three states, five attempts over 7.5s, and a refusal
that arrives AFTER an unresolved attempt is not called a loss: the likeliest
reason a node refuses a block it did not refuse a second ago is that it already
holds it.

The hold-back was released whenever a competing hash occupied our height, at ANY
depth. The confirm arm demanded sixteen blocks of burial to COUNT a block; the
release arm demanded nothing to drop it. A one-block fork, whose common fate is
to flip back, permanently deleted that block's hold-back, and the next
settlement then distributed a whole subsidy plus fees at no confirmations. Both
verdicts now wait for the same burial, with a provisional [reorg?] line so the
operator is not blind for eighty minutes.

flush_state's answer was honoured by the settlement path and thrown away by the
block path, the one place where the write carries something the pool cannot
reconstruct. A failed write now sets a sticky accounting_halt. The block is
still submitted: it is irreplaceable, and refusing would turn a bookkeeping
failure into a certain loss.

## What could have paid the wrong people

hbit-pool-payout took its ENTIRE recipient list from an unauthenticated plain
HTTP GET of {argv[1]}/stats, and read the pool's own accounting file only if
that answer parsed empty. Whatever replied on that URL chose every recipient of
the whole distributable balance, signed by the pool key. The endpoint could not
even do its job: the tool holds the exclusive settlement lock, so the server is
by construction not running.

Admission::Missing deleted the only copy of the signed payout bytes and re-owed
the rows, justified by a comment saying the node inserts before it relays. That
is false here: submit_transaction defaults async to false and handle_new_tx
broadcasts to peers BEFORE returning Ok, so ret=0 already means relayed. The
next cycle signed a different transaction for the same money, and if any peer
still held the first, both were mineable.

An owed row whose address could not be paid was allocated to off the top of
every cycle and then skipped by the chunk builder, so it was never deducted and
came back next cycle. Not a stall: a permanent tax on every honest miner, for
ever.

Pplns::restore DROPPED shares past the window while record banks what it evicts,
so a restart moved credit from the miners who earned it to the miners who
remained.

The fee reserve is subtracted once while the network fee is paid per
transaction, and nothing compared them. Re-owing the tail was the obvious fix
and is wrong: take_owed only reads the ledger, so those rows are still on it.

## What could have paid on income that never existed

balance_answer refused only a NON-ZERO ret, so a body with no ret at all fell
through to a whole-document search and {"list":[{"hacash":"999999:248"}]} became
a balance the settlement would split.

A missing block at a height the node's own tip covers was priced as zero fees.
That is not an answer, it is a node failing to produce a block it must hold, and
our block may be canonical there with its fees already in the wallet.

An unreadable state file left the pool running with zero owed, zero paid, zero
in flight, beside a wallet that may hold real money. It now refuses to start,
and that check runs before any node check because it is a local fact.

Nothing verified WHICH CHAIN the node was on. Every other check asks the node
about its own tip and confirms the answer is self-consistent, which a node on
another chain passes effortlessly. Identity now comes from block 1, whose
prevhash is the genesis by construction.

## What was exposed

Verifying a submission is deliberately slow and nothing bounded how many ran at
once: a connection is a thread, MAX_CONNS is 1024. The gate makes a submission
wait, never refuses it (any of them may be a block) and gives one source a
quarter, because MAX_PER_IP alone would let one host take every permit.

Moving the halt or the per-worker budget ahead of the hash, which the obvious
reading suggests, is worse: the worker is free to FORGE and /stats publishes the
victim list, so 64 cheap GETs would shed a stranger's next submission.

/stats built its whole credit table under the global pool mutex on every
request. Now cached for two seconds, which bounds a flood to one computation.

The miner pasted connect inside format!("http://{}"), so https:// produced
http://https://... and plaintext was not a choice an operator could decline.

## Deployment, which nobody could run

The systemd section installed binaries into /opt/hbit while both units execute
/opt/hbit/bin; built fullnode while the node unit runs hacash; never created the
config the node unit passes as its only argument; created the passphrase file
0400 and then wrote to it; and enabled ufw with no SSH rule. The Docker node
bound 0.0.0.0 with no api_token, and the node REFUSES to serve on a non-loopback
bind without one: it prints a line and returns while the process keeps syncing,
so the healthcheck never passed and the pool never started.

Five tests now hold the README and the unit files to each other, because nothing
did and none of it is catchable by compiling.

## Measured against the live chain

The pool ran 55 minutes against a real mainnet node at height 771598 to 771611:
20,480 shares accepted, zero rejected, zero duplicate, zero invalid, and the
pool answered every one of twenty samples. Two independent measurements agreed
within 6%: the miner counted 27.3 MH/s of hashing, and the pool's share rate
times the share difficulty implied 25.7.

That run found two things no test had. The identity check asked for block 0,
which this node does not serve at all (?height=0 cannot be told from "no height
given"), so a correct mainnet node was REFUSED, and seven passing tests had
agreed with each other about a shape that does not exist. And the startup
staleness bound of 1800s fired on a healthy node: measured over 200 real blocks
the median gap is 212s but one in 200 exceeds 1800s, so it is now 3600s, where
none of those 200 gaps reach.

## What this does NOT do

Frozen block entitlements are RECORDED, not paid from. A block's reward is still
split over whoever holds credit at settlement time, roughly eighty minutes after
it was found. The pool now writes down who earned each block, durably and at the
instant of discovery, and reports what the two models would pay when they
differ, so the change that moves money between people can be watched on real
blocks before it decides anything.

No block was found during the live run, so block discovery through hold-back,
maturity and payout has still never executed against the real chain. That is the
one part of the money path this cannot claim.

cargo test: 165 hbit-pool, 305 app, 0 failing. cargo clippy --no-deps -p
hbit-pool --all-targets -D warnings: clean, and now in CI, where hbit-pool was
never built before this.
…ric miner is generic

A miner is not built for this pool. An unmodified poworker points at fullnodes,
at this pool and at other people's pools, and it has to behave the same way at
all of them. The only settings a pool is entitled to change are where to connect
and which address to credit.

/query/miner/notice broke that. The miner asks with the height it is MINING (the
tip plus one) and reads "answer >= that" as new work existing. mint's own
miner_notice returns latest_block().height(), the TIP, so the comparison is
false until a block really arrives and poworker takes its 200ms anti-spin floor.
This pool returned tpl.height, the height being mined, which made the comparison
true on EVERY reply.

The floor was therefore skipped every time. It costs nothing while the endpoint
parks, because 45 seconds pass anyway - but the endpoint stops parking once
MAX_NOTICE_WAITERS long-polls are already held, and answers immediately instead.
So every rig went to two requests per cycle with no delay at exactly the moment
the pool was already shedding load.

The parking condition is unchanged and was always right: waiting for
tpl.height > want is waiting for the tip to reach want. Only the number reported
moves, through one function both reply paths now use.

Found by reading the miner against the node, prompted by the plain observation
that the miner is not an HBIT component: it is the same binary a solo miner runs
against their own node, and a pool that needs it configured differently is the
thing that is wrong.

cargo test: 166 hbit-pool, 0 failing. The test states the miner's own condition
verbatim and fails when the reply goes back to the template height.
…e miner

The miner is a Windows desktop program an individual runs on their gaming PC.
The pool is Linux server software an operator runs on a VPS beside their own
full node, holding other people's money. They are different products for
different people on different rhythms, and until now they shipped as one
release: every pool fix waited for a miner release, and every miner release
republished the pool under a version number that meant nothing about it.

The miner also is not an HBIT component. It is the same binary a solo miner runs
against their own node, and it points at other people's pools too. A pool is
entitled to change where a miner connects and which address it credits, and
nothing else.

## What changes

`pool-v*` tags now build and publish the pool through release-pool.yml. `v*`
tags keep building and publishing the miner exactly as before, and can no longer
publish a pool archive at all. The two patterns cannot collide: a ref beginning
`pool-` never matches a pattern beginning `v`.

release.yml still BUILDS and TESTS the pool, as a gate. Dropping that would let
a miner change break the crate that signs payouts and nobody would know until a
pool tag was cut.

hbit-pool is 0.2.0, not 0.1.1. The accounting file gained a schema, a found
block now records who earned it, and several payout rules changed. Nothing an
older build wrote became unreadable, but what this build MEANS by that file is
different, and the number an operator reads in the startup banner should say so.

## What the new workflow checks that the old one did not

The pool archive is verified before it is published, rather than discovered on
somebody's VPS: every binary present and executable, every document the operator
guide references actually in the package, the shipped node config carrying an
EMPTY reward, and no wallet material of any kind. Both binaries are run with
--help, so an archive that cannot start is not a release.

Provenance is attested before the release is created, so a package that cannot
be signed never reaches an operator.

## The download page

Moskyera.github.io took all three buttons from one release of this repository.
It now looks the pool up by its own `pool-v` tag prefix, and the miner lookup
skips pool releases so one landing most recently cannot become the miner
download. That change lives in the Moskyera.github.io repository and is not in
this commit.

cargo test: 166 hbit-pool, 0 failing. cargo clippy --no-deps -D warnings clean.
Both workflow files parse as YAML and declare the jobs they intend to run.
github.com/<repo>/releases/latest can only point at one release, and it is where
a visitor expects the miner and where the download page's fallback links send
anyone whose API request failed. The first pool tag took it, so for a few
minutes 'Download Full package' could have landed a miner on Linux server
software.

Also derives the release title from the tag rather than interpolating the whole
ref, which had produced 'HBIT pool pool-v0.2.0'.

Both were found by looking at the published result instead of trusting the run
that said success.
…knows

Asked whether selecting HBIT needs the miner configured differently. It does
not, and it must not: a pool is entitled to change where a miner connects and
which address it credits, nothing else. But the pool was not holding up its end.

While halt_reason() is set the pool credits no new share. It kept serving the
cached template anyway, so every rig kept hashing for nothing. The miner could
not tell: the submit path answers {"ret":1,"kind":"degraded"} with the reason
stripped, and pool_kind_verdict has no arm for that kind at all, so it falls
through as an unknown reply.

The obvious fix was to teach the miner a degraded verdict. That is worse. It
would only help miners built after it, and every rig already downloaded would go
on burning power.

poworker ALREADY knows how to stop. upstream_stale_reason reads an err
containing "stale" from /query/miner/pending and /query/miner/notice and pauses
the mining threads until work returns. So the pool now answers both that way
while halted, and every already-released miner does the right thing with no
update and no per-pool setting.

Pausing costs nothing that was not already lost: during an accounting halt the
pool cannot record who earned a block, during a node halt the tip is dead, and
during a difficulty halt credit means nothing.

The submit path also stops throwing the reason away, so an operator reading a
rig's log sees which halt rather than a bare kind.

The test quotes poworker's detector verbatim and fails two ways: when a halted
pool serves work again, and when the reason stops carrying the word the miner
keys on. 167 tests, clippy clean.
… out there

A patch, not a minor: no file format and no payout rule moved. What changed is
that a halted pool now says so on /query/miner/pending and /query/miner/notice,
in the words poworker's existing pause already keys on, so a rig downloaded
months ago stops hashing for credit that will never come.

That is the whole reason to ship it separately. It fixes nothing about the
miner, it needs no miner update, and an operator running the pool should not
wait for one.
…othing

Each one was found by reading the node's own source rather than the pool's, and
each is proven by reverting the fix and watching its test go red.

A refusal is not automatically "I have never heard of that hash". The node's
/query/transaction answers ret=1 in four places, and TWO of them are reached
only after state.tx_exist has already found the transaction on chain: the block
behind it would not load, or the decoded block did not carry it. Both said the
payout IS mined. Read as Gone they ran GoneAction::Forget, handed the rows back
to the owed ledger, and the next cycle paid those miners again out of the
operator's own wallet. Only the exact absence answer is Gone now; every other
refusal, and any wording this pool has never seen, is Unknown, so the hash stays
tracked and the cycle is skipped. That costs a delay instead of somebody's
money, which is the only direction this may fail in. Matched whole, never by
prefix: "transaction not found in the block" opens with the same three words and
means the opposite.

hbit-pool-payout passed a literal 0 where the server passes POOL_FEE_UNITS. That
agreed only for as long as the constant stayed 0. Set a fee and the two settlers
divided the same pot differently, so which one an operator happened to run
decided what every miner was paid, and the manual one would have handed out
money /terms says the pool keeps. Its dry run now names the fee as well: that
screen is the only thing read before real money moves.

Banked credit past BANK_WORKERS_MAX was dropped in silence, and on restore it
was dropped by worker id, because banked_snapshot writes the rows sorted. The
alphabetically-last miners lost their banked credit on every restart and it was
split over everyone else. The cap stays, since it is the only thing between that
map and a flood of invented addresses, but it now sheds the SMALLEST credit and
counts what it shed. restore's bucket merge had no bound of its own, so a file
this build wrote could itself exceed the cap; that is closed too. The total is
published as credit_refused_ms, so a settlement that paid somebody short leaves
a mark instead of none.

The per-worker rate limiter fails OPEN once its table is full of active ids, and
that stays: refusing an honest miner's share costs it real money and the
residence weighting decides the split anyway. What was not acceptable was doing
it silently, because while it lasts the one thing holding back a batch of
withheld shares is not running. It is counted with one integer add under the
lock already held, and the operator line is composed on the template cycle
instead of the share path.

A same-height reorg replaces the parent and leaves the height alone, so the tip
test a generic miner reads could never fire for it. Every rig parked in the
notice long-poll went on hashing a header built on an abandoned block until the
poll timed out, and every share it found was rebuilt against the NEW parent,
came out above target, was refused, and counted against the rig as a bad streak:
marked bad for doing what this pool told it. The poll now parks on the job the
miner itself uses, height AND parent, and the reply still reports the tip, so an
unmodified miner is told nothing new and simply re-reads its work. Three
comments and one operator diagnostic that stated the old limitation are
corrected with it.

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

A sentence in a markdown file is the one part of this pool the compiler never
reads, and both of these ship inside the release archive an operator installs
from. Both now have a test.

POOL-OPERATOR.md said a node that is still syncing is refused. There is no such
check and there cannot be one from here: the node's /query/latest answers with a
height and a diamond number, and nothing else. An operator who believed it
stopped watching the node's own sync, and this deployment's known failure is
precisely a history sync that finishes short of the tip and then ignores live
blocks. What the pool really does was undocumented in that file: the mainnet
genesis check read from block 1's prevhash, the 3600 second tip bound at
startup, the 7200 second bound while running, and the halts, none of which had a
row in the troubleshooting table either. It now states all of them, says plainly
what it cannot detect and why, and points at hbit-v2/MAINNET-SAFETY.md rather
than repeating it.

hbit-vps-setup.sh refused fast_sync = true because it "builds a chain that
cannot be extended". This repository withdrew that claim in 2532814, after a
controlled sync with the flag on reached the tip with no errors at all, and it
corrected the config while missing the script. So the archive shipped a
retracted claim beside its own retraction. The refusal is right and stays hard,
for the reason that survives reading the node: chain/src/insert.rs runs the
minter block gate only when fast_sync is off, and mint/src/check/block_accept.rs
is the only place a synced block's difficulty and PoW hash are ever checked. A
node synced with it on took a peer's whole history on trust, and this pool would
pay real HAC for work measured against it. Block bodies and the tx_exist index
are written either way, so nothing looks wrong while it happens, which is
exactly why a warning would not do.

The packaging copied docs/hbit-v2 as docs-hbit-v2, so the link this commit adds
to the runbook would have been dead for every operator reading the shipped copy
while working in the repository. It keeps its name now, and the archive check
fails if the file is not there.

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

Nothing here is about one pool. The miner reads the difficulty out of the header
it is already hashing, which every source of work has to send correctly because
it is the same bytes the node checks.

HAC/day divided the measured hashrate by the target the RESULT was measured
against. Pooled, that is the pool's share target, easier than a block by
2^share_bits, so the ratio hit its 1.0 clamp: every pooled rig read as 100% of
the network, claimed a full block reward every block on screen and in the panel,
and pause_if_unprofitable could not fire however much power it was burning. This
file already knew, and says so at network_hac_per_hps_day: a share target fed in
there values a hash at hundreds of times what it is worth. The autotuner obeyed
that; the mining line did not.

The fix is the header's own difficulty field, carried on the result so the
hashrate, the height and the difficulty all come from one snapshot. A pool
cannot quietly shrink it: block_intro is serialized straight into the 89 bytes
the miner hashes, with only the nonce overwritten, and a block whose difficulty
is not the value the node recomputes is rejected. So this reads correctly solo,
through the relay, on HBIT and on any pool speaking the same miner API.

That change is what made the second defect reachable, and it is the worse one.
The profitability pause was a ONE-WAY LATCH. paused_unprofitable is cleared in
exactly one place, and that line sits past the empty-drain return, while the
pause itself stops the very workers whose results are the only thing that can
reach it. A rig paused on a low HAC price, or on one drain tick that happened to
measure nothing, never mined again for the life of the process, and its operator
was told only that cost exceeded revenue. Before this commit a pooled rig could
not get there, because the clamp kept its revenue at 288 HAC a day. It can now,
so the pause expires after a minute and the rig takes one fresh measurement. If
it really is unprofitable the next tick pauses it again, which costs seconds of
power rather than a day of income. The stamp is per worker, so the HACD side's
pause is not this side's to lift on a timer.

A header claiming difficulty 0 is one no node accepts, so nothing can price it.
That is now said once a minute rather than computed as a revenue of zero, and no
profitability decision is taken either way, since pausing a rig over a header
the pool got wrong would cost its operator everything for a fault that is not
theirs. It does NOT skip the rest: winners are still queued and submitted. A
block is irreplaceable, the node decides what it accepts, and a template this
miner cannot PRICE is still one it may have just won on.

Operators will see HAC/day fall sharply on a pooled rig. Nothing broke. It was
reading roughly 2^share_bits too high.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reason to take this one is the payout classifier. A node refusal reached
with the transaction already in its chain state was read as "that payout is
lost", and its rows went back on the owed ledger for money that was mined.

It also stops losing banked credit by worker id on restart, stops the
per-worker rate limiter failing open in silence, and releases a rig parked on a
same-height reorg instead of leaving it hashing an abandoned parent.

The accounting file is unchanged in both directions, so 0.2.1 and 0.2.2 read
each other's state and an operator can move either way. Only /stats gains a
field, credit_refused_ms.

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

Both of these have been on the branch since the pool work and neither reached
anyone, because the miner and the pool now ship on separate tags and only the
pool has been tagged since. That is the split working as intended, but it also
means a miner fix has to be tagged deliberately, and this is that.

Earnings were divided by the target the RESULT was measured against. At a pool
that is the share target, easier than a block by 2^share_bits, so the ratio hit
its clamp: every pooled rig read as 100% of the network, claimed a full block
reward every block on screen and in the panel, and pause_if_unprofitable could
not fire however much power it was burning. It now reads the difficulty out of
the header it is already hashing. That is not a courtesy field: block_intro goes
straight into the 89 bytes the miner hashes with only the nonce overwritten, and
a block whose difficulty is not what the node recomputes is rejected. So it is
right solo, through the relay, on HBIT, and on any pool speaking the same miner
API, without the miner knowing which one it is talking to.

That change is what made the profitability pause reachable for pooled rigs, and
the pause was a one-way latch: it stopped the very workers whose results are the
only thing that clears it. It now expires after a minute so the rig takes a
fresh measurement, and pauses again immediately if it really is unprofitable.

`connect = https://host` was pasted into `http://{}`, which produced
`http://https://host`. A pool reached over TLS could not be reached at all, and
plaintext was not something an operator could decline.

Operators will see HAC/day fall sharply on a pooled rig. Nothing broke; it was
reading roughly 2^share_bits too high.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With no name the action falls back to the bare tag, which was fine while this
repository published one thing. It publishes two now, and the releases list read

  v0.5.9            <- the miner, and nothing said so
  HBIT pool v0.2.2

so the only entry a visitor could identify was the one that is NOT the download
most of them want. The wording matches the heading the release body already
carries, and the pool workflow already does the same for its own tags.

v0.5.9 itself is retitled in place; its artifacts and attestations are untouched.

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

Adds the HVM contract sources and the node-side surfaces the HPAY wallet and
Fast Pay Hub read: hpay_channel_exit, hpay_channel_registry and
hpay_contract_deployment, plus their tests.

channel_unilateral_exit stays a hardcoded false in node_api.rs, deliberately.
A verified deployed artifact is necessary but not sufficient: the wallet, Hub,
bill codec, funding path and watchtower must first bind every channel to this
contract profile. The evidence block is honest about the deployment it can see
and refuses to advertise a native capability it cannot yet stand behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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