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 8aa023ba36942bac53efb19e5ae656d892be2242 Mon Sep 17 00:00:00 2001 From: aniket866 Date: Tue, 17 Mar 2026 02:01:50 +0530 Subject: [PATCH 2/3] persistence-with-demo --- .gitignore | 2 ++ main.py | 45 +++++++++++++++++++++++++++++----------- minichain/persistence.py | 5 +++++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index b8edfb6..4b02fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ *.cb2 .*.lb + ## Intermediate documents: *.dvi *.xdv @@ -332,3 +333,4 @@ __pycache__/ *.so *bore.zip *bore_bin +node_data/ diff --git a/main.py b/main.py index 12577dd..47012c0 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ import argparse import asyncio import logging +import os import re import sys @@ -26,11 +27,13 @@ from nacl.encoding import HexEncoder from minichain import Transaction, Blockchain, Block, State, Mempool, P2PNetwork, mine_block +from minichain.persistence import save, load logger = logging.getLogger(__name__) BURN_ADDRESS = "0" * 40 +DATA_DIR = "node_data" # ────────────────────────────────────────────── @@ -63,11 +66,11 @@ def mine_and_process_block(chain, mempool, miner_pk): mined_block = mine_block(block) if chain.add_block(mined_block): - logger.info("✅ Block #%d mined and added (%d txs)", mined_block.index, len(pending_txs)) + logger.info("Block #%d mined and added (%d txs)", mined_block.index, len(pending_txs)) chain.state.credit_mining_reward(miner_pk) return mined_block else: - logger.error("❌ Block rejected by chain") + logger.error("Block rejected by chain") return None @@ -88,13 +91,13 @@ async def handler(data): for addr, acc in remote_accounts.items(): if addr not in chain.state.accounts: chain.state.accounts[addr] = acc - logger.info("🔄 Synced account %s... (balance=%d)", addr[:12], acc.get("balance", 0)) - logger.info("🔄 State sync complete — %d accounts", len(chain.state.accounts)) + logger.info("Synced account %s... (balance=%d)", addr[:12], acc.get("balance", 0)) + logger.info("State sync complete — %d accounts", len(chain.state.accounts)) elif msg_type == "tx": tx = Transaction(**payload) if mempool.add_transaction(tx): - logger.info("📥 Received tx from %s... (amount=%s)", tx.sender[:8], tx.amount) + logger.info("Received tx from %s... (amount=%s)", tx.sender[:8], tx.amount) elif msg_type == "block": txs_raw = payload.pop("transactions", []) @@ -112,7 +115,7 @@ async def handler(data): block.hash = block_hash if chain.add_block(block): - logger.info("📥 Received Block #%d — added to chain", block.index) + logger.info("Received Block #%d — added to chain", block.index) # Apply mining reward for the remote miner (burn address as placeholder) miner = payload.get("miner", BURN_ADDRESS) @@ -121,7 +124,7 @@ async def handler(data): # Drain matching txs from mempool so they aren't re-mined mempool.get_transactions_for_block() else: - logger.warning("📥 Received Block #%s — rejected", block.index) + logger.warning("Received Block #%s — rejected", block.index) return handler @@ -192,9 +195,9 @@ async def cli_loop(sk, pk, chain, mempool, network, nonce_counter): if mempool.add_transaction(tx): nonce_counter[0] += 1 await network.broadcast_transaction(tx) - print(f" ✅ Tx sent: {amount} coins → {receiver[:12]}...") + print(f" Tx sent: {amount} coins -> {receiver[:12]}...") else: - print(" ❌ Transaction rejected (invalid sig, duplicate, or mempool full).") + print(" Transaction rejected (invalid sig, duplicate, or mempool full).") # ── mine ── elif cmd == "mine": @@ -253,7 +256,19 @@ async def run_node(port: int, connect_to: str | None, fund: int): """Boot the node, optionally connect to a peer, then enter the CLI.""" sk, pk = create_wallet() - chain = Blockchain() + # ── Load existing chain or start fresh ────────────────────────────────── + chain_file = os.path.join(DATA_DIR, "blockchain.json") + if os.path.exists(chain_file): + try: + chain = load(path=DATA_DIR) + logger.info("Loaded existing chain (%d blocks) from '%s'", len(chain.chain), DATA_DIR) + except Exception as e: + logger.warning("Could not load chain: %s — starting fresh.", e) + chain = Blockchain() + else: + chain = Blockchain() + logger.info("No saved chain found — starting fresh.") + mempool = Mempool() network = P2PNetwork() @@ -269,7 +284,7 @@ async def on_peer_connected(writer): }) + "\n" writer.write(sync_msg.encode()) await writer.drain() - logger.info("🔄 Sent state sync to new peer") + logger.info("Sent state sync to new peer") network._on_peer_connected = on_peer_connected @@ -278,7 +293,7 @@ async def on_peer_connected(writer): # Fund this node's wallet so it can transact in the demo if fund > 0: chain.state.credit_mining_reward(pk, reward=fund) - logger.info("💰 Funded %s... with %d coins", pk[:12], fund) + logger.info("Funded %s... with %d coins", pk[:12], fund) # Connect to a seed peer if requested if connect_to: @@ -294,6 +309,12 @@ async def on_peer_connected(writer): try: await cli_loop(sk, pk, chain, mempool, network, nonce_counter) finally: + try: + os.makedirs(DATA_DIR, exist_ok=True) + save(chain, path=DATA_DIR) + logger.info("Chain saved to '%s' (%d blocks)", DATA_DIR, len(chain.chain)) + except Exception as e: + logger.error("Failed to save chain: %s", e) await network.stop() diff --git a/minichain/persistence.py b/minichain/persistence.py index e30bab0..50a8e7c 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -75,6 +75,10 @@ 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}'") + # FIX: validate raw_accounts is a dict before use + 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 from .state import State @@ -138,3 +142,4 @@ def _deserialize_block(data: dict) -> Block: # any future change in the hash algorithm. block.merkle_root = data.get("merkle_root") return block + \ No newline at end of file From d105c9a78a9617c67b233a9022291c4e428bdf20 Mon Sep 17 00:00:00 2001 From: Aniket Date: Tue, 17 Mar 2026 02:14:53 +0530 Subject: [PATCH 3/3] Code rabbit follow up Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- main.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index f24e5f1..d48557a 100644 --- a/main.py +++ b/main.py @@ -86,8 +86,9 @@ def mine_and_process_block(chain, mempool, miner_pk): mined_block = mine_block(block) if chain.add_block(mined_block): - logger.info("Block #%d mined and added (%d txs)", mined_block.index, len(pending_txs)) - logger.info("✅ Block #%d mined and added (%d txs)", mined_block.index, len(mineable_txs)) + if chain.add_block(mined_block): + logger.info("✅ Block #%d mined and added (%d txs)", mined_block.index, len(mineable_txs)) + mempool.remove_transactions(mineable_txs) mempool.remove_transactions(mineable_txs) chain.state.credit_mining_reward(miner_pk) return mined_block