diff --git a/.cursor/context/harvester.md b/.cursor/context/harvester.md index f50d4f8e4e..8aba375d67 100644 --- a/.cursor/context/harvester.md +++ b/.cursor/context/harvester.md @@ -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 diff --git a/chia/_tests/core/custom_types/test_proof_of_space.py b/chia/_tests/core/custom_types/test_proof_of_space.py index 37703bb7e1..9084bd5618 100644 --- a/chia/_tests/core/custom_types/test_proof_of_space.py +++ b/chia/_tests/core/custom_types/test_proof_of_space.py @@ -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: diff --git a/chia/_tests/harvester/test_harvester_api.py b/chia/_tests/harvester/test_harvester_api.py index e8cd1dd149..ce7f045827 100644 --- a/chia/_tests/harvester/test_harvester_api.py +++ b/chia/_tests/harvester/test_harvester_api.py @@ -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): diff --git a/chia/harvester/harvester_api.py b/chia/harvester/harvester_api.py index 5340816713..2d1be2ad0f 100644 --- a/chia/harvester/harvester_api.py +++ b/chia/harvester/harvester_api.py @@ -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 diff --git a/chia/simulator/block_tools.py b/chia/simulator/block_tools.py index 615c779842..34007c2298 100644 --- a/chia/simulator/block_tools.py +++ b/chia/simulator/block_tools.py @@ -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: diff --git a/chia/types/blockchain_format/proof_of_space.py b/chia/types/blockchain_format/proof_of_space.py index 31cbef3cd9..662c932088 100644 --- a/chia/types/blockchain_format/proof_of_space.py +++ b/chia/types/blockchain_format/proof_of_space.py @@ -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, )