Skip to content

tProxy silently discards 100% of shares against pools with version_rolling_allowed: false #650

Description

@gimballock

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

  1. 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.
  2. 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(());
}
  1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions