CHIA-4218 Return None when anchored wallet timestamp lookup misses the peak header (#21274)

* Treat missing anchored peak header as not-synced in wallet timestamp lookup

Follow-up to the wallet timestamp backtracking bound. When
get_timestamp_for_height_from_peer runs in anchored mode (an
expected_header_hash supplied by new_peak_wallet) and the peer returns no
header block for the starting peak height, the loop previously decremented
the height without advancing the anchor and then validated lower heights
against the stale peak hash. Return None (peer treated as not synced)
instead, so the anchor chain is never extended from a missing block.

Non-anchored lookups (get_timestamp_for_height) are unchanged and still
backtrack past a missing peak height.

* Clarify anchored timestamp lookup comment
This commit is contained in:
Zachary Brown
2026-08-18 10:01:21 -07:00
committed by GitHub
parent 0ab01dc336
commit 33c8674fda
2 changed files with 78 additions and 0 deletions
+69
View File
@@ -625,6 +625,75 @@ async def test_get_timestamp_for_height_from_peer_rejects_wrong_response_height(
add_to_blocks.assert_not_called()
@pytest.mark.anyio
@pytest.mark.parametrize("peak_response", [None, []])
async def test_get_timestamp_for_height_from_peer_rejects_missing_peak_response(
root_path_populated_with_config: Path, monkeypatch: pytest.MonkeyPatch, peak_response: list[object] | None
) -> None:
config = load_config(root_path_populated_with_config, "config.yaml", "wallet")
wallet_node = WalletNode(config, root_path_populated_with_config, test_constants)
expected_hash = bytes32(b"\x01" * 32)
peer = cast(WSChiaConnection, types.SimpleNamespace(get_peer_info=lambda: "peer"))
add_to_blocks = Mock()
cache = types.SimpleNamespace(
get_height_timestamp=Mock(return_value=None),
get_block=Mock(return_value=None),
add_to_blocks=add_to_blocks,
)
requested_heights: list[int] = []
async def missing_response(peer: WSChiaConnection, start_height: uint32, end_height: uint32) -> list[object] | None:
requested_heights.append(int(start_height))
return peak_response
monkeypatch.setattr(wallet_node, "get_cache_for_peer", lambda peer: cache)
monkeypatch.setattr("chia.wallet.wallet_node.request_header_blocks", missing_response)
# An anchored lookup whose peak height returns no block (empty or timeout) must not
# backtrack with a stale anchor; it returns None after the single failed peak request.
assert await wallet_node.get_timestamp_for_height_from_peer(uint32(100), peer, expected_hash) is None
assert requested_heights == [100]
add_to_blocks.assert_not_called()
@pytest.mark.anyio
async def test_get_timestamp_for_height_from_peer_unanchored_backtracks_past_missing_peak(
root_path_populated_with_config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = load_config(root_path_populated_with_config, "config.yaml", "wallet")
wallet_node = WalletNode(config, root_path_populated_with_config, test_constants)
timestamp = uint64(12_345)
peer = cast(WSChiaConnection, types.SimpleNamespace(get_peer_info=lambda: "peer"))
add_to_blocks = Mock()
cache = types.SimpleNamespace(
get_height_timestamp=Mock(return_value=None),
get_block=Mock(return_value=None),
add_to_blocks=add_to_blocks,
)
requested_heights: list[int] = []
async def response(peer: WSChiaConnection, start_height: uint32, end_height: uint32) -> list[object]:
requested_heights.append(int(start_height))
if int(start_height) == 100:
return []
return [
types.SimpleNamespace(
height=uint32(start_height),
header_hash=bytes32(b"\x05" * 32),
prev_header_hash=bytes32(b"\x06" * 32),
foliage_transaction_block=types.SimpleNamespace(timestamp=timestamp),
)
]
monkeypatch.setattr(wallet_node, "get_cache_for_peer", lambda peer: cache)
monkeypatch.setattr("chia.wallet.wallet_node.request_header_blocks", response)
# Without an anchor an empty peak response is not fatal: the lookup keeps backtracking
# toward an older transaction block, so the anchored short-circuit must not apply here.
assert await wallet_node.get_timestamp_for_height_from_peer(uint32(100), peer) == timestamp
assert requested_heights == [100, 99]
@pytest.mark.anyio
async def test_get_timestamp_for_height_from_peer_rejects_multiple_response_blocks(
root_path_populated_with_config: Path, monkeypatch: pytest.MonkeyPatch
+9
View File
@@ -1267,6 +1267,15 @@ class WalletNode:
elif request_height < height:
# The peer might be slightly behind but still synced, so we should allow fetching one more block
break
elif expected_hash is not None:
# Only reached on the starting height. In anchored mode, this block establishes
# the hash chain used to validate any backtracked headers. Without it, continuing
# would only compare lower headers against the original peak hash and fail later.
# Treat the peer as not synced instead.
self.log.warning(
f"missing header block response for height {request_height} from Peer {peer.get_peer_info()}."
)
return None
else:
self.log.debug(f"get_timestamp_for_height_from_peer use cached block for height {request_height}")