diff --git a/include/astro/core/block.hpp b/include/astro/core/block.hpp index 0087671..a96419b 100644 --- a/include/astro/core/block.hpp +++ b/include/astro/core/block.hpp @@ -27,7 +27,6 @@ namespace astro::core { std::vector serialize() const; }; - // ------- Utilities ------- Hash256 compute_merkle_root(const std::vector& transactions); Hash256 empty_merkle_root(); diff --git a/include/astro/core/chain.hpp b/include/astro/core/chain.hpp new file mode 100644 index 0000000..3d69455 --- /dev/null +++ b/include/astro/core/chain.hpp @@ -0,0 +1,46 @@ +#pragma once +#include +#include +#include +#include "astro/core/block.hpp" +#include "astro/core/transaction.hpp" + +namespace astro::core { + + enum class ValidationError { + None = 0, + EmptyChainButNotGenesis, + NonZeroPrevHashForGenesis, + BadPrevLink, + NonMonotonicTimestamp, + BadMerkleRoot, + BadTransactionSignature, + CoinBaseMisplaced, + CoinbaseInNonGenesisBlock, + }; + + struct ValidationResult { + bool is_valid; + ValidationError error; + size_t transaction_index = ~0LL; + }; + + class Chain { + public: + Chain(); + + size_t height() const { return blocks_.size();} + + std::optional tip_hash() const; + const Block* tip() const { return blocks_.empty() ? nullptr : &blocks_.back();} + + ValidationResult validate_block(const Block& block) const; + + ValidationResult append_block(const Block& block); + + Block build_block_from_transactions(std::vector transactions, uint64_t timestamp) const; + + private: + std::vector blocks_; + }; +} \ No newline at end of file diff --git a/src/core/chain.cpp b/src/core/chain.cpp new file mode 100644 index 0000000..6232257 --- /dev/null +++ b/src/core/chain.cpp @@ -0,0 +1,85 @@ +#include "astro/core/chain.hpp" +#include "astro/core/block.hpp" +#include + +namespace astro::core { + Chain::Chain() = default; + + std::optional Chain::tip_hash() const { + if (blocks_.empty()) return std::nullopt; + return blocks_.back().header.hash(); + } + + static bool is_zero_hash(const Hash256& hash) { + for (auto byte : hash) if (byte != 0) return false; + return true; + } + + ValidationResult Chain::validate_block(const Block& block) const { + const bool is_genesis_candidate = blocks_.empty(); + + if (is_genesis_candidate) { + if (!is_zero_hash(block.header.prev_hash)) { + return {false, ValidationError::NonZeroPrevHashForGenesis, ~0ull}; + } + + if (!block.transactions.empty()) { + if (!block.transactions.front().from_pub_pem.empty()) { + return {false, ValidationError::CoinBaseMisplaced, 0}; + } + for (size_t i = 1; i < block.transactions.size(); ++i) { + if (block.transactions[i].from_pub_pem.empty()) return {false, ValidationError::CoinBaseMisplaced, i}; + } + } + } else { + auto parent_hash = blocks_.back().header.hash(); + if (block.header.prev_hash != parent_hash) { + return {false, ValidationError::BadPrevLink, ~0ull}; + } + + if (block.header.timestamp < blocks_.back().header.timestamp) { + return {false, ValidationError::NonMonotonicTimestamp, ~0ull}; + } + + for (size_t i = 0; i < block.transactions.size(); ++i) { + if (block.transactions[i].from_pub_pem.empty()) return {false, ValidationError::CoinbaseInNonGenesisBlock, i}; + } + } + + auto computed_merkle_root = compute_merkle_root(block.transactions); + if (computed_merkle_root!= block.header.merkle_root) { + return {false, ValidationError::BadMerkleRoot, ~0ull}; + } + + for (size_t i = 0; i < block.transactions.size(); ++i) { + const auto& tx = block.transactions[i]; + // Skip signature verification for an allowed coinbase at genesis (empty from_pub_pem) + if (is_genesis_candidate && i == 0 && tx.from_pub_pem.empty()) continue; + if (!tx.verify()) { + return {false, ValidationError::BadTransactionSignature, i}; + } + } + return {true, ValidationError::None, ~0ull}; + } + + ValidationResult Chain::append_block(const Block& block) { + auto validation_result = validate_block(block); + if (!validation_result.is_valid) return validation_result; + blocks_.push_back(block); + return validation_result; + } + + Block Chain::build_block_from_transactions(std::vector transactions, uint64_t timestamp) const { + Block output; + output.transactions = std::move(transactions); + BlockHeader header; + header.version = 1; + if (!blocks_.empty()) header.prev_hash = blocks_.back().header.hash(); + header.merkle_root = compute_merkle_root(output.transactions); + header.timestamp = timestamp; + header.nonce = 0; + output.header = header; + return output; + } + +} \ No newline at end of file diff --git a/src/main.cpp b/src/main.cpp index a5eebaf..b57c872 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include using namespace astro::core; @@ -21,7 +22,8 @@ static void print_usage() { " astro-node demo-keys [--curve CURVE] [--message MESSAGE]\n" " astro-node demo-tx [--amount N] [--nonce N] [--to LABEL]\n" " astro-node demo-genesis\n" - " astro-node demo-merkle [--leaves CSV] [--index N]\n\n" + " astro-node demo-merkle [--leaves CSV] [--index N]\n" + " astro-node demo-chain\n\n" "Options:\n" " --curve EC curve name (default: secp256k1)\n" " --message Message to sign (default: 'astro demo')\n" @@ -218,6 +220,34 @@ int main(int argc, char** argv) { return 0; } + if (command == "demo-chain") { + if (!crypto_init()) { + std::fprintf(stderr, "crypto_init failed\n"); + return 1; + } + Chain chain; + uint64_t t0 = static_cast(std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + auto genesis = make_genesis_block("Astro begins", t0); + auto res0 = chain.append_block(genesis); + std::cout << "append genesis: " << (res0.is_valid ? "OK" : "FAIL") << "\n"; + + auto key_pair = generate_ec_keypair(); + Transaction tx; + tx.version = 1; tx.nonce = 1; tx.amount = 42; + tx.from_pub_pem = key_pair.pubkey_pem; tx.to_label = "alice"; + tx.sign(key_pair.privkey_pem); + + auto block1 = chain.build_block_from_transactions({tx}, t0 + 1); + auto res1 = chain.append_block(block1); + std::cout << "append block #2: " << (res1.is_valid ? "OK" : "FAIL") << "\n"; + if (auto th = chain.tip_hash()) { + std::cout << "tip: " << to_hex(std::span(th->data(), th->size())) << "\n"; + std::cout << "height: " << chain.height() << "\n"; + } + return res1.is_valid ? 0 : 2; + } + print_usage(); return 0; } \ No newline at end of file diff --git a/tests/test_block.cpp b/tests/test_block.cpp index fa14902..744b25a 100644 --- a/tests/test_block.cpp +++ b/tests/test_block.cpp @@ -8,8 +8,8 @@ using namespace astro::core; TEST(BlockHeader, SerializeAndHashDeterminism) { BlockHeader header; header.version = 1; - header.prev_hash = {}; // zeros - header.merkle_root = {}; // zeros + header.prev_hash = {}; + header.merkle_root = {}; header.timestamp = 1700000000ULL; header.nonce = 123; diff --git a/tests/test_chain.cpp b/tests/test_chain.cpp new file mode 100644 index 0000000..2e62fbc --- /dev/null +++ b/tests/test_chain.cpp @@ -0,0 +1,105 @@ +#include +#include +#include "astro/core/chain.hpp" +#include "astro/core/keys.hpp" + +using namespace astro::core; + +static uint64_t now_sec() { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch() + ).count() + ); +} + +TEST(Chain, AppendGenesisThenValidBlock) { + ASSERT_TRUE(crypto_init()); + Chain c; + uint64_t t0 = now_sec(); + + // Build genesis with a single coinbase-like tx (no signer) + Transaction coinbase; + coinbase.to_label = "genesis-note"; + Block g = make_genesis_block("genesis-note", t0); + auto r0 = c.append_block(g); + ASSERT_TRUE(r0.is_valid); + EXPECT_EQ(c.height(), 1u); + + // Next block with a signed tx + auto kp = generate_ec_keypair(); + ASSERT_FALSE(kp.privkey_pem.empty()); + ASSERT_FALSE(kp.pubkey_pem.empty()); + Transaction tx; + tx.version = 1; tx.nonce = 1; tx.amount = 10; + tx.from_pub_pem = kp.pubkey_pem; tx.to_label = "alice"; + tx.sign(kp.privkey_pem); + + Block b1 = c.build_block_from_transactions({tx}, t0 + 1); + auto r1 = c.append_block(b1); + ASSERT_TRUE(r1.is_valid); + EXPECT_EQ(c.height(), 2u); + EXPECT_TRUE(c.tip_hash().has_value()); +} + +TEST(Chain, RejectsBadPrevLink) { + ASSERT_TRUE(crypto_init()); + Chain c; + uint64_t t = now_sec(); + auto g = make_genesis_block("g", t); + ASSERT_TRUE(c.append_block(g).is_valid); + + // Build a block that points to zero (wrong prev) + auto kp = generate_ec_keypair(); + ASSERT_FALSE(kp.privkey_pem.empty()); + ASSERT_FALSE(kp.pubkey_pem.empty()); + Transaction tx; tx.version=1; tx.nonce=1; tx.amount=1; tx.from_pub_pem=kp.pubkey_pem; tx.to_label="x"; tx.sign(kp.privkey_pem); + + Block bad = c.build_block_from_transactions({tx}, t+1); + bad.header.prev_hash = {}; + auto r = c.append_block(bad); + EXPECT_FALSE(r.is_valid); + EXPECT_EQ(r.error, ValidationError::BadPrevLink); +} + +TEST(Chain, RejectsBadMerkleOrSig) { + ASSERT_TRUE(crypto_init()); + Chain c; + uint64_t t = now_sec(); + ASSERT_TRUE(c.append_block(make_genesis_block("g", t)).is_valid); + + auto kp = generate_ec_keypair(); + ASSERT_FALSE(kp.privkey_pem.empty()); + ASSERT_FALSE(kp.pubkey_pem.empty()); + Transaction tx; tx.version=1; tx.nonce=1; tx.amount=1; tx.from_pub_pem=kp.pubkey_pem; tx.to_label="x"; tx.sign(kp.privkey_pem); + Block b = c.build_block_from_transactions({tx}, t+1); + + // Tamper merkle + b.header.merkle_root = {}; + auto r1 = c.append_block(b); + EXPECT_FALSE(r1.is_valid); + EXPECT_EQ(r1.error, ValidationError::BadMerkleRoot); + + // Fix merkle, break signature + b.header.merkle_root = compute_merkle_root(b.transactions); + ASSERT_FALSE(b.transactions[0].signature.empty()); + b.transactions[0].signature[0] ^= 0x01; + auto r2 = c.append_block(b); + EXPECT_FALSE(r2.is_valid); + EXPECT_EQ(r2.error, ValidationError::BadTransactionSignature); +} + +TEST(Chain, RejectsCoinbaseInNonGenesis) { + ASSERT_TRUE(crypto_init()); + Chain c; + uint64_t t = now_sec(); + ASSERT_TRUE(c.append_block(make_genesis_block("g", t)).is_valid); + + Transaction cb; + cb.to_label = "illegal"; + Block b = c.build_block_from_transactions({cb}, t+1); + + auto r = c.append_block(b); + EXPECT_FALSE(r.is_valid); + EXPECT_EQ(r.error, ValidationError::CoinbaseInNonGenesisBlock); +} \ No newline at end of file