From 0e5eb656f2fef65bbf5c0cba31ea687701d08202 Mon Sep 17 00:00:00 2001 From: aniket866 Date: Thu, 12 Mar 2026 02:09:01 +0530 Subject: [PATCH 1/3] presistence --- minichain/__init__.py | 3 + minichain/persistence.py | 140 ++++++++++++++++++++++++++++++ tests/test_persistence.py | 174 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 minichain/persistence.py create mode 100644 tests/test_persistence.py diff --git a/minichain/__init__.py b/minichain/__init__.py index a3e42ae..ae52604 100644 --- a/minichain/__init__.py +++ b/minichain/__init__.py @@ -6,6 +6,7 @@ from .contract import ContractMachine from .p2p import P2PNetwork from .mempool import Mempool +from .persistence import save, load __all__ = [ "mine_block", @@ -18,4 +19,6 @@ "ContractMachine", "P2PNetwork", "Mempool", + "save", + "load", ] diff --git a/minichain/persistence.py b/minichain/persistence.py new file mode 100644 index 0000000..e30bab0 --- /dev/null +++ b/minichain/persistence.py @@ -0,0 +1,140 @@ +""" +Chain persistence: save and load the blockchain and state to/from JSON. + +Design: + - blockchain.json holds the full list of serialized blocks + - state.json holds the accounts dict + +Usage: + from minichain.persistence import save, load + + save(blockchain, path="data/") + blockchain = load(path="data/") +""" + +import json +import os +import logging +from .block import Block +from .transaction import Transaction +from .chain import Blockchain + +logger = logging.getLogger(__name__) + +_CHAIN_FILE = "blockchain.json" +_STATE_FILE = "state.json" + + +# Public API + +def save(blockchain: Blockchain, path: str = ".") -> None: + """ + Persist the blockchain and account state to two JSON files inside `path`. + + Args: + blockchain: The live Blockchain instance to save. + path: Directory to write blockchain.json and state.json into. + """ + os.makedirs(path, exist_ok=True) + + _write_json( + os.path.join(path, _CHAIN_FILE), + [block.to_dict() for block in blockchain.chain], + ) + + _write_json( + os.path.join(path, _STATE_FILE), + blockchain.state.accounts, + ) + + logger.info( + "Saved %d blocks and %d accounts to '%s'", + len(blockchain.chain), + len(blockchain.state.accounts), + path, + ) + + +def load(path: str = ".") -> Blockchain: + """ + Restore a Blockchain from JSON files inside `path`. + + Returns a fully initialised Blockchain whose chain and state match + what was previously saved with save(). + + Raises: + FileNotFoundError: if blockchain.json or state.json are missing. + ValueError: if the data is structurally invalid. + """ + chain_path = os.path.join(path, _CHAIN_FILE) + state_path = os.path.join(path, _STATE_FILE) + + raw_blocks = _read_json(chain_path) + raw_accounts = _read_json(state_path) + + if not isinstance(raw_blocks, list) or not raw_blocks: + raise ValueError(f"Invalid or empty chain data in '{chain_path}'") + + blockchain = Blockchain.__new__(Blockchain) # skip __init__ (no genesis) + import threading + from .state import State + from .contract import ContractMachine + + blockchain._lock = threading.RLock() + blockchain.chain = [_deserialize_block(b) for b in raw_blocks] + + blockchain.state = State.__new__(State) + blockchain.state.accounts = raw_accounts + blockchain.state.contract_machine = ContractMachine(blockchain.state) + + logger.info( + "Loaded %d blocks and %d accounts from '%s'", + len(blockchain.chain), + len(blockchain.state.accounts), + path, + ) + return blockchain + + +# Helpers + +def _write_json(filepath: str, data) -> None: + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +def _read_json(filepath: str): + if not os.path.exists(filepath): + raise FileNotFoundError(f"Persistence file not found: '{filepath}'") + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + + +def _deserialize_block(data: dict) -> Block: + """Reconstruct a Block (including its transactions) from a plain dict.""" + transactions = [ + Transaction( + sender=tx["sender"], + receiver=tx["receiver"], + amount=tx["amount"], + nonce=tx["nonce"], + data=tx.get("data"), + signature=tx.get("signature"), + timestamp=tx["timestamp"], + ) + for tx in data.get("transactions", []) + ] + + block = Block( + index=data["index"], + previous_hash=data["previous_hash"], + transactions=transactions, + timestamp=data["timestamp"], + difficulty=data.get("difficulty"), + ) + block.nonce = data["nonce"] + block.hash = data["hash"] + # Preserve the stored merkle root rather than recomputing to guard against + # any future change in the hash algorithm. + block.merkle_root = data.get("merkle_root") + return block diff --git a/tests/test_persistence.py b/tests/test_persistence.py new file mode 100644 index 0000000..976a0f6 --- /dev/null +++ b/tests/test_persistence.py @@ -0,0 +1,174 @@ +""" +Tests for chain persistence (save / load round-trip). +""" + +import os +import tempfile +import unittest + +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder + +from minichain import Blockchain, Transaction, Block, mine_block +from minichain.persistence import save, load + + +def _make_keypair(): + sk = SigningKey.generate() + pk = sk.verify_key.encode(encoder=HexEncoder).decode() + return sk, pk + + +class TestPersistence(unittest.TestCase): + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + # Helpers + + def _chain_with_tx(self): + """Return a Blockchain that has one mined block with a transfer.""" + 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) + + block = Block( + index=1, + previous_hash=bc.last_block.hash, + transactions=[tx], + difficulty=1, + ) + mine_block(block, difficulty=1) + bc.add_block(block) + return bc, alice_pk, bob_pk + + # Tests + + def test_save_creates_files(self): + bc = Blockchain() + save(bc, path=self.tmpdir) + + self.assertTrue(os.path.exists(os.path.join(self.tmpdir, "blockchain.json"))) + self.assertTrue(os.path.exists(os.path.join(self.tmpdir, "state.json"))) + + def test_chain_length_preserved(self): + bc, _, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + + restored = load(path=self.tmpdir) + self.assertEqual(len(restored.chain), len(bc.chain)) + + def test_block_hashes_preserved(self): + bc, _, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + + restored = load(path=self.tmpdir) + for original, loaded in zip(bc.chain, restored.chain): + self.assertEqual(original.hash, loaded.hash) + self.assertEqual(original.index, loaded.index) + self.assertEqual(original.previous_hash, loaded.previous_hash) + + def test_account_balances_preserved(self): + bc, alice_pk, bob_pk = self._chain_with_tx() + save(bc, path=self.tmpdir) + + restored = load(path=self.tmpdir) + self.assertEqual( + bc.state.get_account(alice_pk)["balance"], + restored.state.get_account(alice_pk)["balance"], + ) + self.assertEqual( + bc.state.get_account(bob_pk)["balance"], + restored.state.get_account(bob_pk)["balance"], + ) + + def test_account_nonces_preserved(self): + bc, alice_pk, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + + restored = load(path=self.tmpdir) + self.assertEqual( + bc.state.get_account(alice_pk)["nonce"], + restored.state.get_account(alice_pk)["nonce"], + ) + + 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] + + self.assertEqual(original_tx.sender, loaded_tx.sender) + self.assertEqual(original_tx.receiver, loaded_tx.receiver) + self.assertEqual(original_tx.amount, loaded_tx.amount) + self.assertEqual(original_tx.nonce, loaded_tx.nonce) + self.assertEqual(original_tx.signature, loaded_tx.signature) + + def test_loaded_chain_can_add_new_block(self): + """Restored chain must still accept new valid blocks.""" + bc, alice_pk, bob_pk = self._chain_with_tx() + save(bc, path=self.tmpdir) + + restored = load(path=self.tmpdir) + + # Build a second transfer on top of the loaded chain + alice_sk, alice_pk2 = _make_keypair() + _, carol_pk = _make_keypair() + restored.state.credit_mining_reward(alice_pk2, 50) + + tx2 = Transaction(alice_pk2, carol_pk, 10, 0) + tx2.sign(alice_sk) + + block2 = Block( + index=len(restored.chain), + previous_hash=restored.last_block.hash, + transactions=[tx2], + difficulty=1, + ) + mine_block(block2, difficulty=1) + + self.assertTrue(restored.add_block(block2)) + self.assertEqual(len(restored.chain), len(bc.chain) + 1) + + def test_load_missing_file_raises(self): + with self.assertRaises(FileNotFoundError): + load(path=self.tmpdir) # nothing saved yet + + def test_genesis_only_chain(self): + bc = Blockchain() + save(bc, path=self.tmpdir) + restored = load(path=self.tmpdir) + + self.assertEqual(len(restored.chain), 1) + self.assertEqual(restored.chain[0].hash, "0" * 64) + + def test_contract_storage_preserved(self): + """Contract accounts and storage survive a save/load cycle.""" + from minichain import State, Transaction as Tx + bc = Blockchain() + + deployer_sk, deployer_pk = _make_keypair() + bc.state.credit_mining_reward(deployer_pk, 100) + + code = "storage['hits'] = storage.get('hits', 0) + 1" + tx_deploy = Tx(deployer_pk, None, 0, 0, data=code) + tx_deploy.sign(deployer_sk) + contract_addr = bc.state.apply_transaction(tx_deploy) + self.assertIsInstance(contract_addr, str) + + save(bc, path=self.tmpdir) + restored = load(path=self.tmpdir) + + contract = restored.state.get_account(contract_addr) + self.assertEqual(contract["code"], code) + + +if __name__ == "__main__": + unittest.main() From d9b136731b27aaf99fe70517d3fe08da31f939f1 Mon Sep 17 00:00:00 2001 From: Aniket Date: Thu, 12 Mar 2026 02:20:11 +0530 Subject: [PATCH 2/3] Code rabbit follow up Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- tests/test_persistence.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 976a0f6..09b2746 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -168,6 +168,7 @@ def test_contract_storage_preserved(self): contract = restored.state.get_account(contract_addr) self.assertEqual(contract["code"], code) + self.assertEqual(contract["storage"]["hits"], 1) if __name__ == "__main__": From b7df4bb3a35529f510f2091b6dbc9d89bc22ed49 Mon Sep 17 00:00:00 2001 From: Aniket Date: Thu, 12 Mar 2026 02:20:59 +0530 Subject: [PATCH 3/3] Code rabbit follow up Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- minichain/persistence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/minichain/persistence.py b/minichain/persistence.py index e30bab0..8068e44 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -74,6 +74,8 @@ def load(path: str = ".") -> Blockchain: if not isinstance(raw_blocks, list) or not raw_blocks: raise ValueError(f"Invalid or empty chain data in '{chain_path}'") + if not isinstance(raw_accounts, dict): + raise ValueError(f"Invalid accounts data in '{state_path}'") blockchain = Blockchain.__new__(Blockchain) # skip __init__ (no genesis) import threading