Two distinct defects, one of which is only visible because of the other. Found with real ASIC hashrate against a third-party production SV2 pool that advertises version_rolling_allowed: false.
Net effect: 100% of shares are discarded inside the tProxy, while the SV1 miner is told every share was accepted.
Defect 1 (root cause) — offered version-rolling mask ignores upstream capability
miner-apps/translator/src/lib/sv1/sv1_server/downstream_message_handler.rs:39-41 answers mining.configure by granting the SV1 downstream:
data.version_rolling_mask = request
.version_rolling_mask()
.map(|mask| HexU32Be(mask & 0x1FFFE000));
This never consults whether the upstream pool permits version rolling. When the upstream sends NewExtendedMiningJob { version_rolling_allowed: false }, the miner has already been authorised to roll 0x1FFFE000, and share validation rejects exactly those bits — channels-sv2/src/client/extended.rs:590-598:
if !job.0.version_rolling_allowed {
if (share.version & 0x1fffe000) != 0 {
return Err(ShareValidationError::VersionRollingNotAllowed(...));
}
}
The granted mask is the rejected bit range, bit for bit, so the failure rate is 100% rather than probabilistic.
Defect 2 — the drop site logs nothing
miner-apps/translator/src/lib/sv2/channel_manager/mod.rs:604 (aggregated) and :647 (non-aggregated) discard every ShareValidationError through a bare else:
let value = self.extended_channels.get_mut(&m.channel_id)
.map(|mut extended_channel| extended_channel.validate_share(m.clone()));
if let Some(Ok(_result)) = value {
info!("SubmitSharesExtended: valid share, forwarding it to upstream ...");
...
} else {
return Ok(()); // <-- Err(_) and None both land here, unlogged at any level
}
Some(Err(_)) (validation failed) and None (no such channel) are indistinguishable, and neither is reported. Meanwhile the SV1 path has already responded { result: true } to the miner, so from the miner's perspective every share was accepted.
Notably, the other roles do handle this error explicitly — pool-apps/pool/src/lib/channel_manager/mining_message_handler.rs matches Err(ShareValidationError::VersionRollingNotAllowed(code)), and jd-client does the same in two places. The translator is the only role that discards share-validation errors silently.
Reproduction
SV1 ASIC → tProxy (non-aggregated) → SV2 pool advertising version_rolling_allowed: false on extended jobs.
Adding a diagnostic at the drop site (patch below) produces, for every submit:
ERROR translator_sv2::sv2::channel_manager: SubmitSharesExtended: share REJECTED by
local validation (non-aggregated) | channel_id: 1,
error: VersionRollingNotAllowed("version-rolling-not-allowed")
Measured, same binary and same config file, changing only upstream_address:
|
pool with version_rolling_allowed: false |
pool with true |
mining.submit received |
1028 |
480 |
| forwarded upstream |
0 |
138 |
VersionRollingNotAllowed |
1028 (100%) |
0 |
DoesNotMeetTarget |
0 |
342 |
The negotiated mask logged identically in both runs (Negotiated version_rolling_mask: Some(HexU32Be(536862720)) = 0x1FFFE000), so the miner's behaviour is constant; the only variable is the upstream's version_rolling_allowed. The DoesNotMeetTarget rejections in the right-hand column are expected in that setup (a rate-shaping proxy sits downstream) and are shown only to demonstrate the diagnostic reports real rejections rather than manufacturing them.
Versions
Both defects present on main as of this writing — sv2-apps 44af69e5, stratum c1a79913.
Possibly related
#402 includes a report of a very high share-rejection rate against Braiins Pool via non-aggregated tProxy. That may share this root cause, but it is not the same observation: those shares reached the pool and were rejected there, whereas these never leave the translator. Flagging in case the two turn out to be connected.
Suggested direction
- Mask negotiation — clamp the offered mask to
0 when the upstream forbids version rolling. This is awkward as written, because mining.configure must be answered before the first NewExtendedMiningJob reveals the upstream's capability. Options include deferring the mining.configure reply until the first job is known, or issuing mining.set_version_mask to revise the grant once capability is learned.
- Diagnostics — log every
ShareValidationError at both drop sites, distinguishing Some(Err(_)) from None. Minimal patch that produced the evidence above:
} else {
match value {
Some(Err(ref e)) => error!(
"SubmitSharesExtended: share REJECTED by local validation (non-aggregated) | channel_id: {}, error: {:?}",
m.channel_id, e
),
None => error!(
"SubmitSharesExtended: channel not found for local validation (non-aggregated) | channel_id: {}",
m.channel_id
),
Some(Ok(_)) => {}
}
return Ok(());
}
- Worth considering separately: whether the SV1
{ result: true } should be deferred until the share is actually accepted upstream. Answering true for a share that is about to be discarded locally makes this class of bug invisible from the miner side, and corrupts any hashrate accounting derived from SV1 accept counts.
Happy to open a PR for (2), and for (1) if there's a preferred direction on where the capability check should live.
Two distinct defects, one of which is only visible because of the other. Found with real ASIC hashrate against a third-party production SV2 pool that advertises
version_rolling_allowed: false.Net effect: 100% of shares are discarded inside the tProxy, while the SV1 miner is told every share was accepted.
Defect 1 (root cause) — offered version-rolling mask ignores upstream capability
miner-apps/translator/src/lib/sv1/sv1_server/downstream_message_handler.rs:39-41answersmining.configureby granting the SV1 downstream:This never consults whether the upstream pool permits version rolling. When the upstream sends
NewExtendedMiningJob { version_rolling_allowed: false }, the miner has already been authorised to roll0x1FFFE000, and share validation rejects exactly those bits —channels-sv2/src/client/extended.rs:590-598:The granted mask is the rejected bit range, bit for bit, so the failure rate is 100% rather than probabilistic.
Defect 2 — the drop site logs nothing
miner-apps/translator/src/lib/sv2/channel_manager/mod.rs:604(aggregated) and:647(non-aggregated) discard everyShareValidationErrorthrough a bareelse:Some(Err(_))(validation failed) andNone(no such channel) are indistinguishable, and neither is reported. Meanwhile the SV1 path has already responded{ result: true }to the miner, so from the miner's perspective every share was accepted.Notably, the other roles do handle this error explicitly —
pool-apps/pool/src/lib/channel_manager/mining_message_handler.rsmatchesErr(ShareValidationError::VersionRollingNotAllowed(code)), and jd-client does the same in two places. The translator is the only role that discards share-validation errors silently.Reproduction
SV1 ASIC → tProxy (non-aggregated) → SV2 pool advertising
version_rolling_allowed: falseon extended jobs.Adding a diagnostic at the drop site (patch below) produces, for every submit:
Measured, same binary and same config file, changing only
upstream_address:version_rolling_allowed: falsetruemining.submitreceivedVersionRollingNotAllowedDoesNotMeetTargetThe negotiated mask logged identically in both runs (
Negotiated version_rolling_mask: Some(HexU32Be(536862720))=0x1FFFE000), so the miner's behaviour is constant; the only variable is the upstream'sversion_rolling_allowed. TheDoesNotMeetTargetrejections in the right-hand column are expected in that setup (a rate-shaping proxy sits downstream) and are shown only to demonstrate the diagnostic reports real rejections rather than manufacturing them.Versions
Both defects present on
mainas of this writing — sv2-apps44af69e5, stratumc1a79913.Possibly related
#402 includes a report of a very high share-rejection rate against Braiins Pool via non-aggregated tProxy. That may share this root cause, but it is not the same observation: those shares reached the pool and were rejected there, whereas these never leave the translator. Flagging in case the two turn out to be connected.
Suggested direction
0when the upstream forbids version rolling. This is awkward as written, becausemining.configuremust be answered before the firstNewExtendedMiningJobreveals the upstream's capability. Options include deferring themining.configurereply until the first job is known, or issuingmining.set_version_maskto revise the grant once capability is learned.ShareValidationErrorat both drop sites, distinguishingSome(Err(_))fromNone. Minimal patch that produced the evidence above:{ result: true }should be deferred until the share is actually accepted upstream. Answeringtruefor a share that is about to be discarded locally makes this class of bug invisible from the miner side, and corrupts any hashrate accounting derived from SV1 accept counts.Happy to open a PR for (2), and for (1) if there's a preferred direction on where the capability check should live.