diff --git a/src/blockchain/CMakeLists.txt b/src/blockchain/CMakeLists.txt index 34915ab..51de4f2 100644 --- a/src/blockchain/CMakeLists.txt +++ b/src/blockchain/CMakeLists.txt @@ -5,15 +5,16 @@ # add_library(blockchain - impl/storage_util.cpp - impl/block_storage_impl.cpp impl/block_storage_error.cpp + impl/block_storage_impl.cpp impl/block_storage_initializer.cpp impl/block_tree_error.cpp impl/block_tree_impl.cpp impl/block_tree_initializer.cpp impl/cached_tree.cpp impl/genesis_block_header_impl.cpp + impl/storage_util.cpp + state_transition_function.cpp ) target_link_libraries(blockchain Boost::boost diff --git a/src/blockchain/is_justifiable_slot.hpp b/src/blockchain/is_justifiable_slot.hpp new file mode 100644 index 0000000..c2f5f58 --- /dev/null +++ b/src/blockchain/is_justifiable_slot.hpp @@ -0,0 +1,25 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include + +#include + +#include "types/slot.hpp" + +namespace lean { + inline bool isJustifiableSlot(Slot finalized_slot, Slot candidate) { + BOOST_ASSERT(candidate >= finalized_slot); + auto delta = candidate - finalized_slot; + return delta <= 5 + // any x^2 + or fmod(sqrt(delta), 1) == 0 + // any x^2+x + or fmod(sqrt(delta + 0.25), 1) == 0.5; + } +} // namespace lean diff --git a/src/blockchain/state_transition_function.cpp b/src/blockchain/state_transition_function.cpp new file mode 100644 index 0000000..d585601 --- /dev/null +++ b/src/blockchain/state_transition_function.cpp @@ -0,0 +1,274 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "blockchain/state_transition_function.hpp" + +#include + +#include "blockchain/is_justifiable_slot.hpp" +#include "types/signed_block.hpp" +#include "types/state.hpp" + +namespace lean { + constexpr BlockHash kZeroHash; + + inline bool getBit(const std::vector &bits, size_t i) { + return i < bits.size() and bits.at(i); + } + + inline void setBit(std::vector &bits, size_t i) { + if (bits.size() <= i) { + bits.resize(i + 1); + } + bits.at(i) = true; + } + + using Justifications = std::map>; + + /** + * Returns a map of `root -> justifications` constructed from the flattened + * data in the state. + */ + inline Justifications getJustifications(const State &state) { + auto &roots = state.justifications_roots.data(); + auto &validators = state.justifications_validators.data(); + Justifications justifications; + size_t offset = 0; + BOOST_ASSERT(validators.size() == roots.size() * VALIDATOR_REGISTRY_LIMIT); + for (auto &root : roots) { + auto next_offset = offset + VALIDATOR_REGISTRY_LIMIT; + std::vector bits{ + validators.begin() + offset, + validators.begin() + next_offset, + }; + justifications[root] = std::move(bits); + offset = next_offset; + } + return justifications; + } + + /** + * Saves a map of `root -> justifications` back into the state's flattened + * data structure. + */ + inline void setJustifications(State &state, + const Justifications &justifications) { + auto &roots = state.justifications_roots.data(); + auto &validators = state.justifications_validators.data(); + roots.clear(); + roots.reserve(justifications.size()); + validators.clear(); + validators.reserve(justifications.size() * VALIDATOR_REGISTRY_LIMIT); + for (auto &[root, bits] : justifications) { + BOOST_ASSERT(bits.size() == VALIDATOR_REGISTRY_LIMIT); + roots.push_back(root); + validators.insert(validators.end(), bits.begin(), bits.end()); + } + } + + State STF::generateGenesisState(const Config &config) const { + BlockHeader header; + header.body_root = sszHash(BlockBody{}); + return State{ + .config = config, + .latest_block_header = header, + }; + } + + Block STF::genesisBlock(const State &state) const { + return Block{.state_root = sszHash(state)}; + } + + outcome::result STF::stateTransition(const SignedBlock &signed_block, + const State &parent_state, + bool check_state_root) const { + auto &block = signed_block.message; + auto state = parent_state; + // Process slots (including those with no blocks) since block + OUTCOME_TRY(processSlots(state, block.slot)); + // Process block + OUTCOME_TRY(processBlock(state, block)); + // Verify state root + if (check_state_root) { + auto state_root = sszHash(state); + if (block.state_root != state_root) { + return Error::STATE_ROOT_DOESNT_MATCH; + } + } + return std::move(state); + } + + outcome::result STF::processSlots(State &state, Slot slot) const { + if (state.slot >= slot) { + return Error::INVALID_SLOT; + } + while (state.slot < slot) { + processSlot(state); + ++state.slot; + } + return outcome::success(); + } + + void STF::processSlot(State &state) const { + // Cache latest block header state root + if (state.latest_block_header.state_root == kZeroHash) { + state.latest_block_header.state_root = sszHash(state); + } + } + + outcome::result STF::processBlock(State &state, + const Block &block) const { + OUTCOME_TRY(processBlockHeader(state, block)); + OUTCOME_TRY(processOperations(state, block.body)); + return outcome::success(); + } + + outcome::result STF::processBlockHeader(State &state, + const Block &block) const { + // Verify that the slots match + if (block.slot != state.slot) { + return Error::INVALID_SLOT; + } + // Verify that the block is newer than latest block header + if (block.slot <= state.latest_block_header.slot) { + return Error::INVALID_SLOT; + } + // Verify that proposer index is the correct index + if (not validateProposerIndex(state, block)) { + return Error::INVALID_PROPOSER; + } + // Verify that the parent matches + state.latest_block_header.updateHash(); + if (block.parent_root != state.latest_block_header.hash()) { + return Error::PARENT_ROOT_DOESNT_MATCH; + } + + // If this was first block post genesis, 3sf mini special treatment is + // required to correctly set genesis block root as already justified and + // finalized. This is not possible at the time of genesis state generation + // and are set at zero bytes because genesis block is calculated using + // genesis state causing a circular dependency + [[unlikely]] if (state.latest_block_header.slot == 0) { + // block.parent_root is the genesis root + state.latest_justified.root = block.parent_root; + state.latest_finalized.root = block.parent_root; + } + + // now that we can vote on parent, push it at its correct slot index in the + // structures + state.historical_block_hashes.push_back(block.parent_root); + // genesis block is always justified + state.justified_slots.push_back(state.latest_block_header.slot == 0); + + // if there were empty slots, push zero hash for those ancestors + for (auto num_empty_slots = block.slot - state.latest_block_header.slot - 1; + num_empty_slots > 0; + --num_empty_slots) { + state.historical_block_hashes.push_back(kZeroHash); + state.justified_slots.push_back(false); + } + + // Cache current block as the new latest block + state.latest_block_header = block.getHeader(); + // Overwritten in the next process_slot call + state.latest_block_header.state_root = kZeroHash; + return outcome::success(); + } + + outcome::result STF::processOperations(State &state, + const BlockBody &body) const { + // process attestations + OUTCOME_TRY(processAttestations(state, body.attestations.data())); + // other operations will get added as the functionality evolves + return outcome::success(); + } + + outcome::result STF::processAttestations( + State &state, const std::vector &attestations) const { + // get justifications, justified slots and historical block hashes are + // already upto date as per the processing in process_block_header + auto justifications = getJustifications(state); + + // From 3sf-mini/consensus.py - apply votes + for (auto &signed_vote : attestations) { + auto &vote = signed_vote.data; + if (vote.source.slot >= state.historical_block_hashes.size()) { + return Error::INVALID_VOTE_SOURCE_SLOT; + } + if (vote.target.slot >= state.historical_block_hashes.size()) { + return Error::INVALID_VOTE_TARGET_SLOT; + } + // Ignore votes whose source is not already justified, + // or whose target is not in the history, or whose target is not a + // valid justifiable slot + if (not getBit(state.justified_slots.data(), vote.source.slot) + // This condition is missing in 3sf mini but has been added here + // because we don't want to re-introduce the target again for + // remaining votes if the slot is already justified and its tracking + // already cleared out from justifications map + or getBit(state.justified_slots.data(), vote.target.slot) + or vote.source.root + != state.historical_block_hashes.data().at(vote.source.slot) + or vote.target.root + != state.historical_block_hashes.data().at(vote.target.slot) + or vote.target.slot <= vote.source.slot + or not isJustifiableSlot(state.latest_finalized.slot, + vote.target.slot)) { + continue; + } + + auto justifications_it = justifications.find(vote.target.root); + // Track attempts to justify new hashes + if (justifications_it == justifications.end()) { + justifications_it = + justifications.emplace(vote.target.root, std::vector{}).first; + justifications_it->second.resize(VALIDATOR_REGISTRY_LIMIT); + } + + if (vote.validator_id >= justifications_it->second.size()) { + return Error::INVALID_VOTER; + } + justifications_it->second.at(vote.validator_id) = true; + + size_t count = std::ranges::count(justifications_it->second, true); + + // If 2/3 voted for the same new valid hash to justify + // in 3sf mini this is strict equality, but we have updated it to >= + // also have modified it from count >= (2 * state.config.num_validators) + // / 3 to prevent integer division which could lead to less than 2/3 of + // validators justifying specially if the num_validators is low in testing + // scenarios + if (3 * count >= 2 * state.config.num_validators) { + state.latest_justified = vote.target; + setBit(state.justified_slots.data(), vote.target.slot); + justifications.erase(vote.target.root); + + // Finalization: if the target is the next valid justifiable hash after + // the source + auto any = false; + for (auto slot = vote.source.slot + 1; slot < vote.target.slot; + ++slot) { + if (isJustifiableSlot(state.latest_finalized.slot, slot)) { + any = true; + break; + } + } + if (not any) { + state.latest_finalized = vote.source; + } + } + } + + // flatten and set updated justifications back to the state + setJustifications(state, justifications); + return outcome::success(); + } + + bool STF::validateProposerIndex(const State &state, + const Block &block) const { + return block.proposer_index == block.slot % state.config.num_validators; + } +} // namespace lean diff --git a/src/blockchain/state_transition_function.hpp b/src/blockchain/state_transition_function.hpp new file mode 100644 index 0000000..cf04bb5 --- /dev/null +++ b/src/blockchain/state_transition_function.hpp @@ -0,0 +1,77 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#pragma once + +#include +#include + +#include "types/slot.hpp" + +namespace lean { + struct Block; + struct BlockBody; + struct Config; + struct SignedBlock; + struct SignedVote; + struct State; + + class STF { + public: + enum class Error { + INVALID_SLOT, + STATE_ROOT_DOESNT_MATCH, + INVALID_PROPOSER, + PARENT_ROOT_DOESNT_MATCH, + INVALID_VOTE_SOURCE_SLOT, + INVALID_VOTE_TARGET_SLOT, + INVALID_VOTER, + }; + Q_ENUM_ERROR_CODE_FRIEND(Error) { + using E = decltype(e); + switch (e) { + case E::INVALID_SLOT: + return "Invalid slot"; + case E::INVALID_PROPOSER: + return "Invalid proposer"; + case E::PARENT_ROOT_DOESNT_MATCH: + return "Parent root doesn't match"; + case E::STATE_ROOT_DOESNT_MATCH: + return "State root doesn't match"; + case E::INVALID_VOTE_SOURCE_SLOT: + return "Invalid vote source slot"; + case E::INVALID_VOTE_TARGET_SLOT: + return "Invalid vote target slot"; + case E::INVALID_VOTER: + return "Invalid voter"; + } + abort(); + } + + State generateGenesisState(const Config &config) const; + Block genesisBlock(const State &state) const; + + /** + * Apply block to parent state. + * @returns new state + */ + outcome::result stateTransition(const SignedBlock &signed_block, + const State &parent_state, + bool check_state_root) const; + + private: + outcome::result processSlots(State &state, Slot slot) const; + void processSlot(State &state) const; + outcome::result processBlock(State &state, const Block &block) const; + outcome::result processBlockHeader(State &state, + const Block &block) const; + outcome::result processOperations(State &state, + const BlockBody &body) const; + outcome::result processAttestations( + State &state, const std::vector &attestations) const; + bool validateProposerIndex(const State &state, const Block &block) const; + }; +} // namespace lean diff --git a/src/types/block.hpp b/src/types/block.hpp index d759f0e..b99deb2 100644 --- a/src/types/block.hpp +++ b/src/types/block.hpp @@ -34,10 +34,11 @@ namespace lean { return hash_cached.value(); } void setHash() { - BOOST_ASSERT(not hash_cached.has_value()); auto header = getHeader(); header.updateHash(); - hash_cached = header.hash(); + auto hash = header.hash(); + BOOST_ASSERT(not hash_cached.has_value() or hash == hash_cached); + hash_cached = hash; } BlockIndex slotHash() const { diff --git a/src/types/block_body.hpp b/src/types/block_body.hpp index 8eb898e..5afb58f 100644 --- a/src/types/block_body.hpp +++ b/src/types/block_body.hpp @@ -9,15 +9,15 @@ #include #include "types/constants.hpp" -#include "types/vote.hpp" +#include "types/signed_vote.hpp" namespace lean { struct BlockBody : ssz::ssz_container { /// @note votes will be replaced by aggregated attestations. - ssz::list votes; + ssz::list attestations; - SSZ_CONT(votes); + SSZ_CONT(attestations); }; } // namespace lean diff --git a/src/types/config.hpp b/src/types/config.hpp index ae764c6..ee06c01 100644 --- a/src/types/config.hpp +++ b/src/types/config.hpp @@ -6,14 +6,15 @@ #pragma once -namespace lean { +#include - /// @note temporary property to support simplified round robin block - /// production in absence of randao & deposit mechanisms - struct Config { +namespace lean { + struct Config : ssz::ssz_container { + /// @note temporary property to support simplified round robin block + /// production in absence of randao & deposit mechanisms uint64_t num_validators; uint64_t genesis_time; - }; - + SSZ_CONT(num_validators, genesis_time); + }; } // namespace lean diff --git a/src/types/state.hpp b/src/types/state.hpp index bd0e2ae..c48ce72 100644 --- a/src/types/state.hpp +++ b/src/types/state.hpp @@ -6,24 +6,37 @@ #pragma once -namespace lean { +#include "types/block_header.hpp" +#include "types/checkpoint.hpp" +#include "types/config.hpp" +#include "types/constants.hpp" - struct State { - Config : config; - uint64_t : slot; - BlockHeader : latest_block_header; +namespace lean { + struct State : ssz::ssz_container { + Config config; + Slot slot; + BlockHeader latest_block_header; - Checkpoint : latest_justified; - Checkpoint : latest_finalized; + Checkpoint latest_justified; + Checkpoint latest_finalized; - List[Bytes32, HISTORICAL_ROOTS_LIMIT] : historical_block_hashes; - List[bool, HISTORICAL_ROOTS_LIMIT] : justified_slots; + ssz::list historical_block_hashes; + ssz::list justified_slots; // Diverged from 3SF-mini.py: // Flattened `justifications: Dict[str, List[bool]]` for SSZ compatibility - List[Bytes32, HISTORICAL_ROOTS_LIMIT] : justifications_roots; - Bitlist[HISTORICAL_ROOTS_LIMIT * VALIDATOR_REGISTRY_LIMIT] - : justifications_validators; - }; + ssz::list justifications_roots; + ssz::list + justifications_validators; + SSZ_CONT(config, + slot, + latest_block_header, + latest_justified, + latest_finalized, + historical_block_hashes, + justified_slots, + justifications_roots, + justifications_validators); + }; } // namespace lean diff --git a/tests/unit/blockchain/CMakeLists.txt b/tests/unit/blockchain/CMakeLists.txt index ed52e57..9d5bde8 100644 --- a/tests/unit/blockchain/CMakeLists.txt +++ b/tests/unit/blockchain/CMakeLists.txt @@ -11,4 +11,11 @@ target_link_libraries(block_storage_test blockchain #??? logger_for_tests storage - ) \ No newline at end of file + ) + +addtest(state_transition_function_test + state_transition_function_test.cpp + ) +target_link_libraries(state_transition_function_test + blockchain + ) diff --git a/tests/unit/blockchain/state_transition_function_test.cpp b/tests/unit/blockchain/state_transition_function_test.cpp new file mode 100644 index 0000000..aa9bf0c --- /dev/null +++ b/tests/unit/blockchain/state_transition_function_test.cpp @@ -0,0 +1,48 @@ +/** + * Copyright Quadrivium LLC + * All Rights Reserved + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "blockchain/state_transition_function.hpp" + +#include + +#include "types/config.hpp" +#include "types/signed_block.hpp" +#include "types/state.hpp" + +TEST(STF, Test) { + lean::STF stf; + + lean::Config config{ + .num_validators = 2, + .genesis_time = 0, + }; + auto state0 = stf.generateGenesisState(config); + auto block0 = stf.genesisBlock(state0); + block0.setHash(); + + lean::Block block1{ + .slot = block0.slot + 1, + .proposer_index = 1, + .parent_root = block0.hash(), + }; + auto state1 = stf.stateTransition({.message = block1}, state0, false).value(); + block1.state_root = lean::sszHash(state1); + auto state1_apply = + stf.stateTransition({.message = block1}, state0, true).value(); + EXPECT_EQ(state1_apply, state1); + block1.setHash(); + + lean::Block block2{ + .slot = block1.slot + 3, + .parent_root = block1.hash(), + }; + auto state2 = stf.stateTransition({.message = block2}, state1, false).value(); + block2.state_root = lean::sszHash(state2); + auto state2_apply = + stf.stateTransition({.message = block2}, state1, true).value(); + EXPECT_EQ(state2_apply, state2); + block2.setHash(); +}