[CHIA-4475] implement the schedule for maximum plot strength (#21645)

implement the schedule for maximum plot strength
This commit is contained in:
Arvid Norberg
2026-11-06 02:23:01 +01:00
committed by GitHub
parent 368233b6fc
commit 640bcfc361
6 changed files with 132 additions and 20 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ Public docs describe harvesters as checking plots for a farmer, but source corre
- Pool-specific difficulty overrides are keyed by `pool_contract_puzzle_hash`. If no matching `PoolDifficulty` exists, the global signage-point difficulty/sub-slot iters remain in force.
- V1 full proof retrieval can fail for decompressor timeouts, line point compression (`GRResult_NoProof received`), plot I/O/prover errors, or fork phase-out checks. These failures are logged and dropped per plot rather than aborting the whole challenge.
- V2 partial proof lookup returns only partial proofs that pass the same required-iterations threshold. It must carry plot id, plot index, `meta_group`, strength, plot public key, and pool identifiers because the farmer/solver path needs those fields to build the final `ProofOfSpace`.
- V2 filter eligibility uses the same `calculate_prefix_bits()` → `passes_plot_filter()` path as V1, but with `NUMBER_ZERO_BITS_PLOT_FILTER_V2` and height-based adjustments (`PLOT_FILTER_V2_FIRST/SECOND/THIRD_ADJUSTMENT_HEIGHT`). Plots with `strength_v2` below `MIN_PLOT_STRENGTH` or above `MAX_PLOT_STRENGTH` are rejected by `check_plot_param()`; harvester-side and validation-side filter math must stay identical or farmers produce proofs full nodes reject. See `chia/types/blockchain_format/proof_of_space.py` for exact constants.
- V2 filter eligibility uses `calculate_plot_filter_bits()` and `passes_plot_filter_v2()` with the pre-signage-point transaction height. The base filter follows `PLOT_FILTER_V2_RELATIVE_HEIGHT`, and the effective filter also depends on plot strength. The allowed strength range is `MIN_PLOT_STRENGTH` through `MAX_PLOT_STRENGTH` minus the current base filter bits. When the pre-signage-point transaction height is unavailable during height-agnostic weight-proof validation, `check_plot_param()` uses the relaxed absolute `MAX_PLOT_STRENGTH` bound. Harvester-side and validation-side rules must stay aligned or farmers may produce proofs full nodes reject. See `chia/types/blockchain_format/proof_of_space.py` for exact constants and calculations.
- The lookup warning threshold is operationally important: late proof lookup risks missed rewards. Avoid adding synchronous work, unbounded logging, RPC calls, or long lock holds to the signage-point path.
## Signature Authority
@@ -14,12 +14,14 @@ from chia.consensus.default_constants import DEFAULT_CONSTANTS
from chia.types.blockchain_format.proof_of_space import (
calculate_base_plot_filter_bits,
calculate_plot_filter_bits,
calculate_pos_challenge,
calculate_prefix_bits,
check_plot_param,
compute_plot_group_id,
is_v1_phased_out,
is_v2_plot,
make_pos,
max_plot_strength,
num_phase_out_epochs,
passes_plot_filter,
passes_plot_filter_v2,
@@ -224,11 +226,17 @@ def test_is_v2_plot(plot_param: PlotParam, expected: bool) -> None:
pool_contract_puzzle_hash=bytes32(b"1" * 32),
expected_error="Calculated pos challenge doesn't match the provided one",
),
ProofOfSpaceCase(
id="v2 plot above height-dependent maximum strength",
plot_size=PlotParam.make_v2(0, 0, 9),
pool_contract_puzzle_hash=bytes32(b"1" * 32),
expected_error="Plot strength (9) is too high (max is 8)",
),
ProofOfSpaceCase(
id="v2 plot strength 18",
plot_size=PlotParam.make_v2(0, 0, 18),
pool_contract_puzzle_hash=bytes32(b"1" * 32),
expected_error="Plot strength (18) is too high (max is 17)",
expected_error="Plot strength (18) is too high (max is 8)",
),
ProofOfSpaceCase(
id="Not passing the plot filter v2 missing filter_challenge",
@@ -425,12 +433,65 @@ def test_verify_and_get_quality_string_v2(caplog: pytest.LogCaptureFixture, case
(PlotParam.make_v2(0, 0, 1), False), # strength too low
(PlotParam.make_v2(0, 0, 2), True),
(PlotParam.make_v2(0, 0, 3), True),
(PlotParam.make_v2(0, 0, 17), True),
(PlotParam.make_v2(0, 0, 18), False), # strength too high
(PlotParam.make_v2(0, 0, 8), True),
(PlotParam.make_v2(0, 0, 9), False), # strength too high at the initial base filter
(PlotParam.make_v2(0, 0, 17), False),
(PlotParam.make_v2(0, 0, 18), False),
],
)
def test_check_plot_param(plot_param: PlotParam, valid: bool) -> None:
assert check_plot_param(DEFAULT_CONSTANTS, plot_param) == valid
assert check_plot_param(DEFAULT_CONSTANTS, plot_param, DEFAULT_CONSTANTS.HARD_FORK2_HEIGHT) == valid
def test_height_agnostic_v2_strength_limit(monkeypatch: pytest.MonkeyPatch) -> None:
constants = DEFAULT_CONSTANTS.replace(HARD_FORK2_HEIGHT=uint32(1_000_000))
pool_hash = bytes32(b"1" * 32)
plot_public_key = _test_pk()
original_challenge = bytes32(b"2" * 32)
signage_point = bytes32(b"3" * 32)
monkeypatch.setattr("chia.types.blockchain_format.proof_of_space.validate_proof_v2", lambda *args: bytes32.zeros)
for strength, expected_valid in [(9, True), (17, True), (18, False)]:
param = PlotParam.make_v2(0, 0, strength)
assert check_plot_param(constants, param, None) == expected_valid
assert not check_plot_param(constants, param, constants.HARD_FORK2_HEIGHT)
group_id = compute_plot_group_id(strength, plot_public_key, pool_hash)
pos = make_pos(
calculate_pos_challenge(group_id, original_challenge, signage_point),
None,
pool_hash,
plot_public_key,
param,
b"proof",
)
quality = verify_and_get_quality_string(
pos,
constants,
original_challenge,
signage_point,
height=constants.HARD_FORK2_HEIGHT,
prev_transaction_block_height=uint32(0),
height_agnostic=True,
)
assert (quality is not None) == expected_valid
@pytest.mark.parametrize(
"relative_height,expected_maximum",
[
(10_100_999, 8),
(10_101_000, 9),
(50_493_999, 16),
(50_494_000, 17),
],
)
def test_max_plot_strength_follows_base_filter(relative_height: int, expected_maximum: int) -> None:
constants = DEFAULT_CONSTANTS.replace(HARD_FORK2_HEIGHT=uint32(1_000_000))
height = uint32(constants.HARD_FORK2_HEIGHT + relative_height)
assert max_plot_strength(height, constants) == expected_maximum
assert check_plot_param(constants, PlotParam.make_v2(0, 0, expected_maximum), height)
assert not check_plot_param(constants, PlotParam.make_v2(0, 0, expected_maximum + 1), height)
class TestProofOfSpace:
+31 -1
View File
@@ -11,12 +11,13 @@ from unittest.mock import MagicMock, patch
import pytest
from chia_rs import ConsensusConstants, FullBlock, PartialProof, ProofOfSpace
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint8, uint16, uint64
from chia_rs.sized_ints import uint8, uint16, uint32, uint64
from packaging.version import Version
from chia._tests.conftest import ConsensusMode, HarvesterFarmerEnvironment
from chia._tests.plotting.util import get_test_plots
from chia._tests.util.time_out_assert import time_out_assert
from chia.consensus.default_constants import DEFAULT_CONSTANTS
from chia.harvester.harvester_api import HarvesterAPI
from chia.plotting.prover import V1Prover, V2Prover, V2Quality
from chia.plotting.util import PlotInfo
@@ -112,6 +113,35 @@ def create_test_setup(
return env, new_challenge, mock_peer
@pytest.mark.parametrize(
"relative_height,strength,expected",
[
(0, 8, True),
(0, 9, False),
(10_101_000, 9, True),
(10_101_000, 10, False),
],
)
def test_v2_plot_strength_gate(relative_height: int, strength: int, expected: bool) -> None:
constants = DEFAULT_CONSTANTS.replace(HARD_FORK2_HEIGHT=uint32(1_000_000))
api = HarvesterAPI(MagicMock())
api.harvester.constants = constants
plot_info = MagicMock(spec=PlotInfo)
inner_prover = MagicMock()
inner_prover.get_strength.return_value = uint8(strength)
inner_prover.get_meta_group.return_value = uint8(0)
inner_prover.plot_group_id.return_value = bytes32.zeros
plot_info.prover = V2Prover(inner_prover)
challenge = MagicMock(spec=harvester_protocol.NewSignagePointHarvester2)
challenge.last_tx_height = uint32(constants.HARD_FORK2_HEIGHT + relative_height)
challenge.filter_challenge = bytes32.zeros
challenge.signage_point_index = uint8(0)
with patch("chia.harvester.harvester_api.passes_plot_filter_v2", return_value=True) as plot_filter:
assert api._plot_passes_filter(plot_info, challenge) == expected
assert plot_filter.called == expected
@contextmanager
def mock_successful_proof(plot_info: PlotInfo) -> Generator[None, None, None]:
if isinstance(plot_info.prover, V1Prover):
+9 -4
View File
@@ -32,6 +32,7 @@ from chia.types.blockchain_format.proof_of_space import (
generate_plot_public_key,
is_v1_phased_out,
make_pos,
max_plot_strength,
passes_plot_filter,
passes_plot_filter_v2,
v1_cut_off_height,
@@ -65,20 +66,24 @@ class HarvesterAPI:
) -> bool:
if plot_info.prover.get_version() == PlotVersion.V2:
assert isinstance(plot_info.prover, V2Prover)
strength = plot_info.prover.get_strength()
constants = self.harvester.constants
if strength < constants.MIN_PLOT_STRENGTH or strength > max_plot_strength(
challenge.last_tx_height, constants
):
return False
# V2 plots always use predictable filter — no fallback
filter_challenge = getattr(challenge, "filter_challenge", None)
if filter_challenge is None:
return False
group_strength = calculate_plot_filter_bits(
challenge.last_tx_height, self.harvester.constants, plot_info.prover.get_strength()
)
group_strength = calculate_plot_filter_bits(challenge.last_tx_height, constants, strength)
return passes_plot_filter_v2(
plot_info.prover.get_id(),
plot_info.prover.get_meta_group(),
group_strength,
filter_challenge,
challenge.signage_point_index,
self.harvester.constants.FILTER_WINDOW_SIZE,
constants.FILTER_WINDOW_SIZE,
)
# V1 plots use original prefix-bits filter
+4 -2
View File
@@ -123,6 +123,7 @@ from chia.types.blockchain_format.proof_of_space import (
generate_taproot_sk,
is_v1_phased_out,
make_pos,
max_plot_strength,
num_phase_out_epochs,
passes_plot_filter,
passes_plot_filter_v2,
@@ -1885,10 +1886,11 @@ class BlockTools:
f"cannot be used for farming: {plot_info.prover.get_filename()}"
)
continue
if plot_info.prover.get_strength() > constants.MAX_PLOT_STRENGTH:
max_strength = max_plot_strength(prev_tx_height, constants)
if plot_info.prover.get_strength() > max_strength:
self.log.warn(
f"Plot strength ({plot_info.prover.get_strength()}) exceeds max "
f"({constants.MAX_PLOT_STRENGTH}): {plot_info.prover.get_filename()}"
f"({max_strength}): {plot_info.prover.get_filename()}"
)
continue
if filter_challenge is None or signage_point_index is None:
+22 -8
View File
@@ -55,15 +55,24 @@ def make_pos(
)
def check_plot_param(constants: ConsensusConstants, ps: PlotParam) -> bool:
def check_plot_param(
constants: ConsensusConstants,
ps: PlotParam,
pre_sp_tx_height: uint32 | None,
) -> bool:
size_v1 = ps.size_v1
strength_v2 = ps.strength_v2
if strength_v2 is not None:
if strength_v2 < constants.MIN_PLOT_STRENGTH:
log.error(f"Plot strength ({strength_v2}) is lower than the minimum ({constants.MIN_PLOT_STRENGTH})")
return False
if strength_v2 > constants.MAX_PLOT_STRENGTH:
log.error(f"Plot strength ({strength_v2}) is too high (max is {constants.MAX_PLOT_STRENGTH})")
# A missing pre-SP transaction height makes this check height agnostic. Use the
# absolute maximum, a relaxed bound that accepts plots valid at later heights.
max_strength = (
constants.MAX_PLOT_STRENGTH if pre_sp_tx_height is None else max_plot_strength(pre_sp_tx_height, constants)
)
if strength_v2 > max_strength:
log.error(f"Plot strength ({strength_v2}) is too high (max is {max_strength})")
return False
return True
@@ -174,7 +183,7 @@ def verify_and_get_quality_string(
log.error("Expected pool public key or pool contract puzzle hash but got both")
return None
if not check_plot_param(constants, plot_param):
if not check_plot_param(constants, plot_param, None if height_agnostic else prev_transaction_block_height):
return None
plot_id: bytes32 = pos.compute_plot_id()
@@ -279,9 +288,9 @@ def calculate_plot_filter_input(plot_id: bytes32, challenge_hash: bytes32, signa
return std_hash(plot_id + challenge_hash + signage_point)
def calculate_base_plot_filter_bits(height: uint32, constants: ConsensusConstants) -> int:
def calculate_base_plot_filter_bits(pre_sp_tx_height: uint32, constants: ConsensusConstants) -> int:
base_bits = int(constants.NUMBER_ZERO_BITS_PLOT_FILTER_V2)
relative_height = int(height) - constants.HARD_FORK2_HEIGHT
relative_height = int(pre_sp_tx_height) - constants.HARD_FORK2_HEIGHT
if relative_height <= 0:
return base_bits
for index, offset in enumerate(constants.PLOT_FILTER_V2_RELATIVE_HEIGHT):
@@ -291,13 +300,18 @@ def calculate_base_plot_filter_bits(height: uint32, constants: ConsensusConstant
return base_bits
def calculate_plot_filter_bits(height: uint32, constants: ConsensusConstants, plot_strength: int) -> int:
def max_plot_strength(pre_sp_tx_height: uint32, constants: ConsensusConstants) -> int:
return int(constants.MAX_PLOT_STRENGTH) - calculate_base_plot_filter_bits(pre_sp_tx_height, constants)
def calculate_plot_filter_bits(pre_sp_tx_height: uint32, constants: ConsensusConstants, plot_strength: int) -> int:
# min-strength schedule would change the zero point here.
if plot_strength < constants.MIN_PLOT_STRENGTH:
raise ValueError(f"Plot strength ({plot_strength}) is lower than the minimum ({constants.MIN_PLOT_STRENGTH})")
strength_delta = int(plot_strength) - constants.MIN_PLOT_STRENGTH
return min(
calculate_base_plot_filter_bits(height, constants) + strength_delta, constants.MAX_EFFECTIVE_PLOT_FILTER_BITS
calculate_base_plot_filter_bits(pre_sp_tx_height, constants) + strength_delta,
constants.MAX_EFFECTIVE_PLOT_FILTER_BITS,
)