generated from StabilityNexus/Template-Repo
-
-
Notifications
You must be signed in to change notification settings - Fork 8
persist blockchain across sessions via save/load #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
aniket866
wants to merge
3
commits into
StabilityNexus:main
Choose a base branch
from
aniket866:persistence
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| """ | ||
| 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}'") | ||
| 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 | ||
| 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) | ||
aniket866 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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: | ||
aniket866 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| """ | ||
| 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() | ||
aniket866 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| # 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): | ||
aniket866 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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() | ||
aniket866 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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) | ||
aniket866 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| self.assertEqual(contract["storage"]["hits"], 1) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.