Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions minichain/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .contract import ContractMachine
from .p2p import P2PNetwork
from .mempool import Mempool
from .persistence import save, load

__all__ = [
"mine_block",
Expand All @@ -18,4 +19,6 @@
"ContractMachine",
"P2PNetwork",
"Mempool",
"save",
"load",
]
140 changes: 140 additions & 0 deletions minichain/persistence.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
aniket866 marked this conversation as resolved.

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)
Comment thread
aniket866 marked this conversation as 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:
Comment thread
aniket866 marked this conversation as 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
174 changes: 174 additions & 0 deletions tests/test_persistence.py
Original file line number Diff line number Diff line change
@@ -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()
Comment thread
aniket866 marked this conversation as 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):
Comment thread
aniket866 marked this conversation as 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()
Comment thread
aniket866 marked this conversation as 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)
Comment thread
aniket866 marked this conversation as resolved.


if __name__ == "__main__":
unittest.main()