Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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",
]
142 changes: 142 additions & 0 deletions minichain/persistence.py
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)

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
175 changes: 175 additions & 0 deletions tests/test_persistence.py
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()

# 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)
self.assertEqual(contract["storage"]["hits"], 1)


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