From 4d68e686ba162c6c7d88398832fd24c428cb2342 Mon Sep 17 00:00:00 2001 From: Darioush Jalali Date: Wed, 24 Jun 2026 10:52:58 -0700 Subject: [PATCH 1/2] test(light-client): port lightclient tests to test-loop --- nightly/pytest-sanity.txt | 7 - pytest/lib/lightclient.py | 158 -------- pytest/tests/sanity/lightclient_test.py | 199 ---------- ...pc_light_client_execution_outcome_proof.py | 230 ----------- test-loop-tests/src/tests/light_client.rs | 361 ++++++++++++++++++ test-loop-tests/src/tests/mod.rs | 1 + 6 files changed, 362 insertions(+), 594 deletions(-) delete mode 100644 pytest/lib/lightclient.py delete mode 100755 pytest/tests/sanity/lightclient_test.py delete mode 100755 pytest/tests/sanity/rpc_light_client_execution_outcome_proof.py create mode 100644 test-loop-tests/src/tests/light_client.rs diff --git a/nightly/pytest-sanity.txt b/nightly/pytest-sanity.txt index caf16da3932..637fb63945f 100644 --- a/nightly/pytest-sanity.txt +++ b/nightly/pytest-sanity.txt @@ -44,13 +44,6 @@ pytest sanity/one_val.py pytest sanity/one_val.py nightly --features nightly # TODO(spice): Assess if this test is relevant for spice and if yes fix it. #pytest sanity/one_val.py nightly --features nightly,protocol_feature_spice -pytest sanity/lightclient_test.py -pytest sanity/lightclient_test.py --features nightly -pytest sanity/lightclient_test.py --features nightly,protocol_feature_spice -pytest sanity/rpc_light_client_execution_outcome_proof.py -pytest sanity/rpc_light_client_execution_outcome_proof.py --features nightly -# TODO(spice): Assess if this test is relevant for spice and if yes fix it. -#pytest sanity/rpc_light_client_execution_outcome_proof.py --features nightly,protocol_feature_spice pytest --timeout=10m sanity/block_sync_archival.py pytest --timeout=10m sanity/block_sync_archival.py --features nightly # TODO(spice): Assess if this test is relevant for spice and if yes fix it. diff --git a/pytest/lib/lightclient.py b/pytest/lib/lightclient.py deleted file mode 100644 index f359022cb9e..00000000000 --- a/pytest/lib/lightclient.py +++ /dev/null @@ -1,158 +0,0 @@ -from serializer import BinarySerializer -import hashlib, base58 -import nacl.signing -from utils import combine_hash - -ED_PREFIX = "ed25519:" - - -class BlockHeaderInnerLite: - pass - - -inner_lite_schema = dict([ - [ - BlockHeaderInnerLite, { - 'kind': - 'struct', - 'fields': [ - ['height', 'u64'], - ['epoch_id', [32]], - ['next_epoch_id', [32]], - ['prev_state_root', [32]], - ['outcome_root', [32]], - ['timestamp', 'u64'], - ['next_bp_hash', [32]], - ['block_merkle_root', [32]], - ] - } - ], -]) - - -def compute_block_hash(inner_lite_view, inner_rest_hash, prev_hash): - inner_rest_hash = base58.b58decode(inner_rest_hash) - prev_hash = base58.b58decode(prev_hash) - - inner_lite = BlockHeaderInnerLite() - inner_lite.height = inner_lite_view['height'] - inner_lite.epoch_id = base58.b58decode(inner_lite_view['epoch_id']) - inner_lite.next_epoch_id = base58.b58decode( - inner_lite_view['next_epoch_id']) - inner_lite.prev_state_root = base58.b58decode( - inner_lite_view['prev_state_root']) - inner_lite.outcome_root = base58.b58decode(inner_lite_view['outcome_root']) - inner_lite.timestamp = int(inner_lite_view['timestamp_nanosec']) - inner_lite.next_bp_hash = base58.b58decode(inner_lite_view['next_bp_hash']) - inner_lite.block_merkle_root = base58.b58decode( - inner_lite_view['block_merkle_root']) - - msg = BinarySerializer(inner_lite_schema).serialize(inner_lite) - inner_lite_hash = hashlib.sha256(msg).digest() - inner_hash = combine_hash(inner_lite_hash, inner_rest_hash) - final_hash = combine_hash(inner_hash, prev_hash) - - return base58.b58encode(final_hash) - - -# follows the spec from NEP 25 (https://github.com/nearprotocol/NEPs/pull/25) -def validate_light_client_block(last_known_block, - new_block, - block_producers_map, - panic=False): - new_block_hash = compute_block_hash(new_block['inner_lite'], - new_block['inner_rest_hash'], - new_block['prev_block_hash']) - next_block_hash_decoded = combine_hash( - base58.b58decode(new_block['next_block_inner_hash']), - base58.b58decode(new_block_hash)) - - if new_block['inner_lite']['epoch_id'] not in [ - last_known_block['inner_lite']['epoch_id'], - last_known_block['inner_lite']['next_epoch_id'] - ]: - if panic: - assert False - return False - - block_producers = block_producers_map[new_block['inner_lite']['epoch_id']] - if len(new_block['approvals_after_next']) != len(block_producers): - if panic: - assert False - return False - - total_stake = 0 - approved_stake = 0 - - for approval, stake in zip(new_block['approvals_after_next'], - block_producers): - total_stake += int(stake['stake']) - - if approval is None: - continue - - approved_stake += int(stake['stake']) - - public_key = stake['public_key'] - - signature = base58.b58decode(approval[len(ED_PREFIX):]) - verify_key = nacl.signing.VerifyKey( - base58.b58decode(public_key[len(ED_PREFIX):])) - - approval_message = bytearray() - approval_message.append(0) - approval_message += next_block_hash_decoded - approval_message.append(new_block['inner_lite']['height'] + 2) - for i in range(7): - approval_message.append(0) - approval_message = bytes(approval_message) - verify_key.verify(approval_message, signature) - - threshold = total_stake * 2 // 3 - if approved_stake <= threshold: - if panic: - assert False - return False - - if new_block['inner_lite']['epoch_id'] == last_known_block['inner_lite'][ - 'next_epoch_id']: - if new_block['next_bps'] is None: - if panic: - assert False - return False - - print(new_block['next_bps']) - serialized_next_bp = bytearray() - serialized_next_bp.append(len(new_block['next_bps'])) - for i in range(3): - serialized_next_bp.append(0) - for bp in new_block['next_bps']: - version = 0 - if 'validator_stake_struct_version' in bp: - # version of ValidatorStake enum - version = int(bp['validator_stake_struct_version'][1:]) - 1 - serialized_next_bp.append(version) - serialized_next_bp.append(5) - for i in range(3): - serialized_next_bp.append(0) - serialized_next_bp += bp['account_id'].encode('utf-8') - serialized_next_bp.append(0) # public key type - serialized_next_bp += base58.b58decode( - bp['public_key'][len(ED_PREFIX):]) - stake = int(bp['stake']) - for i in range(16): - serialized_next_bp.append(stake & 255) - stake >>= 8 - - serialized_next_bp = bytes(serialized_next_bp) - - computed_hash = base58.b58encode( - hashlib.sha256(serialized_next_bp).digest()) - if computed_hash != new_block['inner_lite']['next_bp_hash'].encode( - 'utf-8'): - if panic: - assert False - return False - - block_producers_map[new_block['inner_lite'] - ['next_epoch_id']] = new_block['next_bps'] diff --git a/pytest/tests/sanity/lightclient_test.py b/pytest/tests/sanity/lightclient_test.py deleted file mode 100755 index 791e7c2c0d1..00000000000 --- a/pytest/tests/sanity/lightclient_test.py +++ /dev/null @@ -1,199 +0,0 @@ -#!/usr/bin/env python3 -# Generates three epochs worth of blocks -# Requests next light client block until it reaches the last final block. -# Verifies that the returned blocks are what we expect, and runs the validation on them - -import sys, time -import pathlib - -sys.path.append(str(pathlib.Path(__file__).resolve().parents[2] / 'lib')) - -from cluster import start_cluster, load_config -from configured_logger import logger -from lightclient import compute_block_hash, validate_light_client_block -import utils - -TIMEOUT = 150 -config = load_config() -client_config_changes = {} -if not config['local']: - client_config_changes = { - "consensus": { - "min_block_production_delay": { - "secs": 4, - "nanos": 0, - }, - "max_block_production_delay": { - "secs": 8, - "nanos": 0, - }, - "max_block_wait_delay": { - "secs": 24, - "nanos": 0, - }, - } - } - TIMEOUT = 600 - -client_config_changes['archive'] = True -client_config_changes['tracked_shards_config'] = 'AllShards' - -no_state_snapshots_config = client_config_changes -no_state_snapshots_config[ - 'store.state_snapshot_config.state_snapshot_type'] = "Disabled" - -nodes = start_cluster( - 4, 0, 4, None, - [["epoch_length", 6], ["block_producer_kickout_threshold", 40], - ["chunk_producer_kickout_threshold", 40]], { - 0: no_state_snapshots_config, - 1: client_config_changes, - 2: client_config_changes, - 3: client_config_changes - }) - -for node in nodes: - node.stop_checking_store() - -started = time.time() - -hash_to_height = {} -hash_to_epoch = {} -hash_to_next_epoch = {} -height_to_hash = {} -epochs = [] - -first_epoch_switch_height = None -last_epoch = None - -block_producers_map = {} - - -def get_light_client_block(hash_, last_known_block): - global block_producers_map - - ret = nodes[0].json_rpc('next_light_client_block', [hash_]) - if ret['result'] != {} and last_known_block is not None: - validate_light_client_block(last_known_block, - ret['result'], - block_producers_map, - panic=True) - return ret - - -def get_up_to(from_, to): - global first_epoch_switch_height, last_epoch - - for height, hash_ in utils.poll_blocks(nodes[0], - timeout=TIMEOUT, - poll_interval=0.01): - block = nodes[0].get_block(hash_) - - hash_to_height[hash_] = height - height_to_hash[height] = hash_ - - cur_epoch = block['result']['header']['epoch_id'] - - hash_to_epoch[hash_] = cur_epoch - hash_to_next_epoch[hash_] = block['result']['header']['next_epoch_id'] - - if (first_epoch_switch_height is None and last_epoch is not None and - last_epoch != cur_epoch): - first_epoch_switch_height = height - last_epoch = cur_epoch - - if height >= to: - break - - for i in range(from_, to + 1): - hash_ = height_to_hash[i] - logger.info( - f"{i} {hash_} {hash_to_epoch[hash_]} {hash_to_next_epoch[hash_]}") - - if len(epochs) == 0 or epochs[-1] != hash_to_epoch[hash_]: - epochs.append(hash_to_epoch[hash_]) - - -# don't start from 1, since couple heights get produced while the nodes spin up -get_up_to(4, 15) -get_up_to(16, 22 + first_epoch_switch_height) - -# since we already "know" the first block, the first light client block that will be returned -# will be for the second epoch. The second epoch spans blocks 7-12, and the last final block in -# it has height 10. Then blocks go in increments of 6. -# the last block returned will be the last final block, with height 27 -heights = [ - None, 3 + first_epoch_switch_height, 9 + first_epoch_switch_height, - 15 + first_epoch_switch_height, 20 + first_epoch_switch_height -] - -last_known_block_hash = height_to_hash[4] -last_known_block = None -iter_ = 1 - -while True: - assert time.time() - started < TIMEOUT - - res = get_light_client_block(last_known_block_hash, last_known_block) - - if last_known_block_hash == height_to_hash[20 + first_epoch_switch_height]: - assert res['result'] == {} - break - - assert res['result']['inner_lite']['epoch_id'] == epochs[iter_] - logger.info(f"{iter_} {heights[iter_]}") - assert res['result']['inner_lite']['height'] == heights[iter_], ( - res['result']['inner_lite'], first_epoch_switch_height) - - last_known_block_hash = compute_block_hash( - res['result']['inner_lite'], res['result']['inner_rest_hash'], - res['result']['prev_block_hash']).decode('ascii') - assert last_known_block_hash == height_to_hash[ - res['result']['inner_lite']['height']], "%s != %s" % ( - last_known_block_hash, - height_to_hash[res['result']['inner_lite']['height']]) - - if last_known_block is None: - block_producers_map[res['result']['inner_lite'] - ['next_epoch_id']] = res['result']['next_bps'] - last_known_block = res['result'] - - iter_ += 1 - -res = get_light_client_block(height_to_hash[19 + first_epoch_switch_height], - last_known_block) -logger.info(res) -assert res['result']['inner_lite']['height'] == 20 + first_epoch_switch_height - -get_up_to(23 + first_epoch_switch_height, 24 + first_epoch_switch_height) - -# Test that the light client block is always in the same epoch as the block 2 heights onward (needed to make -# sure that the proofs can be verified using the keys of the block producers of the epoch of the block). -# Before the loop below the last block is 24 + C, which is in the new epoch, thus we expect the light client -# block during the first iteration to be 21 + C. After the first iteration we move one block onward, now -# having the head at 25 + C. At this point the last final block is 23 + C, still in the previous epoch, so -# we still expect 21 + C to be returned. We then move again to 26 + C, and (in the section after the loop) -# check that the light client block now corresponds to 24 + C, which is in the same epoch as 26 + C. -for i in range(2): - res = get_light_client_block(height_to_hash[19 + first_epoch_switch_height], - last_known_block) - assert res['result']['inner_lite'][ - 'height'] == 21 + first_epoch_switch_height, ( - res['result']['inner_lite']['height'], - 21 + first_epoch_switch_height) - - res = get_light_client_block(height_to_hash[20 + first_epoch_switch_height], - last_known_block) - assert res['result']['inner_lite'][ - 'height'] == 21 + first_epoch_switch_height - - res = get_light_client_block(height_to_hash[21 + first_epoch_switch_height], - last_known_block) - assert res['result'] == {} - - get_up_to(i + 25 + first_epoch_switch_height, - i + 25 + first_epoch_switch_height) - -res = get_light_client_block(height_to_hash[21 + first_epoch_switch_height], - last_known_block) -assert res['result']['inner_lite']['height'] == 24 + first_epoch_switch_height diff --git a/pytest/tests/sanity/rpc_light_client_execution_outcome_proof.py b/pytest/tests/sanity/rpc_light_client_execution_outcome_proof.py deleted file mode 100755 index a99577751e1..00000000000 --- a/pytest/tests/sanity/rpc_light_client_execution_outcome_proof.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/usr/bin/env python3 -# Spins up two nodes, deploy a smart contract to one node, -# Send a transaction to call a contract method. Check that -# the transaction and receipts execution outcome proof for -# light client works - -import base58, base64 -import hashlib -import json -import struct -import sys -import pathlib - -sys.path.append(str(pathlib.Path(__file__).resolve().parents[2] / 'lib')) -from cluster import start_cluster, Key -from serializer import BinarySerializer -import transaction -import time -import utils -from lightclient import compute_block_hash - - -class PartialExecutionOutcome: - pass - - -class PartialExecutionStatus: - pass - - -class Unknown: - pass - - -class Failure: - pass - - -partial_execution_outcome_schema = dict([ - [ - PartialExecutionOutcome, - { - 'kind': - 'struct', - 'fields': [ - ['receipt_ids', [[32]]], - ['gas_burnt', 'u64'], - ['tokens_burnt', 'u128'], - ['executor_id', 'string'], - ['status', PartialExecutionStatus], - ] - }, - ], - [ - PartialExecutionStatus, { - 'kind': - 'enum', - 'field': - 'enum', - 'values': [ - ['unknown', Unknown], - ['failure', Failure], - ['successValue', ['u8']], - ['successReceiptId', [32]], - ] - } - ], - [Unknown, { - 'kind': 'struct', - 'fields': [] - }], - [Failure, { - 'kind': 'struct', - 'fields': [] - }], -]) - - -def serialize_execution_outcome_with_id(outcome, id): - partial_outcome = PartialExecutionOutcome() - partial_outcome.receipt_ids = [ - base58.b58decode(x) for x in outcome['receipt_ids'] - ] - partial_outcome.gas_burnt = outcome['gas_burnt'] - partial_outcome.tokens_burnt = int(outcome['tokens_burnt']) - partial_outcome.executor_id = outcome['executor_id'] - execution_status = PartialExecutionStatus() - if 'SuccessValue' in outcome['status']: - execution_status.enum = 'successValue' - execution_status.successValue = base64.b64decode( - outcome['status']['SuccessValue']) - elif 'SuccessReceiptId' in outcome['status']: - execution_status.enum = 'successReceiptId' - execution_status.successReceiptId = base58.b58decode( - outcome['status']['SuccessReceiptId']) - elif 'Failure' in outcome['status']: - execution_status.enum = 'failure' - execution_status.failure = Failure() - elif 'Unknown' in outcome['status']: - execution_status.enum = 'unknown' - execution_status.unknown = Unknown - else: - assert False, f'status not supported: {outcome["status"]}' - partial_outcome.status = execution_status - msg = BinarySerializer(partial_execution_outcome_schema).serialize( - partial_outcome) - partial_outcome_hash = hashlib.sha256(msg).digest() - outcome_hashes = [partial_outcome_hash] - for log_entry in outcome['logs']: - outcome_hashes.append( - hashlib.sha256(bytes(log_entry, 'utf-8')).digest()) - res = [base58.b58decode(id)] - res.extend(outcome_hashes) - borsh_res = bytearray() - length = len(res) - for i in range(4): - borsh_res.append(length & 255) - length //= 256 - for hash_result in res: - borsh_res += bytearray(hash_result) - return borsh_res - - -def check_transaction_outcome_proof(nodes, should_succeed, nonce): - latest_block_hash = nodes[1].get_latest_block().hash_bytes - function_caller_key = nodes[0].signer_key - gas = 300000000000000 if should_succeed else 1000 - - function_call_1_tx = transaction.sign_function_call_tx( - function_caller_key, nodes[0].signer_key.account_id, 'write_key_value', - struct.pack(' latest_block_height + 2 and - light_client_request_block_hash is None): - light_client_request_block_hash = hash_ - if cur_height > latest_block_height + 7: - break - - light_client_block = nodes[0].json_rpc( - 'next_light_client_block', [light_client_request_block_hash])['result'] - light_client_block_hash = compute_block_hash( - light_client_block['inner_lite'], light_client_block['inner_rest_hash'], - light_client_block['prev_block_hash']).decode('utf-8') - - queries = [{ - "type": - "transaction", - "transaction_hash": - function_call_result['result']['transaction_outcome']['id'], - "sender_id": - "test0", - "light_client_head": - light_client_block_hash - }] - outcomes = [ - (function_call_result['result']['transaction_outcome']['outcome'], - function_call_result['result']['transaction_outcome']['id']) - ] - for receipt_outcome in function_call_result['result']['receipts_outcome']: - outcomes.append((receipt_outcome['outcome'], receipt_outcome['id'])) - queries.append({ - "type": "receipt", - "receipt_id": receipt_outcome['id'], - "receiver_id": "test0", - "light_client_head": light_client_block_hash - }) - - for query, (outcome, id) in zip(queries, outcomes): - res = nodes[0].json_rpc('light_client_proof', query, timeout=10) - assert 'error' not in res, res - light_client_proof = res['result'] - # check that execution outcome root proof is valid - execution_outcome_hash = hashlib.sha256( - serialize_execution_outcome_with_id(outcome, id)).digest() - outcome_root = utils.compute_merkle_root_from_path( - light_client_proof['outcome_proof']['proof'], - execution_outcome_hash) - block_outcome_root = utils.compute_merkle_root_from_path( - light_client_proof['outcome_root_proof'], - hashlib.sha256(outcome_root).digest()) - block = nodes[0].json_rpc( - 'block', - {"block_id": light_client_proof['outcome_proof']['block_hash']}) - expected_root = block['result']['header']['outcome_root'] - assert base58.b58decode( - expected_root - ) == block_outcome_root, f'expected outcome root {expected_root} actual {base58.b58encode(block_outcome_root)}' - # check that the light block header is valid - block_header_lite = light_client_proof['block_header_lite'] - computed_block_hash = compute_block_hash( - block_header_lite['inner_lite'], - block_header_lite['inner_rest_hash'], - block_header_lite['prev_block_hash']) - assert light_client_proof['outcome_proof'][ - 'block_hash'] == computed_block_hash.decode( - 'utf-8' - ), f'expected block hash {light_client_proof["outcome_proof"]["block_hash"]} actual {computed_block_hash}' - # check that block proof is valid - block_merkle_root = utils.compute_merkle_root_from_path( - light_client_proof['block_proof'], - light_client_proof['outcome_proof']['block_hash']) - assert base58.b58decode( - light_client_block['inner_lite']['block_merkle_root'] - ) == block_merkle_root, f'expected block merkle root {light_client_block["inner_lite"]["block_merkle_root"]} actual {base58.b58encode(block_merkle_root)}' - - -def test_outcome_proof(): - nodes = start_cluster( - 2, 0, 1, None, - [["epoch_length", 1000], ["block_producer_kickout_threshold", 80]], {}) - - latest_block_hash = nodes[0].get_latest_block().hash_bytes - deploy_contract_tx = transaction.sign_deploy_contract_tx( - nodes[0].signer_key, utils.load_test_contract(), 10, latest_block_hash) - deploy_contract_response = nodes[0].send_tx_and_wait(deploy_contract_tx, 15) - assert 'error' not in deploy_contract_response, deploy_contract_response - - check_transaction_outcome_proof(nodes, True, 20) - check_transaction_outcome_proof(nodes, False, 30) - - -if __name__ == '__main__': - test_outcome_proof() diff --git a/test-loop-tests/src/tests/light_client.rs b/test-loop-tests/src/tests/light_client.rs new file mode 100644 index 00000000000..73fb4108f92 --- /dev/null +++ b/test-loop-tests/src/tests/light_client.rs @@ -0,0 +1,361 @@ +use std::collections::{HashMap, HashSet}; + +use crate::setup::builder::TestLoopBuilder; +use crate::setup::env::TestLoopEnv; +use crate::utils::account::create_account_id; +use near_async::messaging::Handler; +use near_async::time::Duration; +use near_client::{GetBlockProof, GetExecutionOutcome, GetNextLightClientBlock}; +use near_o11y::testonly::init_test_logger; +use near_primitives::block_header::{ + Approval, ApprovalInner, compute_bp_hash_from_validator_stakes, +}; +use near_primitives::gas::Gas; +use near_primitives::hash::CryptoHash; +use near_primitives::merkle::{ + combine_hash, compute_root_from_path_and_item, verify_hash, verify_path, +}; +use near_primitives::transaction::SignedTransaction; +use near_primitives::types::validator_stake::ValidatorStake; +use near_primitives::types::{AccountId, Balance, TransactionOrReceiptId}; +use near_primitives::views::{LightClientBlockLiteView, LightClientBlockView}; + +fn light_client_block_hash(block: &LightClientBlockView) -> CryptoHash { + LightClientBlockLiteView { + prev_block_hash: block.prev_block_hash, + inner_rest_hash: block.inner_rest_hash, + inner_lite: block.inner_lite.clone(), + } + .hash() +} + +/// Validates a light client block against the previously known one, following NEP-25, exactly as +/// an external light client would: recompute the block hash, verify each approval signature against +/// the block producers of the block's epoch, check the >2/3 stake threshold, and on an epoch change +/// verify the next block producers hash. `block_producers` maps an epoch id to its ordered block +/// producers and is updated as new epochs are encountered. +fn validate_light_client_block( + last_known_block: &LightClientBlockView, + new_block: &LightClientBlockView, + block_producers: &mut HashMap>, +) { + let new_block_hash = light_client_block_hash(new_block); + let next_block_hash = combine_hash(&new_block.next_block_inner_hash, &new_block_hash); + + assert!( + new_block.inner_lite.epoch_id == last_known_block.inner_lite.epoch_id + || new_block.inner_lite.epoch_id == last_known_block.inner_lite.next_epoch_id, + "new block epoch must be the last known block's epoch or its next epoch", + ); + + let epoch_block_producers = &block_producers[&new_block.inner_lite.epoch_id]; + assert_eq!(new_block.approvals_after_next.len(), epoch_block_producers.len()); + + let approval_message = Approval::get_data_for_sig( + &ApprovalInner::Endorsement(next_block_hash), + new_block.inner_lite.height + 2, + ); + let mut total_stake = 0u128; + let mut approved_stake = 0u128; + for (approval, block_producer) in + new_block.approvals_after_next.iter().zip(epoch_block_producers) + { + total_stake += block_producer.stake().as_yoctonear(); + let Some(signature) = approval else { continue }; + approved_stake += block_producer.stake().as_yoctonear(); + assert!( + signature.verify(&approval_message, block_producer.public_key()), + "approval signature must verify against the block producer key", + ); + } + assert!(approved_stake * 3 > total_stake * 2, "approved stake must exceed 2/3 of total stake"); + + if new_block.inner_lite.epoch_id == last_known_block.inner_lite.next_epoch_id { + let next_bps = + new_block.next_bps.as_ref().expect("next_bps required when crossing into a new epoch"); + let next_stakes: Vec = next_bps.iter().cloned().map(Into::into).collect(); + assert_eq!( + compute_bp_hash_from_validator_stakes(&next_stakes, true), + new_block.inner_lite.next_bp_hash, + "next block producers hash must match", + ); + block_producers.insert(new_block.inner_lite.next_epoch_id, next_stakes); + } +} + +/// Walks the `next_light_client_block` sequence across several epochs and checks that each +/// returned light client block describes the canonical chain and that the walk terminates. +#[test] +fn test_next_light_client_block() { + init_test_logger(); + + let epoch_length = 5; + // Keep all blocks so the early seed block survives the multi-epoch walk. + let mut env = TestLoopBuilder::new() + .validators(4, 0) + .enable_rpc() + .epoch_length(epoch_length) + .gc_num_epochs_to_keep(100) + .build(); + + // A block in an early epoch to seed the walk from. + let start_hash = env.rpc_node().head().last_block_hash; + env.rpc_runner().run_until_head_height(8 * epoch_length); + let final_head_height = env.rpc_node().final_head().height; + + // Walk next_light_client_block forward, validating each block per NEP-25 as a light client + // would. The first block is unvalidated (no prior block to validate against) and seeds the + // block producers map from its next_bps; subsequent blocks are fully validated. + let mut block_producers: HashMap> = HashMap::new(); + let mut last_known_block: Option = None; + let mut last_block_hash = start_hash; + // (height, epoch_id, recomputed_hash) per returned block, checked against the chain below. + let mut steps: Vec<(u64, CryptoHash, CryptoHash)> = Vec::new(); + { + let mut rpc = env.rpc_node_mut(); + let view_client = rpc.view_client_actor(); + // Safety cap; the walk must terminate well before this. + for _ in 0..100 { + let Some(block) = + view_client.handle(GetNextLightClientBlock { last_block_hash }).unwrap() + else { + break; + }; + match &last_known_block { + Some(last_known_block) => { + validate_light_client_block(last_known_block, &block, &mut block_producers) + } + None => { + let next_bps = block.next_bps.clone().expect("first block must carry next_bps"); + let next_stakes = next_bps.into_iter().map(Into::into).collect(); + block_producers.insert(block.inner_lite.next_epoch_id, next_stakes); + } + } + let recomputed_hash = light_client_block_hash(&block); + steps.push((block.inner_lite.height, block.inner_lite.epoch_id, recomputed_hash)); + last_block_hash = recomputed_hash; + last_known_block = Some(LightClientBlockView::clone(&block)); + } + } + + assert!(steps.len() < 100, "light client walk did not terminate"); + + let chain = &env.rpc_node().client().chain; + let mut prev_height = 0; + let mut distinct_epochs = HashSet::new(); + for (height, epoch_id, recomputed_hash) in &steps { + assert!(*height > prev_height, "light client heights must strictly increase: {steps:?}"); + assert!(*height <= final_head_height, "light client block must be final: {steps:?}"); + assert_eq!( + chain.get_block_hash_by_height(*height).unwrap(), + *recomputed_hash, + "recomputed light client block hash must match the canonical chain at height {height}", + ); + prev_height = *height; + distinct_epochs.insert(*epoch_id); + } + + // Walking next_light_client_block should carry us across several epoch boundaries. + assert!(distinct_epochs.len() >= 3, "expected to traverse several epochs, got {steps:?}"); + + // Each step advances exactly one epoch: the walk never repeats or skips an epoch. + assert_eq!( + steps.len(), + distinct_epochs.len(), + "each light client step must be a new epoch: {steps:?}", + ); + + // The walk must climb all the way up to the head's final block, not terminate early. + let last_step_height = steps.last().unwrap().0; + assert!( + final_head_height - last_step_height <= epoch_length, + "light client walk stopped {} blocks below the final head: {steps:?}", + final_head_height - last_step_height, + ); +} + +/// The light client block tracks the last *final* block, so for a fixed last-known block it stays +/// in the previous epoch until the head's final block crosses into the new epoch. +#[test] +fn test_next_light_client_block_epoch_boundary() { + init_test_logger(); + + let epoch_length = 6; + let mut env = TestLoopBuilder::new() + .validators(4, 0) + .enable_rpc() + .epoch_length(epoch_length) + .gc_num_epochs_to_keep(100) + .build(); + + // Use the first block of an epoch as the fixed last-known block, so it sits well below the + // head's final block once we cross into the next epoch. + env.rpc_runner().run_until_head_height(2 * epoch_length); + let epoch_before = env.rpc_node().head().epoch_id; + env.rpc_runner().run_until(|node| node.head().epoch_id != epoch_before, Duration::seconds(20)); + let last_known_head = env.rpc_node().head(); + let last_known_hash = last_known_head.last_block_hash; + let last_known_epoch = last_known_head.epoch_id; + + // Advance until the head enters the next epoch; the head's final block still lags behind in the + // previous epoch. + env.rpc_runner() + .run_until(|node| node.head().epoch_id != last_known_epoch, Duration::seconds(20)); + let next_epoch = env.rpc_node().head().epoch_id; + + let in_previous_epoch = env + .rpc_node_mut() + .view_client_actor() + .handle(GetNextLightClientBlock { last_block_hash: last_known_hash }) + .unwrap() + .unwrap(); + assert_eq!( + in_previous_epoch.inner_lite.epoch_id, last_known_epoch.0, + "light client block should still lag in the previous epoch right after the boundary", + ); + + // Advance one block at a time, staying within the new epoch, until the head's final block + // crosses the boundary and the light client block follows it into the new epoch. + let in_new_epoch = loop { + env.rpc_runner().run_for_number_of_blocks(1); + assert_eq!( + env.rpc_node().head().epoch_id, + next_epoch, + "head must stay within the new epoch" + ); + let block = env + .rpc_node_mut() + .view_client_actor() + .handle(GetNextLightClientBlock { last_block_hash: last_known_hash }) + .unwrap() + .unwrap(); + if block.inner_lite.epoch_id == next_epoch.0 { + break block; + } + assert_eq!( + block.inner_lite.epoch_id, last_known_epoch.0, + "light client block must stay in the previous epoch until the final block crosses", + ); + }; + + // The invariant the light client head relies on: it shares the epoch of the block two heights + // onward, so its approvals can be verified with that epoch's block producers. + let two_ahead = env + .rpc_node() + .client() + .chain + .get_block_header_by_height(in_new_epoch.inner_lite.height + 2) + .unwrap(); + assert_eq!( + two_ahead.epoch_id().0, + in_new_epoch.inner_lite.epoch_id, + "light client block must share the epoch of the block two heights onward", + ); +} + +/// Deploys a contract, calls it (once succeeding, once failing), and verifies the +/// `light_client_proof` merkle proofs for the transaction and each of its receipt outcomes. +#[test] +// TODO(spice-test): Assess if this test is relevant for spice and if yes fix it. +#[cfg_attr(feature = "protocol_feature_spice", ignore)] +fn test_light_client_execution_outcome_proof() { + init_test_logger(); + + let user = create_account_id("user"); + let mut env = TestLoopBuilder::new() + .validators(2, 0) + .enable_rpc() + .epoch_length(1000) + .add_user_account(&user, Balance::from_near(10)) + .build(); + + // A block in the same epoch as the head, used to seed the light client block lookup. + let seed_hash = env.rpc_node().head().last_block_hash; + + let deploy_tx = env.rpc_node().tx_deploy_test_contract(&user); + env.rpc_runner().run_tx(deploy_tx, Duration::seconds(5)); + + // key = 42, value = 10, encoded as two little-endian u64s, matching `write_key_value`. + let mut args = Vec::with_capacity(16); + args.extend_from_slice(&42u64.to_le_bytes()); + args.extend_from_slice(&10u64.to_le_bytes()); + + let success_tx = env.rpc_node().tx_call( + &user, + &user, + "write_key_value", + args.clone(), + Balance::ZERO, + Gas::from_teragas(300), + ); + check_outcome_proofs(&mut env, &user, seed_hash, success_tx); + + let failing_tx = env.rpc_node().tx_call( + &user, + &user, + "write_key_value", + args, + Balance::ZERO, + Gas::from_gas(1000), + ); + check_outcome_proofs(&mut env, &user, seed_hash, failing_tx); +} + +fn check_outcome_proofs( + env: &mut TestLoopEnv, + account_id: &AccountId, + seed_hash: CryptoHash, + tx: SignedTransaction, +) { + let outcome = env.rpc_runner().execute_tx(tx, Duration::seconds(5)).unwrap(); + // Advance so the outcome's block is final and included in later blocks' merkle roots. + env.rpc_runner().run_for_number_of_blocks(4); + + let mut ids = vec![TransactionOrReceiptId::Transaction { + transaction_hash: outcome.transaction_outcome.id, + sender_id: account_id.clone(), + }]; + for receipt_outcome in &outcome.receipts_outcome { + ids.push(TransactionOrReceiptId::Receipt { + receipt_id: receipt_outcome.id, + receiver_id: account_id.clone(), + }); + } + + let mut rpc = env.rpc_node_mut(); + let view_client = rpc.view_client_actor(); + + // The light client head and the block merkle root all proofs are checked against. + let light_client_block = view_client + .handle(GetNextLightClientBlock { last_block_hash: seed_hash }) + .unwrap() + .unwrap(); + let light_client_head = light_client_block_hash(&light_client_block); + let block_merkle_root = light_client_block.inner_lite.block_merkle_root; + + for id in ids { + let execution_outcome = view_client.handle(GetExecutionOutcome { id }).unwrap(); + let outcome_proof = execution_outcome.outcome_proof; + let block_proof = view_client + .handle(GetBlockProof { + block_hash: outcome_proof.block_hash, + head_block_hash: light_client_head, + }) + .unwrap(); + + // outcome -> chunk outcome root -> block outcome root. + let chunk_outcome_root = + compute_root_from_path_and_item(&outcome_proof.proof, &outcome_proof.to_hashes()); + assert!(verify_path( + block_proof.block_header_lite.inner_lite.outcome_root, + &execution_outcome.outcome_root_proof, + &chunk_outcome_root, + )); + + // The light block header recomputes to the proof's block hash. + assert_eq!(block_proof.block_header_lite.hash(), outcome_proof.block_hash); + + // block hash -> light client head's block merkle root. + assert!(verify_hash(block_merkle_root, &block_proof.proof, outcome_proof.block_hash)); + } +} diff --git a/test-loop-tests/src/tests/mod.rs b/test-loop-tests/src/tests/mod.rs index 38a8280a0ad..cbcf48e3255 100644 --- a/test-loop-tests/src/tests/mod.rs +++ b/test-loop-tests/src/tests/mod.rs @@ -37,6 +37,7 @@ mod in_memory_tries; #[cfg(feature = "test_features")] mod indexer; mod jsonrpc; +mod light_client; mod malicious_chunk_producer; mod max_receipt_size; mod ml_dsa_access_key; From 410dd91f413a0392514fc594f59de97c590890f8 Mon Sep 17 00:00:00 2001 From: Darioush Jalali Date: Wed, 24 Jun 2026 10:54:10 -0700 Subject: [PATCH 2/2] fix import block ordering --- test-loop-tests/src/tests/light_client.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test-loop-tests/src/tests/light_client.rs b/test-loop-tests/src/tests/light_client.rs index 73fb4108f92..da2208b806b 100644 --- a/test-loop-tests/src/tests/light_client.rs +++ b/test-loop-tests/src/tests/light_client.rs @@ -1,5 +1,3 @@ -use std::collections::{HashMap, HashSet}; - use crate::setup::builder::TestLoopBuilder; use crate::setup::env::TestLoopEnv; use crate::utils::account::create_account_id; @@ -19,6 +17,7 @@ use near_primitives::transaction::SignedTransaction; use near_primitives::types::validator_stake::ValidatorStake; use near_primitives::types::{AccountId, Balance, TransactionOrReceiptId}; use near_primitives::views::{LightClientBlockLiteView, LightClientBlockView}; +use std::collections::{HashMap, HashSet}; fn light_client_block_hash(block: &LightClientBlockView) -> CryptoHash { LightClientBlockLiteView {