diff --git a/DEPRECATED.md b/DEPRECATED.md index 0615c67..c7e0b5f 100644 --- a/DEPRECATED.md +++ b/DEPRECATED.md @@ -1,3 +1,10 @@ +[Spec](https://github.com/leanEthereum/leanSpec) uses names like +`get_vote_target`,\ +but they should be converted to `getVoteTarget`.\ +Yes, text search doesn't work. + +--- + Module system uses subscriptions and messages.\ For example: diff --git a/src/blockchain/CMakeLists.txt b/src/blockchain/CMakeLists.txt index 51de4f2..34b0c98 100644 --- a/src/blockchain/CMakeLists.txt +++ b/src/blockchain/CMakeLists.txt @@ -5,6 +5,7 @@ # add_library(blockchain + fork_choice.cpp impl/block_storage_error.cpp impl/block_storage_impl.cpp impl/block_storage_initializer.cpp diff --git a/src/blockchain/fork_choice.cpp b/src/blockchain/fork_choice.cpp new file mode 100644 index 0000000..9703089 --- /dev/null +++ b/src/blockchain/fork_choice.cpp @@ -0,0 +1,314 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "blockchain/fork_choice.hpp" + +#include + +#include "types/signed_block.hpp" + +namespace lean { + void ForkChoiceStore::updateSafeTarget() { + // 2/3rd majority min voting voting weight for target selection + auto min_target_score = ceilDiv(config_.num_validators * 2, 3); + + safe_target_ = getForkChoiceHead( + blocks_, latest_justified_, latest_new_votes_, min_target_score); + } + + std::optional ForkChoiceStore::getLatestJustified() { + using Key = std::tuple; + std::optional max; + for (auto &state : states_ | std::views::values) { + Key key{state.latest_justified.slot, state.latest_justified.root}; + if (not max.has_value() or key > max.value()) { + max = key; + } + } + if (not max.has_value()) { + return std::nullopt; + } + auto &[slot, hash] = max.value(); + return Checkpoint{.root = hash, .slot = slot}; + } + + void ForkChoiceStore::updateHead() { + if (auto latest_justified = getLatestJustified()) { + latest_justified_ = latest_justified.value(); + } + head_ = + getForkChoiceHead(blocks_, latest_justified_, latest_known_votes_, 0); + + auto state_it = states_.find(head_); + if (state_it != states_.end()) { + latest_finalized_ = state_it->second.latest_finalized; + } + } + + void ForkChoiceStore::acceptNewVotes() { + for (auto &[voter, vote] : latest_new_votes_) { + latest_known_votes_[voter] = vote; + } + latest_new_votes_.clear(); + updateHead(); + } + + void ForkChoiceStore::tickInterval(bool has_proposal) { + ++time_; + auto current_interval = time_ % INTERVALS_PER_SLOT; + if (current_interval == 0) { + if (has_proposal) { + acceptNewVotes(); + } + } else if (current_interval == 1) { + // validators will vote in this interval using safe target previously + // computed + } else if (current_interval == 2) { + updateSafeTarget(); + } else { + acceptNewVotes(); + } + } + + void ForkChoiceStore::advanceTime(Interval time, bool has_proposal) { + // Calculate the number of intervals that have passed since genesis + auto tick_interval_time = + (time - config_.genesis_time) / SECONDS_PER_INTERVAL; + + // Tick the store one interval at a time until the target time is reached + while (time_ < tick_interval_time) { + // Determine if a proposal should be signaled for the next interval + auto should_signal_proposal = + has_proposal and (time_ + 1) == tick_interval_time; + + // Tick the interval and potentially signal a proposal + tickInterval(should_signal_proposal); + } + } + + BlockHash ForkChoiceStore::getProposalHead(Slot slot) { + auto slot_time = config_.genesis_time + slot * SECONDS_PER_SLOT; + // this would be a no-op if the store is already ticked to the current + // time + advanceTime(slot_time, true); + // this would be a no-op or just a fast compute if store was already + // ticked to accept new votes for a registered validator with the node + acceptNewVotes(); + return head_; + } + + Checkpoint ForkChoiceStore::getVoteTarget() const { + // Start from head as target candidate + auto target_block_root = head_; + + // If there is no very recent safe target, then vote for the k'th ancestor + // of the head + for (auto i = 0; i < 3; ++i) { + if (blocks_.at(target_block_root).slot > blocks_.at(safe_target_).slot) { + target_block_root = blocks_.at(target_block_root).parent_root; + } + } + + // If the latest finalized slot is very far back, then only some slots are + // valid to justify, make sure the target is one of those + while (not isJustifiableSlot(latest_finalized_.slot, + blocks_.at(target_block_root).slot)) { + target_block_root = blocks_.at(target_block_root).parent_root; + } + + return Checkpoint{ + .root = target_block_root, + .slot = blocks_.at(target_block_root).slot, + }; + } + + outcome::result ForkChoiceStore::validateAttestation( + const SignedVote &signed_vote) { + auto &vote = signed_vote.data; + + // Validate vote targets exist in store + if (not blocks_.contains(vote.source.root)) { + return Error::INVALID_ATTESTATION; + } + if (not blocks_.contains(vote.target.root)) { + return Error::INVALID_ATTESTATION; + } + + // Validate slot relationships + auto &source_block = blocks_.at(vote.source.root); + auto &target_block = blocks_.at(vote.target.root); + + if (source_block.slot > target_block.slot) { + return Error::INVALID_ATTESTATION; + } + if (vote.source.slot > vote.target.slot) { + return Error::INVALID_ATTESTATION; + } + + // Validate checkpoint slots match block slots + if (source_block.slot != vote.source.slot) { + return Error::INVALID_ATTESTATION; + } + if (target_block.slot != vote.target.slot) { + return Error::INVALID_ATTESTATION; + } + + // Validate attestation is not too far in the future + auto current_slot = time_ / SECONDS_PER_INTERVAL; + if (vote.slot > current_slot + 1) { + return Error::INVALID_ATTESTATION; + } + + return outcome::success(); + } + + outcome::result ForkChoiceStore::processAttestation( + const SignedVote &signed_vote, bool is_from_block) { + // Validate attestation structure and constraints + BOOST_OUTCOME_TRY(validateAttestation(signed_vote)); + + auto &validator_id = signed_vote.data.validator_id; + auto &vote = signed_vote.data; + + if (is_from_block) { + // update latest known votes if this is latest + auto latest_known_vote = latest_known_votes_.find(validator_id); + if (latest_known_vote == latest_known_votes_.end() + or latest_known_vote->second.slot < vote.slot) { + latest_known_votes_.insert_or_assign(validator_id, vote.target); + } + + // clear from new votes if this is latest + auto latest_new_vote = latest_new_votes_.find(validator_id); + if (latest_new_vote != latest_new_votes_.end() + and latest_new_vote->second.slot <= vote.target.slot) { + latest_new_votes_.erase(latest_new_vote); + } + } else { + // forkchoice should be correctly ticked to current time before importing + // gossiped attestations + auto time_slots = time_ / SECONDS_PER_INTERVAL; + if (vote.slot > time_slots) { + return Error::INVALID_ATTESTATION; + } + + // update latest new votes if this is the latest + auto latest_new_vote = latest_new_votes_.find(validator_id); + if (latest_new_vote == latest_new_votes_.end() + or latest_new_vote->second.slot < vote.target.slot) { + latest_new_votes_.insert_or_assign(validator_id, vote.target); + } + } + + return outcome::success(); + } + + outcome::result ForkChoiceStore::onBlock(Block block) { + block.setHash(); + auto block_hash = block.hash(); + // If the block is already known, ignore it + if (blocks_.contains(block_hash)) { + return outcome::success(); + } + + auto &parent_state = states_.at(block.parent_root); + // at this point parent state should be available so node should sync parent + // chain if not available before adding block to forkchoice + + // Get post state from STF (State Transition Function) + auto state = + stf_.stateTransition({.message = block}, parent_state, true).value(); + blocks_.emplace(block_hash, block); + states_.emplace(block_hash, std::move(state)); + + // add block votes to the onchain known last votes + for (auto &signed_vote : block.body.attestations) { + // Add block votes to the onchain known last votes + BOOST_OUTCOME_TRY(processAttestation(signed_vote, true)); + } + + updateHead(); + + return outcome::success(); + } + + BlockHash getForkChoiceHead(const ForkChoiceStore::Blocks &blocks, + const Checkpoint &root, + const ForkChoiceStore::Votes &latest_votes, + uint64_t min_score) { + // If no votes, return the starting root immediately + if (latest_votes.empty()) { + return root.root; + } + + // For each block, count the number of votes for that block. A vote for + // any descendant of a block also counts as a vote for that block + std::unordered_map vote_weights; + auto get_weight = [&](const BlockHash &hash) { + auto it = vote_weights.find(hash); + return it != vote_weights.end() ? it->second : 0; + }; + + for (auto &vote : latest_votes | std::views::values) { + auto block_it = blocks.find(vote.root); + if (block_it != blocks.end()) { + while (block_it->second.slot > root.slot) { + ++vote_weights[block_it->first]; + block_it = blocks.find(block_it->second.parent_root); + BOOST_ASSERT(block_it != blocks.end()); + } + } + } + + // Identify the children of each block + using Key = std::tuple; + std::unordered_multimap children_map; + for (auto &[hash, block] : blocks) { + if (block.slot > root.slot and get_weight(hash) >= min_score) { + children_map.emplace(block.parent_root, Checkpoint::from(block)); + } + } + + // Start at the root (latest justified hash or genesis) and repeatedly + // choose the child with the most latest votes, tiebreaking by slot then + // hash + auto current = root.root; + while (true) { + auto [begin, end] = children_map.equal_range(current); + if (begin == end) { + return current; + } + Key max; + for (auto it = begin; it != end; ++it) { + Key key{ + get_weight(it->second.root), + it->second.slot, + it->second.root, + }; + if (it == begin or key > max) { + max = key; + } + } + current = std::get<2>(max); + } + } + + ForkChoiceStore getForkchoiceStore(State anchor_state, Block anchor_block) { + BOOST_ASSERT(anchor_block.state_root == sszHash(anchor_state)); + auto anchor_root = anchor_block.hash(); + ForkChoiceStore store{ + .time_ = anchor_block.slot * INTERVALS_PER_SLOT, + .config_ = anchor_state.config, + .head_ = anchor_root, + .safe_target_ = anchor_root, + .latest_justified_ = anchor_state.latest_justified, + .latest_finalized_ = anchor_state.latest_finalized, + }; + store.blocks_.emplace(anchor_root, std::move(anchor_block)); + store.states_.emplace(anchor_root, std::move(anchor_state)); + return store; + } +} // namespace lean diff --git a/src/blockchain/fork_choice.hpp b/src/blockchain/fork_choice.hpp new file mode 100644 index 0000000..eb99508 --- /dev/null +++ b/src/blockchain/fork_choice.hpp @@ -0,0 +1,102 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include + +#include + +#include "blockchain/is_justifiable_slot.hpp" +#include "blockchain/state_transition_function.hpp" +#include "types/block.hpp" +#include "types/state.hpp" +#include "types/validator_index.hpp" +#include "utils/ceil_div.hpp" + +namespace lean { + class ForkChoiceStore { + public: + using Blocks = std::unordered_map; + using Votes = std::unordered_map; + + enum class Error { + INVALID_ATTESTATION, + }; + Q_ENUM_ERROR_CODE_FRIEND(Error) { + using E = decltype(e); + switch (e) { + case E::INVALID_ATTESTATION: + return "Invalid attestation"; + } + abort(); + } + + // Compute the latest block that the validator is allowed to choose as the + // target + void updateSafeTarget(); + + std::optional getLatestJustified(); + + // Updates the store's latest justified checkpoint, head, and latest + // finalized state. + void updateHead(); + + // Process new votes that the staker has received. Vote processing is done + // at a particular time, because of safe target and view merge rules. + // Accepts the latest new votes, merges them into the known votes, and then + // updates the fork-choice head. + void acceptNewVotes(); + + void tickInterval(bool has_proposal); + + // called every interval and with has_proposal flag on the new slot interval + // if node has a validator with proposal in this slot so as to not delay + // accepting new votes and parallelize compute. + // Ticks the store forward in intervals until it reaches the given time. + void advanceTime(Interval time, bool has_proposal); + + BlockHash getProposalHead(Slot slot); + + /** + * Calculates the target checkpoint for a vote based on the head, safe + * target, and latest finalized state. + */ + Checkpoint getVoteTarget() const; + + // Validate incoming attestation before processing. + // Performs basic validation checks on attestation structure and timing. + outcome::result validateAttestation(const SignedVote &signed_vote); + + // Validates and processes a new attestation (a signed vote), updating the + // store's latest votes. + outcome::result processAttestation(const SignedVote &signed_vote, + bool is_from_block); + + // Processes a new block, updates the store, and triggers a head update. + outcome::result onBlock(Block block); + + STF stf_; + Interval time_ = 0; + Config config_; + BlockHash head_; + BlockHash safe_target_; + Checkpoint latest_justified_; + Checkpoint latest_finalized_; + Blocks blocks_; + std::unordered_map states_; + Votes latest_known_votes_; + Votes latest_new_votes_; + }; + + BlockHash getForkChoiceHead(const ForkChoiceStore::Blocks &blocks, + const Checkpoint &root, + const ForkChoiceStore::Votes &latest_votes, + uint64_t min_score); + + ForkChoiceStore getForkchoiceStore(State anchor_state, Block anchor_block); +} // namespace lean diff --git a/src/types/block.hpp b/src/types/block.hpp index b99deb2..c92047f 100644 --- a/src/types/block.hpp +++ b/src/types/block.hpp @@ -29,11 +29,11 @@ namespace lean { return header; } - std::optional hash_cached; + mutable std::optional hash_cached; const BlockHash &hash() const { return hash_cached.value(); } - void setHash() { + void setHash() const { auto header = getHeader(); header.updateHash(); auto hash = header.hash(); diff --git a/src/types/checkpoint.hpp b/src/types/checkpoint.hpp index f966074..90ffaf2 100644 --- a/src/types/checkpoint.hpp +++ b/src/types/checkpoint.hpp @@ -15,7 +15,11 @@ namespace lean { struct Checkpoint : ssz::ssz_container { qtils::ByteArr<32> root; - Slot slot; + Slot slot = 0; + + static Checkpoint from(const auto &v) { + return Checkpoint{.root = v.hash(), .slot = v.slot}; + } SSZ_CONT(root, slot); }; diff --git a/src/types/constants.hpp b/src/types/constants.hpp index 4a2f7bc..1808d5b 100644 --- a/src/types/constants.hpp +++ b/src/types/constants.hpp @@ -15,8 +15,11 @@ using qtils::literals::operator""_bytes; namespace lean { - static constexpr uint64_t SLOT_DURATION_MS = 4000; // 4 seconds - static constexpr uint64_t INTERVALS_PER_SLOT = 4; // 4 intervals by 1 second + static constexpr uint64_t INTERVALS_PER_SLOT = 4; // 4 intervals by 1 second + static constexpr uint64_t SECONDS_PER_INTERVAL = 1; + static constexpr uint64_t SECONDS_PER_SLOT = + SECONDS_PER_INTERVAL * INTERVALS_PER_SLOT; + static constexpr uint64_t SLOT_DURATION_MS = SECONDS_PER_SLOT * 1000; // State list lengths diff --git a/src/types/slot.hpp b/src/types/slot.hpp index c511811..50cf537 100644 --- a/src/types/slot.hpp +++ b/src/types/slot.hpp @@ -10,4 +10,5 @@ namespace lean { using Slot = uint64_t; + using Interval = uint64_t; } // namespace lean diff --git a/src/types/validator_index.hpp b/src/types/validator_index.hpp new file mode 100644 index 0000000..e1ae178 --- /dev/null +++ b/src/types/validator_index.hpp @@ -0,0 +1,13 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include + +namespace lean { + using ValidatorIndex = uint64_t; +} // namespace lean diff --git a/src/types/vote.hpp b/src/types/vote.hpp index e18dd48..e70f4ba 100644 --- a/src/types/vote.hpp +++ b/src/types/vote.hpp @@ -11,8 +11,8 @@ namespace lean { struct Vote : public ssz::ssz_container { - uint64_t validator_id; - uint64_t slot; + uint64_t validator_id = 0; + uint64_t slot = 0; Checkpoint head; Checkpoint target; Checkpoint source; diff --git a/src/utils/ceil_div.hpp b/src/utils/ceil_div.hpp new file mode 100644 index 0000000..7f5e70d --- /dev/null +++ b/src/utils/ceil_div.hpp @@ -0,0 +1,15 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include + +namespace lean { + auto ceilDiv(const std::integral auto &l, const std::integral auto &r) { + return (l + r - 1) / r; + } +} // namespace lean diff --git a/tests/unit/blockchain/CMakeLists.txt b/tests/unit/blockchain/CMakeLists.txt index 9d5bde8..f683fdd 100644 --- a/tests/unit/blockchain/CMakeLists.txt +++ b/tests/unit/blockchain/CMakeLists.txt @@ -13,6 +13,13 @@ target_link_libraries(block_storage_test storage ) +addtest(fork_choice_test + fork_choice_test.cpp + ) +target_link_libraries(fork_choice_test + blockchain + ) + addtest(state_transition_function_test state_transition_function_test.cpp ) diff --git a/tests/unit/blockchain/fork_choice_test.cpp b/tests/unit/blockchain/fork_choice_test.cpp new file mode 100644 index 0000000..2644961 --- /dev/null +++ b/tests/unit/blockchain/fork_choice_test.cpp @@ -0,0 +1,694 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "blockchain/fork_choice.hpp" + +#include + +#include +#include +#include + +#include "qtils/test/outcome.hpp" + +using lean::Block; +using lean::Checkpoint; +using lean::ForkChoiceStore; +using lean::getForkChoiceHead; +using lean::INTERVALS_PER_SLOT; +using lean::SignedVote; + +lean::BlockHash testHash(std::string_view s) { + lean::BlockHash hash; + EXPECT_LE(s.size(), hash.size()); + memcpy(hash.data(), s.data(), s.size()); + return hash; +} + +SignedVote makeVote(const Block &source, const Block &target) { + return SignedVote{ + .data = + { + .slot = target.slot, + .head = Checkpoint::from(target), + .target = Checkpoint::from(target), + .source = Checkpoint::from(source), + }, + }; +} + +std::optional getVote(const ForkChoiceStore::Votes &votes) { + auto it = votes.find(0); + if (it == votes.end()) { + return std::nullopt; + } + return it->second; +} + +lean::Config config{ + .num_validators = 100, + .genesis_time = 1000, +}; + +auto makeBlockMap(std::vector blocks) { + ForkChoiceStore::Blocks map; + for (auto block : blocks) { + block.setHash(); + map.emplace(block.hash(), block); + } + return map; +} + +std::vector makeBlocks(lean::Slot count) { + std::vector blocks; + auto parent_root = testHash("genesis-parent"); + for (lean::Slot slot = 0; slot < count; ++slot) { + lean::Block block{ + .slot = slot, + .parent_root = parent_root, + .state_root = testHash(std::format("state-{}", slot)), + }; + block.setHash(); + blocks.emplace_back(block); + parent_root = block.hash(); + } + return blocks; +} + +// Test basic vote target selection. +TEST(TestVoteTargetCalculation, test_get_vote_target_basic) { + auto blocks = makeBlocks(2); + auto &genesis = blocks.at(0); + auto &block_1 = blocks.at(1); + + // Recent finalization + auto finalized = Checkpoint::from(genesis); + + ForkChoiceStore store{ + .time_ = 100, + .config_ = config, + .head_ = block_1.hash(), + .safe_target_ = block_1.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + }; + + auto target = store.getVoteTarget(); + + // Should target the head block since finalization is recent + EXPECT_EQ(target.root, block_1.hash()); + EXPECT_EQ(target.slot, 1); +} + +// Test vote target selection with very old finalized checkpoint. +TEST(TestVoteTargetCalculation, test_vote_target_with_old_finalized) { + auto blocks = makeBlocks(10); + + // Very old finalized checkpoint (slot 0) + auto finalized = Checkpoint::from(blocks.at(0)); + + // Current head is at slot 9 + auto &head = blocks.at(9); + + ForkChoiceStore store{ + .time_ = 100, + .config_ = config, + .head_ = head.hash(), + .safe_target_ = head.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + }; + + auto target = store.getVoteTarget(); + + // Should return a valid checkpoint + EXPECT_TRUE(store.blocks_.contains(target.root)); +} + +// Test that vote target walks back from head when needed. +TEST(TestVoteTargetCalculation, test_vote_target_walks_back_from_head) { + auto blocks = makeBlocks(3); + auto &genesis = blocks.at(0); + auto &block_1 = blocks.at(1); + auto &block_2 = blocks.at(2); + + // Finalized at genesis + auto finalized = Checkpoint::from(genesis); + + ForkChoiceStore store{ + .time_ = 100, + .config_ = config, + .head_ = block_2.hash(), + // Different from head + .safe_target_ = block_1.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + }; + + auto target = store.getVoteTarget(); + + // Should walk back towards safe target + EXPECT_TRUE(store.blocks_.contains(target.root)); +} + +// Test that vote target respects justifiable slot constraints. +TEST(TestVoteTargetCalculation, test_vote_target_justifiable_slot_constraint) { + // Create a long chain to test slot justification + auto blocks = makeBlocks(21); + + // Finalized very early (slot 0) + auto finalized = Checkpoint::from(blocks.at(0)); + + // Head at slot 20 + auto &head = blocks.at(20); + + ForkChoiceStore store{ + .time_ = 100, + .config_ = config, + .head_ = head.hash(), + .safe_target_ = head.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + }; + + auto target = store.getVoteTarget(); + + // Should return a justifiable slot + EXPECT_TRUE(store.blocks_.contains(target.root)); + + // Check that the slot is justifiable after finalized slot + EXPECT_TRUE(lean::isJustifiableSlot(finalized.slot, target.slot)); +} + +// Test vote target when head and safe_target are the same. +TEST(TestVoteTargetCalculation, + test_vote_target_with_same_head_and_safe_target) { + auto blocks = makeBlocks(2); + auto &genesis = blocks.at(0); + auto &head = blocks.at(1); + + auto finalized = Checkpoint::from(genesis); + + ForkChoiceStore store{ + .time_ = 500, + .config_ = config, + .head_ = head.hash(), + // Same as head + .safe_target_ = head.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + }; + + auto target = store.getVoteTarget(); + + // Should target the head (which is also safe_target) + EXPECT_EQ(target.root, head.hash()); + EXPECT_EQ(target.slot, head.slot); +} + +// Test get_fork_choice_head with validator votes. +TEST(TestForkChoiceHeadFunction, test_get_fork_choice_head_with_votes) { + auto blocks = makeBlocks(3); + auto &root = blocks.at(0); + auto &target = blocks.at(2); + + auto head = getForkChoiceHead(makeBlockMap(blocks), + Checkpoint::from(root), + {{0, Checkpoint::from(target)}}, + 0); + + EXPECT_EQ(head, target.hash()); +} + +// Test get_fork_choice_head with no votes returns the root. +TEST(TestForkChoiceHeadFunction, test_get_fork_choice_head_no_votes) { + auto blocks = makeBlocks(3); + auto &root = blocks.at(0); + + auto head = + getForkChoiceHead(makeBlockMap(blocks), Checkpoint::from(root), {}, 0); + + EXPECT_EQ(head, root.hash()); +} + +// Test get_fork_choice_head respects minimum score. +TEST(TestForkChoiceHeadFunction, test_get_fork_choice_head_with_min_score) { + auto blocks = makeBlocks(3); + auto &root = blocks.at(0); + auto &target = blocks.at(2); + + auto head = getForkChoiceHead(makeBlockMap(blocks), + Checkpoint::from(root), + {{0, Checkpoint::from(target)}}, + 2); + + EXPECT_EQ(head, root.hash()); +} + +// Test get_fork_choice_head with multiple votes. +TEST(TestForkChoiceHeadFunction, test_get_fork_choice_head_multiple_votes) { + auto blocks = makeBlocks(3); + auto &root = blocks.at(0); + auto &target = blocks.at(2); + + auto head = getForkChoiceHead(makeBlockMap(blocks), + Checkpoint::from(root), + { + {0, Checkpoint::from(target)}, + {1, Checkpoint::from(target)}, + {2, Checkpoint::from(target)}, + }, + 0); + + EXPECT_EQ(head, target.hash()); +} + +// Test basic safe target update. +TEST(TestSafeTargetComputation, test_update_safe_target_basic) { + auto blocks = makeBlocks(1); + auto &genesis = blocks.at(0); + + auto finalized = Checkpoint::from(genesis); + + ForkChoiceStore store{ + .time_ = 100, + .config_ = config, + .head_ = genesis.hash(), + .safe_target_ = genesis.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + }; + + // Update safe target (this tests the method exists and runs) + store.updateSafeTarget(); + + // Safe target should be set + EXPECT_EQ(store.safe_target_, genesis.hash()); +} + +// Test safe target computation with votes. +TEST(TestSafeTargetComputation, test_safe_target_with_votes) { + auto blocks = makeBlocks(2); + auto &genesis = blocks.at(0); + auto &block_1 = blocks.at(1); + + auto finalized = Checkpoint::from(genesis); + + ForkChoiceStore store{ + .time_ = 100, + .config_ = config, + .head_ = block_1.hash(), + .safe_target_ = genesis.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + // Add some new votes + .latest_new_votes_ = + { + {0, Checkpoint::from(block_1)}, + {1, Checkpoint::from(block_1)}, + }, + }; + + // Update safe target with votes + store.updateSafeTarget(); + + // Should have computed a safe target + EXPECT_TRUE(store.blocks_.contains(store.safe_target_)); +} + +// Test vote target with only one block. +TEST(TestEdgeCases, test_vote_target_single_block) { + auto blocks = makeBlocks(1); + auto &genesis = blocks.at(0); + + auto finalized = Checkpoint::from(genesis); + + ForkChoiceStore store{ + .time_ = 100, + .config_ = config, + .head_ = genesis.hash(), + .safe_target_ = genesis.hash(), + .latest_justified_ = finalized, + .latest_finalized_ = finalized, + .blocks_ = makeBlockMap(blocks), + }; + + auto target = store.getVoteTarget(); + + EXPECT_EQ(target.root, genesis.hash()); + EXPECT_EQ(target.slot, genesis.slot); +} + +// Test validation of a valid attestation. +TEST(TestAttestationValidation, test_validate_attestation_valid) { + auto blocks = makeBlocks(3); + auto &source = blocks.at(1); + auto &target = blocks.at(2); + + ForkChoiceStore sample_store{ + .time_ = 100, + .blocks_ = makeBlockMap(blocks), + }; + + // Create valid signed vote + // Should validate without error + EXPECT_OUTCOME_SUCCESS( + sample_store.validateAttestation(makeVote(source, target))); +} + +// Test validation fails when source slot > target slot. +TEST(TestAttestationValidation, test_validate_attestation_slot_order_invalid) { + auto blocks = makeBlocks(3); + // Later than target + auto &source = blocks.at(2); + // Earlier than source + auto &target = blocks.at(1); + + ForkChoiceStore sample_store{ + .time_ = 100, + .blocks_ = makeBlockMap(blocks), + }; + + // Create invalid signed vote (source > target slot) + EXPECT_OUTCOME_ERROR( + sample_store.validateAttestation(makeVote(source, target))); +} + +// Test validation fails when referenced blocks are missing. +TEST(TestAttestationValidation, test_validate_attestation_missing_blocks) { + ForkChoiceStore sample_store; + + // Create signed vote referencing missing blocks + EXPECT_OUTCOME_ERROR(sample_store.validateAttestation({})); +} + +// Test validation fails when checkpoint slots don't match block slots. +TEST(TestAttestationValidation, + test_validate_attestation_checkpoint_slot_mismatch) { + auto blocks = makeBlocks(3); + auto &source = blocks.at(1); + auto &target = blocks.at(2); + + ForkChoiceStore sample_store{ + .time_ = 100, + .blocks_ = makeBlockMap(blocks), + }; + + // Create signed vote with mismatched checkpoint slot + auto vote = makeVote(source, target); + ++vote.data.source.slot; + EXPECT_OUTCOME_ERROR(sample_store.validateAttestation(vote)); +} + +// Test validation fails for attestations too far in the future. +TEST(TestAttestationValidation, test_validate_attestation_too_far_future) { + auto blocks = makeBlocks(10); + auto &source = blocks.at(1); + auto &target = blocks.at(9); + + ForkChoiceStore sample_store{ + .blocks_ = makeBlockMap(blocks), + }; + + // Create signed vote for future slot + EXPECT_OUTCOME_ERROR( + sample_store.validateAttestation(makeVote(source, target))); +} + +// Test processing attestation from network gossip. +TEST(TestAttestationProcessing, test_process_network_attestation) { + auto blocks = makeBlocks(3); + auto &source = blocks.at(1); + auto &target = blocks.at(2); + + ForkChoiceStore sample_store{ + .time_ = 100, + .blocks_ = makeBlockMap(blocks), + }; + + // Create valid signed vote + // Process as network attestation + EXPECT_OUTCOME_SUCCESS( + sample_store.processAttestation(makeVote(source, target), false)); + + // Vote should be added to new votes + EXPECT_EQ(getVote(sample_store.latest_new_votes_), Checkpoint::from(target)); +} + +// Test processing attestation from a block. +TEST(TestAttestationProcessing, test_process_block_attestation) { + auto blocks = makeBlocks(3); + auto &source = blocks.at(1); + auto &target = blocks.at(2); + + ForkChoiceStore sample_store{ + .time_ = 100, + .blocks_ = makeBlockMap(blocks), + }; + + // Create valid signed vote + // Process as block attestation + EXPECT_OUTCOME_SUCCESS( + sample_store.processAttestation(makeVote(source, target), true)); + + // Vote should be added to known votes + EXPECT_EQ(getVote(sample_store.latest_known_votes_), + Checkpoint::from(target)); +} + +// Test that newer attestations supersede older ones. +TEST(TestAttestationProcessing, test_process_attestation_superseding) { + auto blocks = makeBlocks(3); + auto &target_1 = blocks.at(1); + auto &target_2 = blocks.at(2); + + ForkChoiceStore sample_store{ + .time_ = 100, + .blocks_ = makeBlockMap(blocks), + }; + + // Process first (older) attestation + EXPECT_OUTCOME_SUCCESS( + sample_store.processAttestation(makeVote(target_1, target_1), false)); + + // Process second (newer) attestation + EXPECT_OUTCOME_SUCCESS( + sample_store.processAttestation(makeVote(target_1, target_2), false)); + + // Should have the newer vote + EXPECT_EQ(getVote(sample_store.latest_new_votes_), + Checkpoint::from(target_2)); +} + +// Test that block attestations remove corresponding new votes. +TEST(TestAttestationProcessing, + test_process_attestation_from_block_supersedes_new) { + auto blocks = makeBlocks(3); + auto &source = blocks.at(1); + auto &target = blocks.at(2); + + ForkChoiceStore sample_store{ + .time_ = 100, + .blocks_ = makeBlockMap(blocks), + }; + + // First process as network vote + auto signed_vote = makeVote(source, target); + EXPECT_OUTCOME_SUCCESS(sample_store.processAttestation(signed_vote, false)); + + // Should be in new votes + ASSERT_TRUE(getVote(sample_store.latest_new_votes_)); + + // Process same vote as block attestation + EXPECT_OUTCOME_SUCCESS(sample_store.processAttestation(signed_vote, true)); + + // Vote should move to known votes and be removed from new votes + ASSERT_FALSE(getVote(sample_store.latest_new_votes_)); + EXPECT_EQ(getVote(sample_store.latest_known_votes_), + Checkpoint::from(target)); +} + +// Test basic time advancement. +TEST(TestTimeAdvancement, test_advance_time_basic) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + + auto initial_time = sample_store.time_; + // Much later time + auto target_time = sample_store.config_.genesis_time + 200; + + sample_store.advanceTime(target_time, true); + + // Time should advance + EXPECT_GT(sample_store.time_, initial_time); +} + +// Test time advancement without proposal. +TEST(TestTimeAdvancement, test_advance_time_no_proposal) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + + auto initial_time = sample_store.time_; + auto target_time = sample_store.config_.genesis_time + 100; + + sample_store.advanceTime(target_time, false); + + // Time should still advance + EXPECT_GE(sample_store.time_, initial_time); +} + +// Test advance_time when already at target time. +TEST(TestTimeAdvancement, test_advance_time_already_current) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + + auto initial_time = sample_store.time_; + auto current_target = sample_store.config_.genesis_time + initial_time; + + // Try to advance to current time (should be no-op) + sample_store.advanceTime(current_target, false); + + // Should not change significantly + EXPECT_LE(std::abs(sample_store.time_ - initial_time), 10); +} + +// Test advance_time with small time increment. +TEST(TestTimeAdvancement, test_advance_time_small_increment) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + + auto initial_time = sample_store.time_; + auto target_time = sample_store.config_.genesis_time + initial_time + 1; + + sample_store.advanceTime(target_time, false); + + // Should advance by small amount + EXPECT_GE(sample_store.time_, initial_time); +} + +// Test basic interval ticking. +TEST(TestIntervalTicking, test_tick_interval_basic) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + auto initial_time = sample_store.time_; + + // Tick one interval forward + sample_store.tickInterval(false); + + // Time should advance by one interval + EXPECT_EQ(sample_store.time_, initial_time + 1); +} + +// Test interval ticking with proposal. +TEST(TestIntervalTicking, test_tick_interval_with_proposal) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + auto initial_time = sample_store.time_; + + sample_store.tickInterval(false); + + // Time should advance + EXPECT_EQ(sample_store.time_, initial_time + 1); +} + +// Test sequence of interval ticks. +TEST(TestIntervalTicking, test_tick_interval_sequence) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + auto initial_time = sample_store.time_; + + // Tick multiple intervals + for (auto i = 0; i < 5; ++i) { + sample_store.tickInterval(i % 2 == 0); + } + + // Should have advanced by 5 intervals + EXPECT_EQ(sample_store.time_, initial_time + 5); +} + +// Test different actions performed based on interval phase. +TEST(TestIntervalTicking, test_tick_interval_actions_by_phase) { + // Reset store to known state + ForkChoiceStore sample_store; + + // Add some test votes for processing + sample_store.latest_new_votes_.emplace(0, Checkpoint{.slot = 1}); + + // Tick through a complete slot cycle + for (lean::Interval interval = 0; interval < INTERVALS_PER_SLOT; ++interval) { + // Proposal only in first interval + auto has_proposal = interval == 0; + sample_store.tickInterval(has_proposal); + + auto current_interval = sample_store.time_ % INTERVALS_PER_SLOT; + auto expected_interval = (interval + 1) % INTERVALS_PER_SLOT; + EXPECT_EQ(current_interval, expected_interval); + } +} + +// Test getting proposal head for a slot. +TEST(TestProposalHeadTiming, test_get_proposal_head_basic) { + auto blocks = makeBlocks(1); + auto &genesis = blocks.at(0); + ForkChoiceStore sample_store{ + .time_ = 100, + .config_ = config, + .head_ = genesis.hash(), + .blocks_ = makeBlockMap(blocks), + }; + + // Get proposal head for slot 0 + auto head = sample_store.getProposalHead(genesis.slot); + + // Should return current head + EXPECT_EQ(head, sample_store.head_); +} + +// Test that get_proposal_head advances store time appropriately. +TEST(TestProposalHeadTiming, test_get_proposal_head_advances_time) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + auto initial_time = sample_store.time_; + + // Get proposal head for a future slot + sample_store.getProposalHead(5); + + // Time may have advanced (depending on slot timing) + // This is mainly testing that the call doesn't fail + EXPECT_GE(sample_store.time_, initial_time); +} + +// Test that get_proposal_head processes pending votes. +TEST(TestProposalHeadTiming, test_get_proposal_head_processes_votes) { + ForkChoiceStore sample_store{ + .time_ = 100, + }; + + // Add some new votes + sample_store.latest_new_votes_.emplace(0, Checkpoint{.slot = 1}); + + // Get proposal head should process votes + sample_store.getProposalHead(1); + + // Votes should have been processed (moved to known votes) + ASSERT_FALSE(getVote(sample_store.latest_new_votes_)); + ASSERT_TRUE(getVote(sample_store.latest_known_votes_)); +} diff --git a/vcpkg-overlay/qtils/portfile.cmake b/vcpkg-overlay/qtils/portfile.cmake index 77ded8d..994cd5f 100644 --- a/vcpkg-overlay/qtils/portfile.cmake +++ b/vcpkg-overlay/qtils/portfile.cmake @@ -2,8 +2,8 @@ vcpkg_check_linkage(ONLY_STATIC_LIBRARY) vcpkg_from_github( OUT_SOURCE_PATH SOURCE_PATH REPO qdrvm/qtils - REF refs/tags/v0.1.4 - SHA512 124f3711eb64df3a2e207bff8bf953ccc2dfa838f21da72a1cc77c8aec95def350e70607adf9d8e7123e56d5bffcf830052f607dfa12badc7efe463bd0be747c + REF refs/tags/v0.1.5 + SHA512 12fe763fdfab70bb90fb8687efdde63bb0b04c0e3f50efea73115997ed278892e99e2b49fa13b9fa80d6e4740dc0c9942c16162c31ab3890e4eb09e1f7a81bc4 ) vcpkg_cmake_configure(SOURCE_PATH "${SOURCE_PATH}") vcpkg_cmake_install()