diff --git a/minichain/block.py b/minichain/block.py index 96923e5..d8eb390 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -152,7 +152,7 @@ def from_dict(cls, payload: dict): # Verify the block hash expected_hash = block.compute_hash() - if block.hash is not None and block.hash != expected_hash: + if block.hash != expected_hash: raise ValueError("block hash does not match header") # Recalculate and verify the Merkle root! diff --git a/minichain/chain.py b/minichain/chain.py index f3e2bf1..97f01a5 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -11,7 +11,15 @@ logger = logging.getLogger(__name__) -def validate_block_link_and_hash(previous_block, block): +class InvalidProofOfWorkError(ValueError): + pass + + +def validate_difficulty(difficulty, max_difficulty): + if type(difficulty) is not int or difficulty < 1 or difficulty > max_difficulty: + raise ValueError(f"invalid difficulty {difficulty}") + +def validate_block(previous_block, block, expected_difficulty): if block.previous_hash != previous_block.hash: raise ValueError( f"invalid previous hash {block.previous_hash} != {previous_block.hash}" @@ -26,17 +34,26 @@ def validate_block_link_and_hash(previous_block, block): if block.hash != expected_hash: raise ValueError(f"invalid hash {block.hash}") - target = "0" * (block.difficulty or 1) - if not block.hash.startswith(target): - raise ValueError(f"invalid Proof of Work: hash {block.hash} does not satisfy difficulty {block.difficulty}") + if block.difficulty != expected_difficulty: + raise ValueError( + f"invalid difficulty {block.difficulty} != expected {expected_difficulty}" + ) + + if not block.hash.startswith("0" * expected_difficulty): + raise InvalidProofOfWorkError( + f"invalid PoW: hash {block.hash} does not satisfy difficulty {expected_difficulty}" + ) if block.timestamp <= previous_block.timestamp: - raise ValueError(f"invalid timestamp: {block.timestamp} is not strictly greater than previous block timestamp {previous_block.timestamp}") + raise ValueError( + f"invalid timestamp: {block.timestamp} is not strictly greater than previous block timestamp {previous_block.timestamp}" + ) max_allowed_time = int(time.time() * 1000) + 15000 if block.timestamp > max_allowed_time: - raise ValueError(f"invalid timestamp: {block.timestamp} is too far in the future (max allowed: {max_allowed_time})") - + raise ValueError( + f"invalid timestamp: {block.timestamp} is too far in the future (max allowed: {max_allowed_time})" + ) class Blockchain: """ @@ -130,7 +147,13 @@ def get_total_work(self, chain_list=None): if chain_list is None: with self._lock: chain_list = self.chain - return sum(2 ** (block.difficulty or 1) for block in chain_list) + + for block in chain_list: + if not isinstance(block.hash, str): + raise ValueError(f"invalid or missing hash on block {block.index}") + validate_difficulty(block.difficulty, len(block.hash)) + + return sum(2 ** block.difficulty for block in chain_list) def _next_difficulty(self, difficulty, avg_block_time): """Advance the EMA difficulty control after a block, returning the new value.""" @@ -150,16 +173,15 @@ def _apply_block(self, prev_block, block, state, difficulty, avg_block_time): from .validators import ValidationStatus try: - validate_block_link_and_hash(prev_block, block) + validate_block(prev_block, block, difficulty) + except InvalidProofOfWorkError as exc: + logger.warning("Block %s rejected: %s", block.index, exc) + return ValidationStatus.INVALID, difficulty, avg_block_time except ValueError as exc: logger.warning("Block %s rejected: %s", block.index, exc) status = ValidationStatus.INVALID if "hash" in str(exc) else ValidationStatus.FAILED return status, difficulty, avg_block_time - if block.difficulty != difficulty: - logger.warning("Block %s rejected: Invalid difficulty. Expected %s, got %s", block.index, difficulty, block.difficulty) - return ValidationStatus.INVALID, difficulty, avg_block_time - receipts = [] for tx in block.transactions: status, receipt = state.validate_and_apply_with_status(tx) @@ -224,9 +246,12 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: return False, [] with self._lock: - current_work = self.get_total_work() - new_work = self.get_total_work(new_chain_list) - + try: + current_work = self.get_total_work() + new_work = self.get_total_work(new_chain_list) + except ValueError as exc: + logger.warning("Reorg rejected: %s", exc) + return False, [] if new_work <= current_work: logger.debug("Incoming chain (work: %s) is not heavier than local chain (work: %s). Rejecting.", new_work, current_work) return False, [] diff --git a/minichain/persistence.py b/minichain/persistence.py index dc989ba..df1f531 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -24,7 +24,7 @@ from typing import Any from .block import Block -from .chain import Blockchain, validate_block_link_and_hash +from .chain import Blockchain logger = logging.getLogger(__name__) @@ -64,7 +64,13 @@ def save(blockchain: Blockchain, path: str = ".") -> None: def load(path: str = ".") -> Blockchain: - """Restore a Blockchain from SQLite inside *path* (with legacy JSON fallback).""" + """Restore a Blockchain from SQLite inside *path* (with legacy JSON fallback). + + Blocks are replayed through the canonical ``Blockchain._apply_block()`` + pipeline so that difficulty adjustment, PoW, receipt validation, state-root + validation and transaction validation are all performed by the same code + path used at runtime. No parallel validation logic is maintained here. + """ db_path = os.path.join(path, _DB_FILE) legacy_path = os.path.join(path, _LEGACY_DATA_FILE) @@ -90,6 +96,9 @@ def load(path: str = ".") -> Blockchain: raise ValueError(f"Invalid or empty chain data in '{path}'") if not isinstance(raw_accounts, dict): raise ValueError(f"Invalid accounts data in '{path}'") + for address, account in raw_accounts.items(): + if not isinstance(address, str) or not isinstance(account, dict): + raise ValueError(f"Invalid accounts data in '{path}'") blocks = [] for raw_block in raw_blocks: @@ -100,17 +109,44 @@ def load(path: str = ".") -> Blockchain: except (KeyError, TypeError, ValueError) as exc: raise ValueError(f"Invalid chain data in '{path}'") from exc - normalized_accounts = {} - for address, account in raw_accounts.items(): - if not isinstance(address, str) or not isinstance(account, dict): - raise ValueError(f"Invalid accounts data in '{path}'") - normalized_accounts[address] = account + blockchain = Blockchain() + + from .pow import calculate_hash + genesis = blocks[0] + if genesis.index != 0: + raise ValueError("Invalid genesis block") + if genesis.hash != calculate_hash(genesis.to_header_dict()): + raise ValueError("Invalid genesis block hash") + if genesis.hash != blockchain.chain[0].hash: + raise ValueError( + f"Persisted genesis hash {genesis.hash!r} does not match " + f"local genesis {blockchain.chain[0].hash!r}" + ) - _verify_chain_integrity(blocks) + from .validators import ValidationStatus + from .state import State + + temp_state = State() + temp_state.chain_id = blockchain.chain_id + temp_state.restore(blockchain._genesis_state_snapshot) + + temp_difficulty = blocks[0].difficulty + temp_avg_block_time = blockchain.target_block_time + + for i in range(1, len(blocks)): + status, temp_difficulty, temp_avg_block_time = blockchain._apply_block( + blocks[i - 1], blocks[i], temp_state, temp_difficulty, temp_avg_block_time + ) + if status != ValidationStatus.VALID: + raise ValueError( + f"Block #{blocks[i].index} failed validation during load " + f"(status={status.name})" + ) - blockchain = Blockchain() blockchain.chain = blocks - blockchain.state.accounts = normalized_accounts + blockchain.state = temp_state + blockchain.current_difficulty = temp_difficulty + blockchain.avg_block_time = temp_avg_block_time logger.info( "Loaded %d blocks and %d accounts from '%s'", @@ -121,29 +157,6 @@ def load(path: str = ".") -> Blockchain: return blockchain -# --------------------------------------------------------------------------- -# Integrity verification -# --------------------------------------------------------------------------- - - -def _verify_chain_integrity(blocks: list[Block]) -> None: - """Verify genesis, hash linkage, and block hashes.""" - genesis = blocks[0] - if genesis.index != 0: - raise ValueError("Invalid genesis block") - from .pow import calculate_hash - if genesis.hash != calculate_hash(genesis.to_header_dict()): - raise ValueError("Invalid genesis block hash") - - for i in range(1, len(blocks)): - block = blocks[i] - prev = blocks[i - 1] - try: - validate_block_link_and_hash(prev, block) - except ValueError as exc: - raise ValueError(f"Block #{block.index}: {exc}") from exc - - # --------------------------------------------------------------------------- # SQLite helpers # --------------------------------------------------------------------------- diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 989af77..3d8058a 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -7,6 +7,7 @@ import tempfile import time import unittest +from unittest.mock import patch, wraps from nacl.encoding import HexEncoder from nacl.signing import SigningKey @@ -33,31 +34,68 @@ def tearDown(self): shutil.rmtree(self.tmpdir, ignore_errors=True) def _chain_with_tx(self): + """Build a 3-block chain that survives full _apply_block() replay. + + Block 1 (coinbase): alice mines an empty block and earns the mining + reward — this is the only valid way to introduce fresh funds into a + chain that will be fully validated during load(). + + Block 2 (transfer): alice sends coins to bob. + """ bc = Blockchain() alice_sk, alice_pk = _make_keypair() _, bob_pk = _make_keypair() - bc.state.credit_mining_reward(alice_pk, 100) + # --- Block 1: coinbase (alice mines, earns mining reward) --- + from minichain.block import calculate_receipt_root + from minichain.state import State + + temp_state1 = bc.state.copy() + temp_state1.chain_id = bc.chain_id + # Mining reward applied inside _apply_block via block.miner + temp_state1.credit_mining_reward(alice_pk, reward=temp_state1.DEFAULT_MINING_REWARD) + coinbase_block = Block( + index=1, + previous_hash=bc.last_block.hash, + transactions=[], + difficulty=bc.current_difficulty, + state_root=temp_state1.state_root(), + receipt_root=None, + receipts=[], + timestamp=bc.last_block.timestamp + bc.target_block_time, + miner=alice_pk, + ) + mine_block(coinbase_block) + result = bc.add_block(coinbase_block) + self.assertEqual(result.name, "VALID", f"Coinbase block rejected: {result}") - tx = Transaction(alice_pk, bob_pk, 30, 0) + # --- Block 2: alice sends to bob --- + tx = Transaction(alice_pk, bob_pk, 1, 0) tx.sign(alice_sk) - temp_state = bc.state.copy() - receipt = temp_state.validate_and_apply(tx) + temp_state2 = bc.state.copy() + temp_state2.chain_id = bc.chain_id + receipt = temp_state2.validate_and_apply(tx) + self.assertIsNotNone(receipt, "Transaction was rejected during block 2 construction") - from minichain.block import calculate_receipt_root - block = Block( - index=1, + total_fees = getattr(receipt, 'gas_used', 0) + temp_state2.credit_mining_reward(alice_pk, reward=temp_state2.DEFAULT_MINING_REWARD + total_fees) + + tx_block = Block( + index=2, previous_hash=bc.last_block.hash, transactions=[tx], difficulty=bc.current_difficulty, - state_root=temp_state.state_root(), + state_root=temp_state2.state_root(), receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], - timestamp=bc.last_block.timestamp + 1000, + timestamp=bc.last_block.timestamp + bc.target_block_time, + miner=alice_pk, ) - mine_block(block) - bc.add_block(block) + mine_block(tx_block) + result = bc.add_block(tx_block) + self.assertEqual(result.name, "VALID", f"Tx block rejected: {result}") + return bc, alice_pk, bob_pk def test_save_creates_sqlite_file(self): @@ -85,8 +123,8 @@ def test_transaction_data_preserved(self): bc, _, _ = self._chain_with_tx() save(bc, path=self.tmpdir) restored = load(path=self.tmpdir) - original_tx = bc.chain[1].transactions[0] - loaded_tx = restored.chain[1].transactions[0] + original_tx = bc.chain[2].transactions[0] + loaded_tx = restored.chain[2].transactions[0] self.assertEqual(original_tx.sender, loaded_tx.sender) self.assertEqual(original_tx.receiver, loaded_tx.receiver) self.assertEqual(original_tx.amount, loaded_tx.amount) @@ -97,8 +135,8 @@ def test_receipt_data_preserved(self): bc, _, _ = self._chain_with_tx() save(bc, path=self.tmpdir) restored = load(path=self.tmpdir) - original_receipt = bc.chain[1].receipts[0] - loaded_receipt = restored.chain[1].receipts[0] + original_receipt = bc.chain[2].receipts[0] + loaded_receipt = restored.chain[2].receipts[0] self.assertEqual(original_receipt.tx_hash, loaded_receipt.tx_hash) self.assertEqual(original_receipt.status, loaded_receipt.status) self.assertEqual(original_receipt.gas_used, loaded_receipt.gas_used) @@ -232,13 +270,34 @@ def test_loaded_chain_can_add_new_block(self): restored = load(path=self.tmpdir) new_sk, new_pk = _make_keypair() - restored.state.credit_mining_reward(new_pk, 50) + from minichain.block import calculate_receipt_root as crr + temp_state0 = restored.state.copy() + temp_state0.chain_id = restored.chain_id + temp_state0.credit_mining_reward(new_pk, reward=temp_state0.DEFAULT_MINING_REWARD) + coinbase_block = Block( + index=len(restored.chain), + previous_hash=restored.last_block.hash, + transactions=[], + difficulty=restored.current_difficulty, + state_root=temp_state0.state_root(), + receipt_root=None, + receipts=[], + timestamp=restored.last_block.timestamp + restored.target_block_time, + miner=new_pk, + ) + mine_block(coinbase_block) + from minichain.validators import ValidationStatus + self.assertEqual(restored.add_block(coinbase_block), ValidationStatus.VALID) - tx2 = Transaction(new_pk, bob_pk, 10, 0) + tx2 = Transaction(new_pk, bob_pk, 1, 0) tx2.sign(new_sk) - temp_state = restored.state.copy() - receipt2 = temp_state.validate_and_apply(tx2) + temp_state2 = restored.state.copy() + temp_state2.chain_id = restored.chain_id + receipt2 = temp_state2.validate_and_apply(tx2) + self.assertIsNotNone(receipt2) + total_fees2 = getattr(receipt2, 'gas_used', 0) + temp_state2.credit_mining_reward(new_pk, reward=temp_state2.DEFAULT_MINING_REWARD + total_fees2) from minichain.block import calculate_receipt_root block2 = Block( @@ -246,15 +305,113 @@ def test_loaded_chain_can_add_new_block(self): previous_hash=restored.last_block.hash, transactions=[tx2], difficulty=restored.current_difficulty, - state_root=temp_state.state_root(), + state_root=temp_state2.state_root(), receipt_root=calculate_receipt_root([receipt2]), receipts=[receipt2], - timestamp=restored.last_block.timestamp + 1000, + timestamp=restored.last_block.timestamp + restored.target_block_time, + miner=new_pk, ) mine_block(block2) - self.assertTrue(restored.add_block(block2)) - self.assertEqual(len(restored.chain), len(bc.chain) + 1) + self.assertEqual(restored.add_block(block2), ValidationStatus.VALID) + self.assertEqual(len(restored.chain), len(bc.chain) + 2) + + def test_load_uses_apply_block_pipeline(self): + """_apply_block() must be called for each non-genesis block during load.""" + bc, _, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + + call_count = [] + import minichain.chain as chain_module + original_apply = chain_module.Blockchain._apply_block + + def spying_apply(self_inner, prev_block, block, state, difficulty, avg_block_time): + call_count.append(block.index) + return original_apply(self_inner, prev_block, block, state, difficulty, avg_block_time) + + with patch.object(chain_module.Blockchain, "_apply_block", spying_apply): + restored = load(path=self.tmpdir) + + expected = list(range(1, len(bc.chain))) + self.assertEqual(sorted(call_count), expected, + f"Expected _apply_block calls for blocks {expected}, got {call_count}") + self.assertEqual(len(restored.chain), len(bc.chain)) + + def test_load_rejects_invalid_pow_in_persisted_block(self): + """A persisted block whose hash fails PoW must be rejected during load.""" + bc, _, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + + db_path = os.path.join(self.tmpdir, DB_FILE) + with sqlite3.connect(db_path) as conn: + row = conn.execute( + "SELECT block_json FROM blocks WHERE height = 2" + ).fetchone() + payload = json.loads(row[0]) + payload["hash"] = "f" * 64 + # We need the hash to equal compute_hash() so deserialization passes, + # but PoW to fail. Use nonce manipulation. + from minichain.block import Block as B + from minichain.pow import calculate_hash + b = B.from_dict(json.loads(row[0])) + header = b.to_header_dict() + nonce = 0 + while True: + header["nonce"] = nonce + h = calculate_hash(header) + if not h.startswith("0"): + break + nonce += 1 + payload["nonce"] = nonce + payload["hash"] = calculate_hash(header) + conn.execute( + "UPDATE blocks SET block_json = ? WHERE height = 2", + (json.dumps(payload),), + ) + + with self.assertRaises(ValueError) as cm: + load(path=self.tmpdir) + self.assertIn("failed validation", str(cm.exception)) + + def test_load_rejects_wrong_declared_difficulty(self): + """A persisted block declaring wrong difficulty must be rejected during load. + + Security property: even if an attacker sets block.difficulty=1 and crafts + a hash that satisfies difficulty 1, load() must reject it because + expected_difficulty (chain-computed) is greater. + """ + bc, _, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + + db_path = os.path.join(self.tmpdir, DB_FILE) + with sqlite3.connect(db_path) as conn: + row = conn.execute( + "SELECT block_json FROM blocks WHERE height = 2" + ).fetchone() + payload = json.loads(row[0]) + payload["difficulty"] = 1 + from minichain.block import Block as B + from minichain.pow import calculate_hash, mine_block as mb + import copy + b = B.from_dict(json.loads(row[0])) + b.difficulty = 1 + header = b.to_header_dict() + nonce = 0 + while True: + header["nonce"] = nonce + h = calculate_hash(header) + if h.startswith("0"): + break + nonce += 1 + payload["nonce"] = nonce + payload["hash"] = h + conn.execute( + "UPDATE blocks SET block_json = ? WHERE height = 2", + (json.dumps(payload),), + ) + with self.assertRaises(ValueError) as cm: + load(path=self.tmpdir) + self.assertIn("failed validation", str(cm.exception)) def test_legacy_json_load_still_supported(self): bc = Blockchain() diff --git a/tests/test_persistence_runtime.py b/tests/test_persistence_runtime.py index 73265e5..32d4d49 100644 --- a/tests/test_persistence_runtime.py +++ b/tests/test_persistence_runtime.py @@ -56,27 +56,28 @@ def tearDown(self): def _chain_with_tx(self): bc = Blockchain() - alice_sk, alice_pk = _make_keypair() - _, bob_pk = _make_keypair() - - bc.state.credit_mining_reward(alice_pk, 100) - tx = Transaction(alice_pk, bob_pk, 30, 0) - tx.sign(alice_sk) + _, miner_pk = _make_keypair() + # Mine a valid empty block (coinbase) so the chain has 2 blocks. + # Using an empty block avoids the need to pre-fund any sender outside + # of a properly validated block. + from minichain.state import State temp_state = bc.state.copy() - receipt = temp_state.validate_and_apply(tx) + temp_state.chain_id = bc.chain_id + temp_state.credit_mining_reward(miner_pk, reward=temp_state.DEFAULT_MINING_REWARD) from minichain.block import calculate_receipt_root block = Block( index=1, previous_hash=bc.last_block.hash, - transactions=[tx], - difficulty=1, + transactions=[], + difficulty=bc.current_difficulty, state_root=temp_state.state_root(), - receipt_root=calculate_receipt_root([receipt]), - receipts=[receipt], + receipt_root=None, + receipts=[], + miner=miner_pk, ) - mine_block(block, difficulty=1) + mine_block(block, difficulty=bc.current_difficulty) bc.add_block(block) return bc @@ -101,6 +102,15 @@ async def fake_cli_loop(sk, pk, loaded_chain, mempool, network, datadir=None): ) async def test_run_node_saves_sqlite_snapshot_on_shutdown(self): + """Verify that the node saves a snapshot on shutdown. + + The ``fund=25`` balance is a dev-only convenience injected directly + into state outside of any block. It is intentionally NOT replayed by + load() because the new load() only reconstructs state through + _apply_block(), which is the correct, secure behaviour. + We therefore only verify that the snapshot was written and the chain + structure survives the round-trip. + """ fixed_sk, fixed_pk = _make_keypair() async def fake_cli_loop(sk, pk, chain, mempool, network, datadir=None): @@ -118,9 +128,12 @@ async def fake_cli_loop(sk, pk, chain, mempool, network, datadir=None): datadir=self.tmpdir, ) + # The snapshot was saved. load() replays through _apply_block(), so + # only block-committed state survives. The genesis-only chain (no + # blocks added in cli_loop) is intact. restored = load(self.tmpdir) - self.assertEqual(restored.state.get_account(fixed_pk)["balance"], 25) self.assertEqual(len(restored.chain), 1) + self.assertEqual(restored.chain[0].hash, Blockchain().chain[0].hash) if __name__ == "__main__": diff --git a/tests/test_pow_validation.py b/tests/test_pow_validation.py new file mode 100644 index 0000000..072c407 --- /dev/null +++ b/tests/test_pow_validation.py @@ -0,0 +1,72 @@ +from minichain import Blockchain, Block +from minichain.validators import ValidationStatus + + +def _make_block_with_invalid_pow(chain): + block = Block( + index=chain.last_block.index + 1, + previous_hash=chain.last_block.hash, + transactions=[], + difficulty=chain.current_difficulty, + state_root=chain.state.state_root(), + ) + + while True: + block.hash = block.compute_hash() + if not block.hash.startswith("0" * block.difficulty): + return block + block.nonce += 1 + + +def test_add_block_rejects_invalid_pow(): + chain = Blockchain() + block = _make_block_with_invalid_pow(chain) + + assert chain.add_block(block) == ValidationStatus.INVALID + assert len(chain.chain) == 1 + + +def test_resolve_conflicts_rejects_invalid_pow(): + chain = Blockchain() + block = _make_block_with_invalid_pow(chain) + + success, _ = chain.resolve_conflicts([chain.chain[0], block]) + + assert success is False + assert len(chain.chain) == 1 + + +def _make_block_with_lied_difficulty(chain, lied_difficulty): + block = Block( + index=chain.last_block.index + 1, + previous_hash=chain.last_block.hash, + transactions=[], + difficulty=lied_difficulty, + state_root=chain.state.state_root(), + ) + target = "0" * lied_difficulty + while True: + block.hash = block.compute_hash() + if block.hash.startswith(target): + return block + block.nonce += 1 + + +def test_add_block_rejects_lied_difficulty(): + chain = Blockchain() + assert chain.current_difficulty > 1 # sanity: test only meaningful if real difficulty is harder + block = _make_block_with_lied_difficulty(chain, lied_difficulty=1) + + assert chain.add_block(block) != ValidationStatus.VALID + assert len(chain.chain) == 1 + + +def test_resolve_conflicts_rejects_lied_difficulty(): + chain = Blockchain() + assert chain.current_difficulty > 1 + block = _make_block_with_lied_difficulty(chain, lied_difficulty=1) + + success, _ = chain.resolve_conflicts([chain.chain[0], block]) + + assert success is False + assert len(chain.chain) == 1 \ No newline at end of file