channels_sv2: enforce share nTime bounds, bound seen_shares, and fix job-ID/chain-tip/coinbase validation gaps (plus template_distribution_sv2 docs fix)) - #2303
Open
plebhash wants to merge 7 commits into
Conversation
plebhash
force-pushed
the
2026-08-17-channels-sv2-hardening
branch
4 times, most recently
from
August 18, 2026 15:16
ba027e4 to
0f52489
Compare
channels_sv2: enforce share nTime bounds, bound seen_shares, and fix job-ID/chain-tip/coinbase validation gapschannels_sv2: enforce share nTime bounds, bound seen_shares, and fix job-ID/chain-tip/coinbase validation gaps (plus template_distribution_sv2 docs fix))
plebhash
force-pushed
the
2026-08-17-channels-sv2-hardening
branch
2 times, most recently
from
August 18, 2026 17:57
cd3ccb6 to
0a508c6
Compare
…m_path A syntactically valid transaction whose input spends a non-null outpoint was accepted as a coinbase and yielded a Merkle root, letting a peer supply an ordinary transaction as coinbase prefix/suffix and have miners work on a job whose first transaction cannot be a valid block coinbase. Enforce the coinbase invariant (exactly one input spending the null outpoint) via Transaction::is_coinbase before deriving the txid. All five callers already handle the None return. Closes stratum-mining#2300
next() incremented with unchecked arithmetic, so its overflow behavior depended on build flags: after u32::MAX allocations it would panic with overflow checks enabled (terminating the embedding server task) or wrap silently in ordinary release builds. Make the wrap explicit and documented: IDs restart from 0 after u32::MAX, identically across build profiles. Reusing an ID after 2^32 allocations is safe because job stores only retain recent jobs (future jobs are consumed on activation, past/stale jobs are flushed on every chain-tip transition), so no store can still track the previous holder of a wrapped ID by the time it comes around again. Closes stratum-mining#2302
… ordering The rustdoc omitted the Template Distribution Protocol's requirement that a Template Provider send at least one future NewTemplate before every SetNewPrevHash, and contradicted itself on how binding template_id is: the struct doc said it *should* be set *if* a future template had previously been sent, while the field doc said it *must* be identical to a previously sent NewTemplate. The conditional reading makes a SetNewPrevHash with no preceding future template look like a normal flow. It is not: spec section 7.3 puts the ordering requirement on the upstream, so the absence of a queued future template signals a non-conforming peer. State the precondition, describe what template_id selects among the queued future templates, and note the direction of the requirement so the conditional phrasing cannot be read as making template_id optional. Docs only, no API or behavior change. Related to stratum-mining#2301.
The Template Distribution Protocol requires a Template Provider to send at least one future NewTemplate before every SetNewPrevHash, so a SetNewPrevHash arriving with no future job queued means the peer broke the protocol. Group and standard server channels responded by returning TemplateIdNotFound and never recording the new chain tip, which leaves the channel worse off than the violation itself: the chain-tip fields are self-contained and do not depend on template_id resolving, so discarding them buys nothing. The two resulting states are both unrecoverable. On an established channel the tip stays on the previous block, so every later non-future NewTemplate builds jobs committed to a dead prev_hash and validate_share keeps crediting work that can never form a block. On a channel that never received a tip, the next non-future NewTemplate fails with ChainTipNotSet. TemplateIdNotFound also carries no error code, so the documented recommendation of shutdown applies and an embedding pool may terminate a shared process over one peer's misbehavior. Mirror the extended channel, which already recovers here: with no future jobs queued, standard demotes the active job, marks past jobs stale, clears the target mappings and seen shares, and updates the chain tip; group drops the active job outright (it never validates shares against stored jobs, so routing it through the stale set would only pin its retired extranonce prefix for an extra tip) and updates the chain tip. TemplateIdNotFound remains for the case where future jobs exist but none matches. Closes stratum-mining#2301
…tion Client channels validated share nTime only against the chain tip's min_ntime, but an immediately-active job carries its own min_ntime, which may be later than the tip's minimum. A share in the gap (chain_tip.min_ntime <= ntime < job.min_ntime) was hashed and returned as Valid/BlockFound, letting a proxy/JDC forward out-of-window work upstream or surface a bogus block candidate. Enforce the per-job bound alongside the chain-tip bound on both standard and extended client channels; for jobs activated from the future queue the two are equal, so the effective bound is max(tip, job) either way. Also repairs the coinbase_tx_suffix test vector in test_share_validation_ntime_below_min_ntime, which carried one extra zero byte: the corrupt suffix made merkle-root computation fail, so the test passed through the same Invalid error for the wrong reason. Closes stratum-mining#2298
…nnel types) No validate_share implementation enforced any upper bound on share nTime: values as large as u32::MAX were hashed and returned as Valid/BlockFound whenever they met the target, corrupting block accounting with headers bitcoind rejects (consensus caps block timestamps at ~2h in the future). Introduce MAX_FUTURE_BLOCK_TIME (mirroring Bitcoin Core's constant) and reject shares with ntime > chain_tip.min_ntime() + MAX_FUTURE_BLOCK_TIME (saturating) on all four channel types. The bound is anchored at chain-tip receipt since the crate is no_std and has no clock: it is deliberately looser than the spec's elapsed-time window but strictly enforceable; embedding applications with a time source can additionally enforce the spec-exact window. Rationale and edge cases are documented on the constant. Three server-standard tests carried pre-mined shares whose ntime sat ~4h past the tip; their chain tips are re-anchored within the consensus window (the header hash does not depend on the tip timestamp, so the pre-mined vectors stay valid). Closes stratum-mining#2299
seen_shares inserted every distinct accepted share hash and was only cleared on chain-tip transitions, whose timing the peer controls (a zero-hashrate channel at maximum target makes essentially every unique header pass validation), so memory grew linearly at message speed for the lifetime of a tip on both server and client channels. A plain evict-oldest cap is not safe on the server: evicting a hash re-admits its duplicate, re-enabling the replay/accounting fraud the set exists to prevent. The two sides therefore get different bounds: - server: a budget derived from the channel's pool-configured expected_share_per_minute (x WORST_CASE_TIP_MINUTES x SEEN_SHARES_MARGIN, clamped into [MIN_SEEN_SHARES_CAP, MAX_SEEN_SHARES_CAP] so absurd configuration can neither disable dedup nor unbound memory). The budget is a hard limit: once full, validate_share returns the new ShareValidationError::SeenSharesBudgetExhausted. It carries no error code because no SubmitShares.Error code fits, so per that enum's contract the embedding application should disconnect the client -- closing the abusing channel, not the process. Deriving the budget is worthwhile here because the limit is load-bearing: it must stay unreachable for any honestly configured rate, and a flat cap that is safe at 1 share/min is reachable within an hour of tip lifetime at 100 shares/min. - client: a flat MAX_SEEN_SHARES of 4096 hashes (128 KB) with oldest-first eviction, held in a single VecDeque. The bound has to be affordable on the smallest supported device, since the client module is documented as no_std/embedded-capable and an adversarial upstream advertising a trivial target can drive the cache to its bound at message speed. A client cache also does not need a whole tip's worth of history: it catches a share source re-sending work it already sent, which arrives within seconds, so 4096 covers ~11h of a typical 6 shares/min channel. No derivation from target/hashrate: those inputs are upstream-controlled, so a derived bound cannot constrain a hostile upstream, and eviction is locally harmless (a replayed evicted hash double-counts one local statistic; nothing is paid out and nothing reaches upstream that it won't independently dedup). Closes stratum-mining#2297
plebhash
force-pushed
the
2026-08-17-channels-sv2-hardening
branch
from
August 18, 2026 18:29
0a508c6 to
4bbde98
Compare
plebhash
marked this pull request as ready for review
August 18, 2026 18:32
Member
Author
|
ready for review |
channels_sv2: enforce share nTime bounds, bound seen_shares, and fix job-ID/chain-tip/coinbase validation gaps (plus template_distribution_sv2 docs fix))channels_sv2: enforce share nTime bounds, bound seen_shares, and fix job-ID/chain-tip/coinbase validation gaps (plus template_distribution_sv2 docs fix))
Member
|
clankers findings:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
close #2297
close #2298
close #2299
close #2300
close #2301
close #2302
close https://github.com/project-loupe/audit-stratum/issues/130
close https://github.com/project-loupe/audit-stratum/issues/132
close https://github.com/project-loupe/audit-stratum/issues/118
close https://github.com/project-loupe/audit-stratum/issues/122
close https://github.com/project-loupe/audit-stratum/issues/124
close https://github.com/project-loupe/audit-stratum/issues/123
close https://github.com/project-loupe/audit-stratum/issues/125
close https://github.com/project-loupe/audit-stratum/issues/128
close https://github.com/project-loupe/audit-stratum/issues/131
companion stratum-mining/sv2-apps#733