Skip to content

Reject non-finite input in hash_rate_to_target#2221

Open
gimballock wants to merge 1 commit into
stratum-mining:mainfrom
marafoundation:fix/hash-rate-to-target-reject-non-finite
Open

Reject non-finite input in hash_rate_to_target#2221
gimballock wants to merge 1 commit into
stratum-mining:mainfrom
marafoundation:fix/hash-rate-to-target-reject-non-finite

Conversation

@gimballock

Copy link
Copy Markdown

Reject non-finite input in hash_rate_to_target

Summary

channels_sv2::target::hash_rate_to_target accepts non-finite arguments
(NaN, ±infinity) and silently produces a garbage target instead of
returning an error. For a +inf hashrate the garbage target is the hardest
the function can emit
— it drives difficulty to the ceiling. This PR rejects
non-finite input up front with a dedicated error variant. It is a pure
input-validation fix: no behavioral change to any caller's control path.

The bug, and why +inf is the dangerous one

The function guards only three cases today:

if share_per_min == 0.0 { return Err(DivisionByZero); }
if share_per_min.is_sign_negative() { return Err(NegativeInput); }
if hashrate.is_sign_negative() { return Err(NegativeInput); }

None of these screen for finiteness, and the intermediate cast saturates
silently:

let h_times_s = hashrate * shares_occurrency_frequence;
let h_times_s = h_times_s as u128;   // NaN -> 0, +inf -> u128::MAX

So a non-finite argument slips past every guard and reaches the target
arithmetic. The three non-finite hashrate inputs resolve as:

Input as u128 Resulting target Difficulty
0.0 (already accepted) 0 ≈ max target easiest
NaN 0 ≈ max target easiest
+inf u128::MAX 0 hardest

The easy cases (NaN, 0.0) are self-correcting — an over-easy target makes
the miner over-produce shares and the controller tightens back up. The +inf
case is not.
It silently emits the maximally over-difficult target — the
over-difficulty direction — from a converter whose contract is to reject bad
input. A miner whose telemetry reports +inf hashrate (a divide-by-zero in a
hashrate estimate, an overflow, an uninitialized sensor) would be handed the
tightest possible difficulty with no error raised anywhere. That is the failure
worth closing at the source: a single non-finite input driving difficulty to the
ceiling.

The fix

Reject non-finite hashrate and share_per_min before any arithmetic, with
a dedicated variant:

if !hashrate.is_finite() || !share_per_min.is_finite() {
    return Err(HashRateToTargetError::NonFiniteInput);
}

Two details that matter:

  • Both operands are screened. A non-finite share_per_min reaches the same
    saturating cast path (60.0 / share_per_min then into the work term), so
    screening only the hashrate would leave the symmetric hole open.
  • The check is ordered first, and the order is load-bearing. A NaN
    compares false to 0.0 and is sign-positive, so it would fall through the
    existing == 0.0 and is_sign_negative() checks to the cast; a -inf is
    sign-negative and would be mis-reported as NegativeInput. Putting the finite
    screen ahead of the others is what makes the rejection correct and the error
    variant accurate. A test pins this ordering.

A new HashRateToTargetError::NonFiniteInput variant (rather than overloading
NegativeInput) keeps the fault honest for callers: "you passed NaN" is a
different error than "you passed a negative number."

Tests

6 unit tests in target.rs (the function had no test module previously):

  • non_finite_hashrate_is_rejectedNaN/+inf/-inf hashrate → NonFiniteInput
  • non_finite_share_per_min_is_rejected — same for share_per_min (both operands)
  • neg_infinity_is_non_finite_not_negative — the load-bearing ordering: -inf
    and a NaN share_per_min are reported NonFiniteInput, not NegativeInput
    / DivisionByZero
  • positive_infinity_hashrate_does_not_yield_a_target — the headline case: a
    +inf hashrate no longer produces the hardest-difficulty target
  • finite_input_still_converts / preexisting_finite_guards_unchanged
    regression guards: ordinary finite conversion still works, zero hashrate is
    still accepted, and the pre-existing NegativeInput / DivisionByZero guards
    are unchanged

Scope

  • One file: sv2/channels-sv2/src/target.rs (+~20 lines of fix, the rest
    tests). No other crate consumes HashRateToTargetError by exhaustive match,
    so the added variant is non-breaking.
  • Input validation only — no behavioral/policy change. This does not alter
    how any controller responds to a valid hint; it rejects inputs that were
    never valid. (Hint-handling policy — how a consumer should react to a
    difficulty revision — is a separate concern and deliberately out of scope here.)

hash_rate_to_target guards only against zero/negative share_per_min and
negative hashrate. It does NOT screen for non-finite values, and the
intermediate `h * s as u128` cast saturates silently: `NaN as u128` is 0
and `f64::INFINITY as u128` is u128::MAX. So a NaN or infinite argument
slips past every existing guard and yields a garbage target with no error.

The +inf case is the dangerous one. A +inf hashrate casts to the maximum
possible work, which collapses the target toward zero — the HARDEST
difficulty the function can emit. So a single non-finite hashrate silently
produces a maximally over-difficult target (the over-difficulty direction),
from a converter whose contract is to reject bad input. NaN and 0.0 produce
the easiest target instead, but the asymmetry is the point: one non-finite
input drives difficulty to the ceiling.

Reject non-finite hashrate AND share_per_min up front, with a dedicated
HashRateToTargetError::NonFiniteInput variant. The check is ordered FIRST,
ahead of the zero/negative checks: a NaN compares false to 0.0 and is
sign-positive, and -inf is sign-negative, so without the leading finite
screen they would fall through to DivisionByZero / NegativeInput / the cast.
The ordering is the soundness property and is pinned by a test.

Pure robustness fix — input validation only, no behavioral change to any
caller's control path. 6 unit tests: each non-finite value on each operand,
the load-bearing ordering (-inf and NaN screened as NonFiniteInput, not
Negative/DivisionByZero), the +inf-yields-no-target headline, and that the
pre-existing finite guards and valid conversions are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gimballock pushed a commit to marafoundation/stratum that referenced this pull request Jul 1, 2026
…ning#2221 class)

Records the bounded result of a parallel source-audit of channels_sv2's
public input surface for the stratum-mining#2221 bug class (functions accepting
non-finite/out-of-range numeric input that produce garbage/dangerous
control values). Confirmed independent findings: A (vardiff new_with_min
NaN-disables-clamp) + B (try_vardiff divisor finiteness), both fixed on
branch fix/vardiff-reject-non-finite-hashrate; C (server set_nominal_hashrate
unvalidated store) as a defense-in-depth note. Six garbage-target caller
sites are the blast radius of stratum-mining#2221 (closed on its merge), not new findings.
Cleared the false suspects (hash_rate_from_target, client setters,
share-accounting sums) — the cleared set outnumbers the confirmed.

Sequencing decided: let stratum-mining#2221 merge first, prepare A+B ready-but-unopened,
then let maintainers' convention choose the tracking construct.

Co-Authored-By: Claude Opus 4.8 (1M context) <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