Checkpoint Merge (#20686)

Co-authored-by: arvidn <arvid@libtorrent.org>
Co-authored-by: Earle Lowe <30607889+emlowe@users.noreply.github.com>
Co-authored-by: Zachary Brown <z.brown@chia.net>
Co-authored-by: Amine Khaldi <amine.khaldi@reactos.org>
This commit is contained in:
Chris Marslender
2026-03-18 14:17:52 -05:00
committed by GitHub
co-authored by arvidn Earle Lowe Zachary Brown Amine Khaldi
parent 888de5db7b
commit c3364780d4
14 changed files with 398 additions and 13 deletions
+1
View File
@@ -144,6 +144,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python environment
if: ${{ !contains(inputs.runs-on, 'blacksmith-4vcpu-windows') }}
uses: Chia-Network/actions/setup-python@main
with:
python-version: ${{ matrix.python.action }}
+3 -3
View File
@@ -157,7 +157,7 @@ jobs:
concurrency-name: ubuntu-intel
configuration: ${{ needs.configure.outputs.configuration }}
matrix_mode: ${{ needs.configure.outputs.matrix_mode }}
runs-on: ubuntu-latest
runs-on: ${{ github.repository_owner == 'Chia-Network' && github.event.repository.visibility == 'private' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-latest' }}
arch: intel
arch-emoji: 🌀
collect-junit: false
@@ -173,7 +173,7 @@ jobs:
concurrency-name: ubuntu-arm
configuration: ${{ needs.configure.outputs.configuration }}
matrix_mode: ${{ needs.configure.outputs.matrix_mode }}
runs-on: ubuntu-24.04-arm
runs-on: ${{ github.repository_owner == 'Chia-Network' && github.event.repository.visibility == 'private' && 'blacksmith-4vcpu-ubuntu-2404-arm' || 'ubuntu-24.04-arm' }}
arch: arm
arch-emoji: 💪
collect-junit: false
@@ -189,7 +189,7 @@ jobs:
concurrency-name: windows
configuration: ${{ needs.configure.outputs.configuration }}
matrix_mode: ${{ needs.configure.outputs.matrix_mode }}
runs-on: windows-latest
runs-on: ${{ github.repository_owner == 'Chia-Network' && github.event.repository.visibility == 'private' && 'blacksmith-4vcpu-windows-2025' || 'windows-latest' }}
arch: intel
arch-emoji: 🌀
collect-junit: false
@@ -2,10 +2,11 @@ from __future__ import annotations
import logging
import random
import time
from collections.abc import AsyncIterator
import pytest
from chia_rs import ConsensusConstants, FullBlock, UnfinishedBlock
from chia_rs import ConsensusConstants, FullBlock, UnfinishedBlock, VDFInfo, VDFProof
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint8, uint16, uint32, uint64, uint128
@@ -30,6 +31,7 @@ from chia.protocols import timelord_protocol
from chia.protocols.timelord_protocol import NewInfusionPointVDF
from chia.simulator.block_tools import BlockTools, create_block_tools_async, get_signage_point, make_unfinished_block
from chia.simulator.keyring import TempKeyring
from chia.types.blockchain_format.classgroup import ClassgroupElement
from chia.util.hash import std_hash
from chia.util.recursive_replace import recursive_replace
@@ -1344,3 +1346,59 @@ async def test_unfinished_block_eviction_mark_requesting(
assert is_requesting
else:
assert not is_requesting
@pytest.mark.anyio
async def test_add_to_future_ip_sets_ttl(seeded_random: random.Random) -> None:
"""add_to_future_ip must register a TTL in future_cache_key_times so that
clear_old_cache_entries can evict stale IP cache entries."""
store = FullNodeStore(DEFAULT_CONSTANTS)
challenge = bytes32.random(seeded_random)
vdf_info = VDFInfo(challenge, uint64(1000), ClassgroupElement.get_default_element())
vdf_proof = VDFProof(uint8(0), b"\x00" * 100, False)
ip = NewInfusionPointVDF(
unfinished_reward_hash=bytes32.random(seeded_random),
challenge_chain_ip_vdf=vdf_info,
challenge_chain_ip_proof=vdf_proof,
reward_chain_ip_vdf=vdf_info,
reward_chain_ip_proof=vdf_proof,
infused_challenge_chain_ip_vdf=None,
infused_challenge_chain_ip_proof=None,
)
store.add_to_future_ip(ip)
assert challenge in store.future_ip_cache
assert challenge in store.future_cache_key_times, (
"add_to_future_ip must set future_cache_key_times so entries can be evicted"
)
@pytest.mark.anyio
async def test_clear_old_cache_entries_evicts_future_ip(seeded_random: random.Random) -> None:
"""Entries added via add_to_future_ip must be evicted by clear_old_cache_entries
after the 1-hour TTL expires."""
store = FullNodeStore(DEFAULT_CONSTANTS)
challenge = bytes32.random(seeded_random)
vdf_info = VDFInfo(challenge, uint64(1000), ClassgroupElement.get_default_element())
vdf_proof = VDFProof(uint8(0), b"\x00" * 100, False)
ip = NewInfusionPointVDF(
unfinished_reward_hash=bytes32.random(seeded_random),
challenge_chain_ip_vdf=vdf_info,
challenge_chain_ip_proof=vdf_proof,
reward_chain_ip_vdf=vdf_info,
reward_chain_ip_proof=vdf_proof,
infused_challenge_chain_ip_vdf=None,
infused_challenge_chain_ip_proof=None,
)
store.add_to_future_ip(ip)
assert challenge in store.future_ip_cache
store.future_cache_key_times[challenge] = int(time.time()) - 3601
store.clear_old_cache_entries()
assert challenge not in store.future_ip_cache, "stale IP cache entry was not evicted"
assert challenge not in store.future_cache_key_times
@@ -0,0 +1,119 @@
"""
Regression tests for FullNode.add_prevalidated_blocks().
SEC-349: Prevalidation failures must return typed errors instead of raising
AssertionError, so the caller can ban the offending peer.
"""
from __future__ import annotations
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from chia_rs.sized_bytes import bytes32
from chia_rs.sized_ints import uint16, uint32, uint64
from chia.consensus.block_body_validation import ForkInfo
from chia.consensus.multiprocess_validation import PreValidationResult
from chia.full_node.full_node import FullNode
from chia.types.peer_info import PeerInfo
from chia.types.validation_state import ValidationState
from chia.util.errors import Err
def _make_fake_self() -> SimpleNamespace:
return SimpleNamespace(
blockchain=SimpleNamespace(
get_block_record_from_db=AsyncMock(return_value=None),
),
weight_proof_handler=None,
log=logging.getLogger("test.add_prevalidated_blocks"),
_state_changed=lambda *a, **kw: None,
)
def _make_fake_block(height: int = 1) -> SimpleNamespace:
return SimpleNamespace(
header_hash=bytes32(b"\xaa" * 32),
prev_header_hash=bytes32(b"\xbb" * 32),
finished_sub_slots=[],
height=uint32(height),
)
def _make_fork_info() -> ForkInfo:
return ForkInfo(
fork_height=-1,
peak_height=-1,
peak_hash=bytes32(b"\x00" * 32),
)
def _make_validation_state() -> ValidationState:
return ValidationState(
ssi=uint64(0),
difficulty=uint64(0),
prev_ses_block=None,
)
@pytest.mark.anyio
async def test_prevalidation_error_returns_err_not_assert() -> None:
"""A PreValidationResult with error != None must produce a typed Err return,
not an AssertionError. This ensures the caller's peer-ban path executes."""
fake_self = _make_fake_self()
block = _make_fake_block()
invalid_result = PreValidationResult(
error=uint16(Err.INVALID_POSPACE.value),
required_iters=None,
conds=None,
timing=uint32(0),
)
blockchain = SimpleNamespace(block_record=lambda _: None, remove_extra_block=lambda _: None)
peer_info = PeerInfo("127.0.0.1", uint16(8444))
summary, err = await FullNode.add_prevalidated_blocks(
fake_self, # type: ignore[arg-type]
blockchain, # type: ignore[arg-type]
[block], # type: ignore[list-item]
[invalid_result],
_make_fork_info(),
peer_info,
_make_validation_state(),
)
assert err is not None, "Expected an error to be returned"
assert err == Err.INVALID_POSPACE
assert summary is None
@pytest.mark.anyio
async def test_prevalidation_none_required_iters_returns_err() -> None:
"""A PreValidationResult with no error but required_iters=None must return
Err.UNKNOWN instead of raising AssertionError."""
fake_self = _make_fake_self()
block = _make_fake_block()
bad_result = PreValidationResult(
error=None,
required_iters=None,
conds=None,
timing=uint32(0),
)
blockchain = SimpleNamespace(block_record=lambda _: None, remove_extra_block=lambda _: None)
peer_info = PeerInfo("127.0.0.1", uint16(8444))
summary, err = await FullNode.add_prevalidated_blocks(
fake_self, # type: ignore[arg-type]
blockchain, # type: ignore[arg-type]
[block], # type: ignore[list-item]
[bad_result],
_make_fork_info(),
peer_info,
_make_validation_state(),
)
assert err is not None, "Expected an error to be returned"
assert err == Err.UNKNOWN
assert summary is None
+156 -1
View File
@@ -4,11 +4,13 @@ from logging import Logger
from pathlib import Path
import pytest
from chia_rs.sized_ints import uint16, uint64
from chia.full_node.full_node_api import FullNodeAPI
from chia.server.node_discovery import FullNodeDiscovery
from chia.server.node_discovery import FullNodeDiscovery, FullNodePeers
from chia.server.server import ChiaServer
from chia.simulator.block_tools import BlockTools
from chia.types.peer_info import PeerInfo, TimestampedPeerInfo
from chia.util.default_root import SIMULATOR_ROOT_PATH
@@ -85,3 +87,156 @@ async def test_enable_private_networks(
await discovery2.initialize_address_manager()
assert discovery2.address_manager is not None
assert discovery2.address_manager.allow_private_subnets is True
class TestPeerHostValidation:
"""Regression tests for SEC-145: unbounded peer list host strings."""
@pytest.mark.anyio
async def test_add_peers_common_rejects_oversized_host(
self,
two_nodes: tuple[FullNodeAPI, FullNodeAPI, ChiaServer, ChiaServer, BlockTools],
) -> None:
chia_server = two_nodes[2]
discovery = FullNodeDiscovery(
server=chia_server,
target_outbound_count=0,
peers_file_path=SIMULATOR_ROOT_PATH / Path(chia_server.config["peers_file_path"]),
introducer_info={"host": "introducer.chia.net", "port": 8444, "enable_private_networks": True},
dns_servers=[],
peer_connect_interval=0,
selected_network=chia_server.config["selected_network"],
default_port=8444,
log=Logger("test_host_validation"),
)
await discovery.initialize_address_manager()
assert discovery.address_manager is not None
oversized_host = "A" * 1000
peer_list = [
TimestampedPeerInfo(oversized_host, uint16(8444), uint64(0)),
]
# Must not raise, and must not add the peer
await discovery._add_peers_common(peer_list, None, False)
assert await discovery.address_manager.size() == 0
@pytest.mark.anyio
async def test_add_peers_common_rejects_non_ip_host(
self,
two_nodes: tuple[FullNodeAPI, FullNodeAPI, ChiaServer, ChiaServer, BlockTools],
) -> None:
chia_server = two_nodes[2]
discovery = FullNodeDiscovery(
server=chia_server,
target_outbound_count=0,
peers_file_path=SIMULATOR_ROOT_PATH / Path(chia_server.config["peers_file_path"]),
introducer_info={"host": "introducer.chia.net", "port": 8444, "enable_private_networks": True},
dns_servers=[],
peer_connect_interval=0,
selected_network=chia_server.config["selected_network"],
default_port=8444,
log=Logger("test_host_validation"),
)
await discovery.initialize_address_manager()
assert discovery.address_manager is not None
invalid_hosts = ["not-an-ip-address", "hello world", "999.999.999.999", ""]
peer_list = [TimestampedPeerInfo(host, uint16(8444), uint64(0)) for host in invalid_hosts]
await discovery._add_peers_common(peer_list, None, False)
assert await discovery.address_manager.size() == 0
@pytest.mark.anyio
async def test_add_peers_common_accepts_valid_ipv4(
self,
two_nodes: tuple[FullNodeAPI, FullNodeAPI, ChiaServer, ChiaServer, BlockTools],
) -> None:
chia_server = two_nodes[2]
discovery = FullNodeDiscovery(
server=chia_server,
target_outbound_count=0,
peers_file_path=SIMULATOR_ROOT_PATH / Path(chia_server.config["peers_file_path"]),
introducer_info={"host": "introducer.chia.net", "port": 8444, "enable_private_networks": True},
dns_servers=[],
peer_connect_interval=0,
selected_network=chia_server.config["selected_network"],
default_port=8444,
log=Logger("test_host_validation"),
)
await discovery.initialize_address_manager()
assert discovery.address_manager is not None
peer_list = [
TimestampedPeerInfo("192.168.1.1", uint16(8444), uint64(0)),
]
await discovery._add_peers_common(peer_list, None, False)
assert await discovery.address_manager.size() >= 1
@pytest.mark.anyio
async def test_add_peers_common_mixed_valid_and_invalid(
self,
two_nodes: tuple[FullNodeAPI, FullNodeAPI, ChiaServer, ChiaServer, BlockTools],
) -> None:
"""Invalid hosts are skipped; valid hosts in the same batch are still added."""
chia_server = two_nodes[2]
discovery = FullNodeDiscovery(
server=chia_server,
target_outbound_count=0,
peers_file_path=SIMULATOR_ROOT_PATH / Path(chia_server.config["peers_file_path"]),
introducer_info={"host": "introducer.chia.net", "port": 8444, "enable_private_networks": True},
dns_servers=[],
peer_connect_interval=0,
selected_network=chia_server.config["selected_network"],
default_port=8444,
log=Logger("test_host_validation"),
)
await discovery.initialize_address_manager()
assert discovery.address_manager is not None
peer_list = [
TimestampedPeerInfo("X" * 500, uint16(8444), uint64(0)),
TimestampedPeerInfo("not-an-ip", uint16(8444), uint64(0)),
TimestampedPeerInfo("192.168.1.1", uint16(8444), uint64(0)),
TimestampedPeerInfo("192.168.1.2", uint16(8444), uint64(0)),
]
await discovery._add_peers_common(peer_list, None, False)
assert await discovery.address_manager.size() >= 1
@pytest.mark.anyio
async def test_add_peers_neighbour_rejects_invalid_host(
self,
two_nodes: tuple[FullNodeAPI, FullNodeAPI, ChiaServer, ChiaServer, BlockTools],
) -> None:
chia_server = two_nodes[2]
discovery = FullNodePeers(
server=chia_server,
target_outbound_count=0,
peers_file_path=SIMULATOR_ROOT_PATH / Path(chia_server.config["peers_file_path"]),
introducer_info={"host": "introducer.chia.net", "port": 8444, "enable_private_networks": True},
dns_servers=[],
peer_connect_interval=0,
selected_network=chia_server.config["selected_network"],
default_port=8444,
log=Logger("test_host_validation"),
)
await discovery.initialize_address_manager()
oversized_host = "B" * 500
invalid_host = "not.an.ip"
valid_host = "10.0.0.1"
neighbour = PeerInfo("10.0.0.100", 8444)
peers = [
TimestampedPeerInfo(oversized_host, uint16(8444), uint64(0)),
TimestampedPeerInfo(invalid_host, uint16(8444), uint64(0)),
TimestampedPeerInfo(valid_host, uint16(8444), uint64(0)),
]
await discovery.add_peers_neighbour(peers, neighbour)
known = discovery.neighbour_known_peers.get(neighbour, set())
assert oversized_host not in known
assert invalid_host not in known
assert valid_host in known
@@ -73,6 +73,26 @@ class TestKeyringWrapper:
)
assert KeyringWrapper.get_shared_instance().has_cached_master_passphrase() is True
# When: the cached master passphrase has been cleared
def test_has_cached_master_passphrase_cleared(self, empty_temp_file_keyring: TempKeyring):
"""
has_cached_master_passphrase should return False when the cache is cleared
"""
# Precondition: default passphrase is cached
assert KeyringWrapper.get_shared_instance().has_cached_master_passphrase() is True
# When: clearing the cached passphrase
KeyringWrapper.get_shared_instance().set_cached_master_passphrase(None)
# Expect: has_cached_master_passphrase reports no passphrase
assert KeyringWrapper.get_shared_instance().has_cached_master_passphrase() is False
# When: setting an empty string passphrase
KeyringWrapper.get_shared_instance().set_cached_master_passphrase("")
# Expect: empty string is also treated as "no passphrase"
assert KeyringWrapper.get_shared_instance().has_cached_master_passphrase() is False
# When: using a file keyring
def test_set_cached_master_passphrase(self, empty_temp_file_keyring: TempKeyring):
"""
+3 -1
View File
@@ -529,7 +529,9 @@ async def test_get_balance(
# Generate some funds, get the balance and make sure it's as expected
await wallet_server.start_client(PeerInfo(self_hostname, full_node_server.get_port()), None)
await time_out_assert(30, wallet_synced)
generated_funds = await full_node_api.farm_blocks_to_wallet(5, wallet_node.wallet_state_manager.main_wallet)
generated_funds = await full_node_api.farm_blocks_to_wallet(
5, wallet_node.wallet_state_manager.main_wallet, timeout=60
)
expected_generated_balance = Balance(
confirmed_wallet_balance=uint128(generated_funds),
unconfirmed_wallet_balance=uint128(generated_funds),
+3 -3
View File
@@ -777,7 +777,7 @@ class DataLayer:
for uploader in uploaders:
self.log.info(f"Using uploader {uploader} for store {store_id.hex()}")
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(timeout=self.client_timeout) as session:
async with session.post(
uploader.url + "/upload",
json=request_json,
@@ -838,7 +838,7 @@ class DataLayer:
"group_files_by_store": self.group_files_by_store,
}
for uploader in uploaders:
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(timeout=self.client_timeout) as session:
async with session.post(
uploader.url + "/add_missing_files",
json=request_json,
@@ -1336,7 +1336,7 @@ class DataLayer:
async def get_uploaders(self, store_id: bytes32) -> list[PluginRemote]:
uploaders = []
for uploader in self.uploaders:
async with aiohttp.ClientSession() as session:
async with aiohttp.ClientSession(timeout=self.client_timeout) as session:
try:
async with session.post(
uploader.url + "/handle_upload",
+1
View File
@@ -314,6 +314,7 @@ class S3Plugin:
if not (bytes32.fromhex(file_name[:64]) == store_id):
log.error(f"failed uploading file {file_name}, store id mismatch")
continue
file_path = self.get_path_for_filename(store_id, file_name, group_files_by_store)
target_file_name = self.get_s3_target_from_path(store_id, file_path, group_files_by_store)
+11 -2
View File
@@ -1628,8 +1628,17 @@ class FullNode:
for i, block in enumerate(blocks_to_validate):
header_hash = block.header_hash
assert vs.prev_ses_block is None or vs.prev_ses_block.height < block.height
assert pre_validation_results[i].error is None
assert pre_validation_results[i].required_iters is not None
if pre_validation_results[i].error is not None:
self.log.error(
f"prevalidation failed for block {header_hash.hex()} height {block.height} "
f"from peer {peer_info}: {Err(pre_validation_results[i].error).name}"
)
return agg_state_change_summary, Err(pre_validation_results[i].error)
if pre_validation_results[i].required_iters is None:
self.log.error(
f"required_iters is None for block {header_hash.hex()} height {block.height} from peer {peer_info}"
)
return agg_state_change_summary, Err.UNKNOWN
state_change_summary: StateChangeSummary | None
# when adding blocks in batches, we won't have any overlapping
# signatures with the mempool. There won't be any cache hits, so
+1
View File
@@ -356,6 +356,7 @@ class FullNodeStore:
if ch not in self.future_ip_cache:
self.future_ip_cache[ch] = []
self.future_ip_cache[ch].append(infusion_point)
self.future_cache_key_times[ch] = int(time.time())
def in_future_sp_cache(self, signage_point: SignagePoint, index: uint8) -> bool:
if signage_point.rc_vdf is None:
+17
View File
@@ -34,6 +34,7 @@ from chia.util.task_referencer import create_referenced_task
MAX_PEERS_RECEIVED_PER_REQUEST = 1000
MAX_TOTAL_PEERS_RECEIVED = 3000
MAX_CONCURRENT_OUTBOUND_CONNECTIONS = 70
MAX_IP_ADDRESS_STRING_LENGTH = 45 # IPv4 max 15, IPv6 max 45
NETWORK_ID_DEFAULT_PORTS = {
"mainnet": 8444,
"testnet7": 58444,
@@ -459,6 +460,16 @@ class FullNodeDiscovery:
if is_misbehaving:
return None
for peer in peer_list:
# Fast-fail oversized junk before IP parsing. IPAddress.create() rejects
# invalid input too, but parsing pathological long strings is much slower.
if len(peer.host) > MAX_IP_ADDRESS_STRING_LENGTH:
self.log.debug(f"Skipping peer with oversized host string ({len(peer.host)} chars)")
continue
try:
IPAddress.create(peer.host)
except ValueError:
self.log.debug(f"Skipping peer with invalid host: {peer.host!r:.50}")
continue
if peer.timestamp < 100000000 or peer.timestamp > time.time() + 10 * 60:
# Invalid timestamp, predefine a bad one.
current_peer = TimestampedPeerInfo(
@@ -552,6 +563,12 @@ class FullNodePeers(FullNodeDiscovery):
async def add_peers_neighbour(self, peers: list[TimestampedPeerInfo], neighbour_info: PeerInfo) -> None:
async with self.lock:
for peer in peers:
if len(peer.host) > MAX_IP_ADDRESS_STRING_LENGTH:
continue
try:
IPAddress.create(peer.host)
except ValueError:
continue
if neighbour_info not in self.neighbour_known_peers:
self.neighbour_known_peers[neighbour_info] = set()
if peer.host not in self.neighbour_known_peers[neighbour_info]:
+1 -1
View File
@@ -219,7 +219,7 @@ class KeyringWrapper:
self.cached_passphrase_is_validated = validated
def has_cached_master_passphrase(self) -> bool:
passphrase = self.get_cached_master_passphrase()
passphrase, _ = self.get_cached_master_passphrase()
return passphrase is not None and len(passphrase) > 0
def has_master_passphrase(self) -> bool:
+3 -1
View File
@@ -387,7 +387,9 @@ def tx_endpoint(
tx_config,
push=request.get("push", push),
merge_spends=request.get("merge_spends", merge_spends),
sign=request.get("sign", self.service.config.get("auto_sign_txs", True)),
sign=False
if func.__name__ == "take_offer"
else request.get("sign", self.service.config.get("auto_sign_txs", True)),
) as action_scope:
response = await func(
self,