diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a41da18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e .[dev] + + - name: Lint + run: make lint + + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore index 9308a4b..d40d7cf 100644 --- a/.gitignore +++ b/.gitignore @@ -258,6 +258,17 @@ pythontex-files-*/ # easy-todo *.lod +# MiniChain local planning docs (do not commit) +issues.md +architectureProposal.md + +# Python caches and virtualenvs +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ + # xcolor *.xcp @@ -324,3 +335,6 @@ TSWLatexianTemp* # option is specified. Footnotes are the stored in a file with suffix Notes.bib. # Uncomment the next line to have this generated file ignored. #*Notes.bib + + +docs/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a99220a --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +PYTHON ?= python3 + +.PHONY: install dev-install test lint format start-node + +install: + $(PYTHON) -m pip install . + +dev-install: + $(PYTHON) -m pip install -e .[dev] + +test: + $(PYTHON) -m pytest + +lint: + $(PYTHON) -m ruff check src tests + +format: + $(PYTHON) -m ruff format src tests + +start-node: + PYTHONPATH=src $(PYTHON) -m minichain --host 127.0.0.1 --port 7000 diff --git a/README.md b/README.md index 0ea906d..7706e0a 100644 --- a/README.md +++ b/README.md @@ -1,236 +1,71 @@ - -
+# MiniChain - -
- Stability Nexus - -
+MiniChain is a minimal, research-oriented blockchain implementation in Python. This repository currently contains the project scaffolding and development environment for the v0 core chain roadmap. -  +## Current Status - -
+Issue #1 (project scaffolding) is implemented with: -[![Static Badge](https://img.shields.io/badge/Stability_Nexus-/TODO-228B22?style=for-the-badge&labelColor=FFC517)](https://TODO.stability.nexus/) +- Python package layout under `src/minichain` +- Placeholder component modules for: + - `crypto`, `transaction`, `block`, `state`, `mempool`, `consensus`, `network`, `storage`, `node` +- `pyproject.toml` project configuration +- `Makefile` for common developer tasks +- Basic CI workflow (`.github/workflows/ci.yml`) +- Baseline tests under `tests/` - +## Requirements -
+- Python 3.11+ - -

- - -Telegram Badge -   - - -X (formerly Twitter) Badge -   - - -Discord Badge -   - - - Medium Badge -   - - - LinkedIn Badge -   - - - Youtube Badge -

- ---- - -
-

MiniChain

-
- -MiniChain is a minimal fully functional blockchain implemented in Python. - -### Background and Motivation - -* Most well-known blockchains are now several years old and have accumulated a lot of technical debt. - Simply forking their codebases is not an optimal option for starting a new chain. - -* MiniChain will be focused on research. Its primary purpose is not to be yet another blockchain - trying to be the one blockchain to kill them all, but rather to serve as a clean codebase that can be a benchmark for research of - variations of the technology. (We hope that MiniChain will be as valuable for blockchain research as, for instance, - MiniSat was valuable for satisfiability and automated reasoning research. MiniSat had less than 600 lines of C++ code.) - -* MiniChain will be focused on education. By having a clean and small codebase, devs will be able to understand - blockchains by looking at the codebase. - -* The blockchain space is again going through a phase where many new blockchains are being launched. - Back in 2017 and 2018, such an expansion period led to many general frameworks for blockchains, - such as Scorex and various Hyperledger frameworks. But most of these frameworks suffered from speculative generality and - were bloated. They focused on extensibility and configurability. MiniChain has a different philosophy: - focus on minimality and, therefore, ease of modification. - -* Recent advances in networking and crypto libraries for Python make it possible to develop MiniChain in Python. - Given that Python is one of the easiest languages to learn and results in usually boilerplate-minimized and easy to read code, - implementing MiniChain in Python aligns with MiniChain's educational goal. - - -### Overview of Tasks - -* Develop a fully functional minimal blockchain in Python, with all the expected components: - peer-to-peer networking, consensus, mempool, ledger, ... - -* Bonus task: add smart contracts to the blockchain. - -Candidates are expected to refine these tasks in their GSoC proposals. -It is encouraged that you develop an initial prototype during the application phase. - -### Requirements - -* Use [PyNaCl](https://pynacl.readthedocs.io/en/latest/) library for hashing, signing transactions and verifying signatures. -* Use [Py-libp2p](https://github.com/libp2p/py-libp2p/tree/main) for p2p networking. -* Implement Proof-of-Work as the consensus protocol. -* Use accounts (instead of UTxO) as the accounting model for the ledger. -* Use as few lines of code as possible without compromising readability and understandability. -* For the bonus task, make Python itself be the language used for smart contracts, but watch out for security concerns related to executing arbitrary code from untrusted sources. - -### Resources - -* Read this book: https://www.marabu.dev/blockchain-foundations.pdf - - ---- - -## Project Maturity - -TODO: In the checklist below, mark the items that have been completed and delete items that are not applicable to the current project: - -* [ ] The project has a logo. -* [ ] The project has a favicon. -* [ ] The protocol: - - [ ] has been described and formally specified in a paper. - - [ ] has had its main properties mathematically proven. - - [ ] has been formally verified. -* [ ] The smart contracts: - - [ ] were thoroughly reviewed by at least two knights of The Stable Order. - - [ ] were deployed to: - - [ ] Ergo - - [ ] Cardano - - [ ] EVM Chains: - - [ ] Ethereum Classic - - [ ] Ethereum - - [ ] Polygon - - [ ] BSC - - [ ] Base -* [ ] The mobile app: - - [ ] has an _About_ page containing the Stability Nexus's logo and pointing to the social media accounts of the Stability Nexus. - - [ ] is available for download as a release in this repo. - - [ ] is available in the relevant app stores. -* [ ] The web frontend: - - [ ] has proper title and metadata. - - [ ] has proper open graph metadata, to ensure that it is shown well when shared in social media (Discord, Telegram, Twitter, LinkedIn). - - [ ] has a footer, containing the Stability Nexus's logo and pointing to the social media accounts of the Stability Nexus. - - [ ] is fully static and client-side. - - [ ] is deployed to Github Pages via a Github Workflow. - - [ ] is accessible through the https://TODO:PROJECT-NAME.stability.nexus domain. -* [ ] the project is listed in [https://stability.nexus/protocols](https://stability.nexus/protocols). - ---- - -## Tech Stack - -TODO: - -### Frontend - -TODO: - -- Next.js 14+ (React) -- TypeScript -- TailwindCSS -- shadcn/ui - -### Blockchain - -TODO: - -- Wagmi -- Solidity Smart Contracts -- Ethers.js - ---- - -## Getting Started - -### Prerequisites - -TODO - -- Node.js 18+ -- npm/yarn/pnpm -- MetaMask or any other web3 wallet browser extension - -### Installation - -TODO - -#### 1. Clone the Repository +## Setup ```bash -git clone https://github.com/StabilityNexus/TODO.git -cd TODO +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +make dev-install ``` -#### 2. Install Dependencies - -Using your preferred package manager: +If you also want networking dependencies: ```bash -npm install -# or -yarn install -# or -pnpm install +python -m pip install -e .[network] ``` -#### 3. Run the Development Server - -Start the app locally: +## Common Commands ```bash -npm run dev -# or -yarn dev -# or -pnpm dev +make test # run unit tests +make lint # run ruff checks +make format # format with ruff +make start-node # run scaffold node entrypoint ``` -#### 4. Open your Browser - -Navigate to [http://localhost:3000](http://localhost:3000) to see the application. - ---- - -## Contributing - -We welcome contributions of all kinds! To contribute: - -1. Fork the repository and create your feature branch (`git checkout -b feature/AmazingFeature`). -2. Commit your changes (`git commit -m 'Add some AmazingFeature'`). -3. Run the development workflow commands to ensure code quality: - - `npm run format:write` - - `npm run lint:fix` - - `npm run typecheck` -4. Push your branch (`git push origin feature/AmazingFeature`). -5. Open a Pull Request for review. +## Run the Node Entrypoint -If you encounter bugs, need help, or have feature requests: - -- Please open an issue in this repository providing detailed information. -- Describe the problem clearly and include any relevant logs or screenshots. - -We appreciate your feedback and contributions! +```bash +PYTHONPATH=src python -m minichain --host 127.0.0.1 --port 7000 +``` -© 2025 The Stable Order. +## Repository Layout + +```text +.github/workflows/ci.yml +src/minichain/ + __init__.py + __main__.py + crypto.py + transaction.py + block.py + state.py + mempool.py + consensus.py + network.py + storage.py + node.py +tests/ + test_scaffold.py +issues.md +architectureProposal.md +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b22da7e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "minichain" +version = "0.1.0" +description = "Minimal, research-oriented blockchain in Python" +readme = "README.md" +requires-python = ">=3.11" +authors = [{ name = "MiniChain Contributors" }] +dependencies = [ + "PyNaCl>=1.5.0", +] + +[project.optional-dependencies] +network = [ + "py-libp2p>=0.2.0", +] +dev = [ + "pytest>=8.0", + "ruff>=0.7.0", +] + +[project.scripts] +minichain-node = "minichain.__main__:main" + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-q" +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I"] diff --git a/src/minichain/__init__.py b/src/minichain/__init__.py new file mode 100644 index 0000000..3bc48b2 --- /dev/null +++ b/src/minichain/__init__.py @@ -0,0 +1,4 @@ +"""MiniChain package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/src/minichain/__main__.py b/src/minichain/__main__.py new file mode 100644 index 0000000..614289f --- /dev/null +++ b/src/minichain/__main__.py @@ -0,0 +1,23 @@ +"""CLI entrypoint for running a MiniChain node.""" + +from __future__ import annotations + +import argparse + +from minichain.node import start_node + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run a MiniChain node.") + parser.add_argument("--host", default="127.0.0.1", help="Host interface for the node") + parser.add_argument("--port", default=7000, type=int, help="Port for the node") + return parser + + +def main() -> None: + args = build_parser().parse_args() + start_node(host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/src/minichain/block.py b/src/minichain/block.py new file mode 100644 index 0000000..f6c5829 --- /dev/null +++ b/src/minichain/block.py @@ -0,0 +1,56 @@ +"""Block primitives and block-level validation logic.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from minichain.crypto import blake2b_digest +from minichain.merkle import compute_merkle_root +from minichain.serialization import serialize_block_header +from minichain.transaction import Transaction + + +@dataclass +class BlockHeader: + """Consensus-critical block header.""" + + version: int + previous_hash: str + merkle_root: str + timestamp: int + difficulty_target: int + nonce: int + block_height: int + + def hash(self) -> bytes: + """Compute the canonical block-header hash.""" + return blake2b_digest(serialize_block_header(self)) + + def hash_hex(self) -> str: + return self.hash().hex() + + +@dataclass +class Block: + """A block containing a header and ordered transactions.""" + + header: BlockHeader + transactions: list[Transaction] = field(default_factory=list) + + def transaction_hashes(self) -> list[bytes]: + return [tx.transaction_id() for tx in self.transactions] + + def computed_merkle_root(self) -> bytes: + return compute_merkle_root(self.transaction_hashes()) + + def computed_merkle_root_hex(self) -> str: + return self.computed_merkle_root().hex() + + def update_header_merkle_root(self) -> None: + self.header.merkle_root = self.computed_merkle_root_hex() + + def has_valid_merkle_root(self) -> bool: + return self.header.merkle_root == self.computed_merkle_root_hex() + + def hash(self) -> bytes: + return self.header.hash() diff --git a/src/minichain/consensus.py b/src/minichain/consensus.py new file mode 100644 index 0000000..41953b9 --- /dev/null +++ b/src/minichain/consensus.py @@ -0,0 +1 @@ +"""Consensus and mining primitives (to be implemented).""" diff --git a/src/minichain/crypto.py b/src/minichain/crypto.py new file mode 100644 index 0000000..ede02d6 --- /dev/null +++ b/src/minichain/crypto.py @@ -0,0 +1,84 @@ +"""Cryptographic identity and signature helpers.""" + +from __future__ import annotations + +from typing import Any + +try: + from nacl.encoding import HexEncoder, RawEncoder + from nacl.exceptions import BadSignatureError + from nacl.hash import blake2b + from nacl.signing import SigningKey, VerifyKey +except ModuleNotFoundError as exc: # pragma: no cover - exercised in dependency-light envs + _NACL_IMPORT_ERROR = exc + HexEncoder = RawEncoder = None # type: ignore[assignment] + BadSignatureError = Exception # type: ignore[assignment] + SigningKey = VerifyKey = Any # type: ignore[assignment] + +ADDRESS_LENGTH_BYTES = 20 + + +def _require_nacl() -> None: + if "blake2b" not in globals(): + msg = "PyNaCl is required for minichain.crypto. Install with: pip install PyNaCl" + raise RuntimeError(msg) from _NACL_IMPORT_ERROR + + +def generate_key_pair() -> tuple[SigningKey, VerifyKey]: + """Generate a new Ed25519 keypair.""" + _require_nacl() + signing_key = SigningKey.generate() + return signing_key, signing_key.verify_key + + +def derive_address(verify_key: VerifyKey) -> str: + """Derive a 20-byte address from a verify key as lowercase hex.""" + _require_nacl() + digest = blake2b_digest(verify_key.encode()) + return digest[:ADDRESS_LENGTH_BYTES].hex() + + +def blake2b_digest(data: bytes) -> bytes: + """Compute a 32-byte BLAKE2b digest.""" + _require_nacl() + return blake2b(data, encoder=RawEncoder) + + +def serialize_signing_key(signing_key: SigningKey) -> str: + """Serialize a signing key into a hex string.""" + _require_nacl() + return signing_key.encode(encoder=HexEncoder).decode("ascii") + + +def deserialize_signing_key(signing_key_hex: str) -> SigningKey: + """Deserialize a signing key from a hex string.""" + _require_nacl() + return SigningKey(signing_key_hex, encoder=HexEncoder) + + +def serialize_verify_key(verify_key: VerifyKey) -> str: + """Serialize a verify key into a hex string.""" + _require_nacl() + return verify_key.encode(encoder=HexEncoder).decode("ascii") + + +def deserialize_verify_key(verify_key_hex: str) -> VerifyKey: + """Deserialize a verify key from a hex string.""" + _require_nacl() + return VerifyKey(verify_key_hex, encoder=HexEncoder) + + +def sign_message(message: bytes, signing_key: SigningKey) -> bytes: + """Sign bytes and return the detached signature bytes.""" + _require_nacl() + return signing_key.sign(message).signature + + +def verify_signature(message: bytes, signature: bytes, verify_key: VerifyKey) -> bool: + """Verify a detached Ed25519 signature.""" + _require_nacl() + try: + verify_key.verify(message, signature) + except BadSignatureError: + return False + return True diff --git a/src/minichain/mempool.py b/src/minichain/mempool.py new file mode 100644 index 0000000..3e15d3b --- /dev/null +++ b/src/minichain/mempool.py @@ -0,0 +1 @@ +"""Mempool data structures and transaction selection logic (to be implemented).""" diff --git a/src/minichain/merkle.py b/src/minichain/merkle.py new file mode 100644 index 0000000..d043f81 --- /dev/null +++ b/src/minichain/merkle.py @@ -0,0 +1,27 @@ +"""Merkle tree construction for transaction commitments.""" + +from __future__ import annotations + +from minichain.crypto import blake2b_digest + + +def _hash_pair(left: bytes, right: bytes) -> bytes: + return blake2b_digest(left + right) + + +def compute_merkle_root(leaves: list[bytes]) -> bytes: + """Compute the Merkle root from pre-hashed leaf bytes.""" + if not leaves: + return blake2b_digest(b"") + + level = [bytes(leaf) for leaf in leaves] + while len(level) > 1: + if len(level) % 2 == 1: + level.append(level[-1]) + + next_level: list[bytes] = [] + for i in range(0, len(level), 2): + next_level.append(_hash_pair(level[i], level[i + 1])) + level = next_level + + return level[0] diff --git a/src/minichain/network.py b/src/minichain/network.py new file mode 100644 index 0000000..7245a33 --- /dev/null +++ b/src/minichain/network.py @@ -0,0 +1 @@ +"""P2P networking layer built on py-libp2p (to be implemented).""" diff --git a/src/minichain/node.py b/src/minichain/node.py new file mode 100644 index 0000000..8922753 --- /dev/null +++ b/src/minichain/node.py @@ -0,0 +1,8 @@ +"""Node orchestration layer for MiniChain.""" + +from __future__ import annotations + + +def start_node(host: str, port: int) -> None: + """Start a MiniChain node (placeholder for Issue #20 integration).""" + print(f"MiniChain node scaffold started on {host}:{port}") diff --git a/src/minichain/serialization.py b/src/minichain/serialization.py new file mode 100644 index 0000000..a9d91cd --- /dev/null +++ b/src/minichain/serialization.py @@ -0,0 +1,66 @@ +"""Deterministic serialization helpers for consensus-critical data.""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +TRANSACTION_FIELD_ORDER = ( + "sender", + "recipient", + "amount", + "nonce", + "fee", + "timestamp", +) + +BLOCK_HEADER_FIELD_ORDER = ( + "version", + "previous_hash", + "merkle_root", + "timestamp", + "difficulty_target", + "nonce", + "block_height", +) + + +def _to_field_map( + value: Mapping[str, Any] | object, field_order: tuple[str, ...] +) -> dict[str, Any]: + if isinstance(value, Mapping): + source = dict(value) + else: + source = {field: getattr(value, field) for field in field_order if hasattr(value, field)} + + missing = [field for field in field_order if field not in source] + if missing: + raise ValueError(f"Missing required fields: {', '.join(missing)}") + + extras = sorted(set(source) - set(field_order)) + if extras: + raise ValueError(f"Unexpected fields: {', '.join(extras)}") + + return {field: source[field] for field in field_order} + + +def serialize_canonical(value: Mapping[str, Any] | object, field_order: tuple[str, ...]) -> bytes: + """Serialize a structure to canonical UTF-8 JSON bytes.""" + canonical_map = _to_field_map(value, field_order) + text = json.dumps( + canonical_map, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + return text.encode("utf-8") + + +def serialize_transaction(value: Mapping[str, Any] | object) -> bytes: + """Serialize a transaction using the canonical transaction field order.""" + return serialize_canonical(value, TRANSACTION_FIELD_ORDER) + + +def serialize_block_header(value: Mapping[str, Any] | object) -> bytes: + """Serialize a block header using the canonical block header field order.""" + return serialize_canonical(value, BLOCK_HEADER_FIELD_ORDER) diff --git a/src/minichain/state.py b/src/minichain/state.py new file mode 100644 index 0000000..16dc1a0 --- /dev/null +++ b/src/minichain/state.py @@ -0,0 +1 @@ +"""Account state and ledger transitions (to be implemented).""" diff --git a/src/minichain/storage.py b/src/minichain/storage.py new file mode 100644 index 0000000..0b8f8ee --- /dev/null +++ b/src/minichain/storage.py @@ -0,0 +1 @@ +"""Persistent storage integration (to be implemented).""" diff --git a/src/minichain/transaction.py b/src/minichain/transaction.py new file mode 100644 index 0000000..551b6e5 --- /dev/null +++ b/src/minichain/transaction.py @@ -0,0 +1,105 @@ +"""Transaction data structures and validation rules.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from minichain.crypto import ( + blake2b_digest, + derive_address, + deserialize_verify_key, + serialize_verify_key, + sign_message, + verify_signature, +) +from minichain.serialization import serialize_transaction + +ADDRESS_HEX_LENGTH = 40 +PUBLIC_KEY_HEX_LENGTH = 64 +SIGNATURE_HEX_LENGTH = 128 + + +def _is_lower_hex(value: str, expected_length: int) -> bool: + if len(value) != expected_length: + return False + return all(ch in "0123456789abcdef" for ch in value) + + +@dataclass +class Transaction: + """A signed account-transfer transaction.""" + + sender: str + recipient: str + amount: int + nonce: int + fee: int + timestamp: int + signature: str = "" + public_key: str = "" + + def signing_payload(self) -> dict[str, int | str]: + """Return the canonical transaction payload that is signed.""" + return { + "sender": self.sender, + "recipient": self.recipient, + "amount": self.amount, + "nonce": self.nonce, + "fee": self.fee, + "timestamp": self.timestamp, + } + + def signing_bytes(self) -> bytes: + """Return canonical bytes for signature generation/verification.""" + return serialize_transaction(self.signing_payload()) + + def transaction_id(self) -> bytes: + """Return a deterministic transaction hash for Merkle commitments.""" + digest_input = bytearray(self.signing_bytes()) + if self.signature: + digest_input.extend(bytes.fromhex(self.signature)) + if self.public_key: + digest_input.extend(bytes.fromhex(self.public_key)) + return blake2b_digest(bytes(digest_input)) + + def _validate_common_fields(self) -> bool: + if not _is_lower_hex(self.sender, ADDRESS_HEX_LENGTH): + return False + if not _is_lower_hex(self.recipient, ADDRESS_HEX_LENGTH): + return False + if not isinstance(self.amount, int) or self.amount < 0: + return False + if not isinstance(self.nonce, int) or self.nonce < 0: + return False + if not isinstance(self.fee, int) or self.fee < 0: + return False + if not isinstance(self.timestamp, int) or self.timestamp < 0: + return False + return True + + def sign(self, signing_key: object) -> None: + """Sign this transaction in-place and populate auth fields.""" + if not self._validate_common_fields(): + raise ValueError("Invalid transaction fields") + verify_key = signing_key.verify_key + self.public_key = serialize_verify_key(verify_key) + self.signature = sign_message(self.signing_bytes(), signing_key).hex() + + def verify(self) -> bool: + """Verify transaction structure, signer identity, and signature.""" + if not self._validate_common_fields(): + return False + if not _is_lower_hex(self.public_key, PUBLIC_KEY_HEX_LENGTH): + return False + if not _is_lower_hex(self.signature, SIGNATURE_HEX_LENGTH): + return False + + try: + verify_key = deserialize_verify_key(self.public_key) + except Exception: + return False + + if derive_address(verify_key) != self.sender: + return False + signature_bytes = bytes.fromhex(self.signature) + return verify_signature(self.signing_bytes(), signature_bytes, verify_key) diff --git a/tests/test_block.py b/tests/test_block.py new file mode 100644 index 0000000..8554366 --- /dev/null +++ b/tests/test_block.py @@ -0,0 +1,78 @@ +"""Unit tests for block hashing and transaction commitments.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +pytest.importorskip("nacl") + +from minichain.block import Block, BlockHeader +from minichain.crypto import derive_address, generate_key_pair +from minichain.transaction import Transaction + + +def _make_signed_transaction(amount: int, nonce: int) -> Transaction: + signing_key, verify_key = generate_key_pair() + tx = Transaction( + sender=derive_address(verify_key), + recipient="ab" * 20, + amount=amount, + nonce=nonce, + fee=1, + timestamp=1_739_800_000 + nonce, + ) + tx.sign(signing_key) + return tx + + +def _make_block() -> Block: + transactions = [ + _make_signed_transaction(amount=10, nonce=0), + _make_signed_transaction(amount=11, nonce=1), + ] + header = BlockHeader( + version=0, + previous_hash="00" * 32, + merkle_root="", + timestamp=1_739_800_111, + difficulty_target=1_000_000, + nonce=7, + block_height=1, + ) + block = Block(header=header, transactions=transactions) + block.update_header_merkle_root() + return block + + +def test_block_hash_is_deterministic() -> None: + block = _make_block() + assert block.hash() == block.hash() + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("version", 1), + ("previous_hash", "11" * 32), + ("merkle_root", "22" * 32), + ("timestamp", 1_739_800_222), + ("difficulty_target", 2_000_000), + ("nonce", 8), + ("block_height", 2), + ], +) +def test_changing_header_field_changes_hash(field: str, value: int | str) -> None: + block = _make_block() + mutated_header = replace(block.header, **{field: value}) + + assert block.header.hash() != mutated_header.hash() + + +def test_header_merkle_root_matches_transaction_body() -> None: + block = _make_block() + assert block.has_valid_merkle_root() + + block.transactions[0].amount += 1 + assert not block.has_valid_merkle_root() diff --git a/tests/test_crypto.py b/tests/test_crypto.py new file mode 100644 index 0000000..2b40967 --- /dev/null +++ b/tests/test_crypto.py @@ -0,0 +1,63 @@ +"""Unit tests for the cryptographic identity module.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("nacl") + +from minichain.crypto import ( + derive_address, + deserialize_signing_key, + deserialize_verify_key, + generate_key_pair, + serialize_signing_key, + serialize_verify_key, + sign_message, + verify_signature, +) + + +def test_generated_key_pair_can_sign_and_verify() -> None: + signing_key, verify_key = generate_key_pair() + message = b"minichain-crypto-test" + + signature = sign_message(message, signing_key) + + assert verify_signature(message, signature, verify_key) + + +def test_address_derivation_is_deterministic() -> None: + signing_key, verify_key = generate_key_pair() + first = derive_address(verify_key) + second = derive_address(verify_key) + + assert first == second + assert first == derive_address(signing_key.verify_key) + assert len(first) == 40 + + +def test_invalid_signature_is_rejected() -> None: + signing_key, verify_key = generate_key_pair() + other_signing_key, _ = generate_key_pair() + message = b"minichain-message" + + wrong_signature = sign_message(message, other_signing_key) + + assert not verify_signature(message, wrong_signature, verify_key) + + +def test_key_hex_serialization_round_trip() -> None: + signing_key, verify_key = generate_key_pair() + + signing_key_hex = serialize_signing_key(signing_key) + verify_key_hex = serialize_verify_key(verify_key) + + decoded_signing_key = deserialize_signing_key(signing_key_hex) + decoded_verify_key = deserialize_verify_key(verify_key_hex) + + message = b"serialization-round-trip" + signature = sign_message(message, decoded_signing_key) + + assert verify_signature(message, signature, decoded_verify_key) + assert derive_address(decoded_verify_key) == derive_address(verify_key) diff --git a/tests/test_merkle.py b/tests/test_merkle.py new file mode 100644 index 0000000..a271f75 --- /dev/null +++ b/tests/test_merkle.py @@ -0,0 +1,37 @@ +"""Unit tests for Merkle tree construction.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("nacl") + +from minichain.crypto import blake2b_digest +from minichain.merkle import compute_merkle_root + + +def test_empty_leaf_list_has_well_defined_root() -> None: + assert compute_merkle_root([]) == blake2b_digest(b"") + + +def test_merkle_root_is_deterministic() -> None: + leaves = [blake2b_digest(b"tx-a"), blake2b_digest(b"tx-b"), blake2b_digest(b"tx-c")] + first = compute_merkle_root(leaves) + second = compute_merkle_root(list(leaves)) + assert first == second + + +def test_merkle_root_changes_when_leaf_changes() -> None: + base = [blake2b_digest(b"tx-a"), blake2b_digest(b"tx-b"), blake2b_digest(b"tx-c")] + modified = [blake2b_digest(b"tx-a"), blake2b_digest(b"tx-b-mutated"), blake2b_digest(b"tx-c")] + assert compute_merkle_root(base) != compute_merkle_root(modified) + + +def test_odd_leaf_count_duplicates_last_leaf() -> None: + leaves = [blake2b_digest(b"tx-a"), blake2b_digest(b"tx-b"), blake2b_digest(b"tx-c")] + + left = blake2b_digest(leaves[0] + leaves[1]) + right = blake2b_digest(leaves[2] + leaves[2]) + expected = blake2b_digest(left + right) + + assert compute_merkle_root(leaves) == expected diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py new file mode 100644 index 0000000..a783321 --- /dev/null +++ b/tests/test_scaffold.py @@ -0,0 +1,33 @@ +"""Scaffolding checks for Issue #1.""" + +from __future__ import annotations + +import importlib + +COMPONENT_MODULES = [ + "crypto", + "transaction", + "block", + "state", + "mempool", + "consensus", + "network", + "storage", + "node", + "serialization", + "merkle", +] + + +def test_component_modules_are_importable() -> None: + for module in COMPONENT_MODULES: + imported = importlib.import_module(f"minichain.{module}") + assert imported is not None + + +def test_cli_parser_defaults() -> None: + from minichain.__main__ import build_parser + + args = build_parser().parse_args([]) + assert args.host == "127.0.0.1" + assert args.port == 7000 diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..4741fde --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,102 @@ +"""Tests for deterministic serialization.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from minichain.serialization import serialize_block_header, serialize_transaction + + +def test_transaction_serialization_is_deterministic() -> None: + tx_a = { + "sender": "a1" * 20, + "recipient": "b2" * 20, + "amount": 25, + "nonce": 1, + "fee": 2, + "timestamp": 1_739_749_000, + } + tx_b = { + "timestamp": 1_739_749_000, + "fee": 2, + "nonce": 1, + "amount": 25, + "recipient": "b2" * 20, + "sender": "a1" * 20, + } + + serialized_a = serialize_transaction(tx_a) + serialized_b = serialize_transaction(tx_b) + + assert serialized_a == serialized_b + assert b" " not in serialized_a + + +def test_changing_transaction_field_changes_serialization() -> None: + base = { + "sender": "aa" * 20, + "recipient": "bb" * 20, + "amount": 10, + "nonce": 0, + "fee": 1, + "timestamp": 123456, + } + mutated = dict(base) + mutated["amount"] = 11 + + assert serialize_transaction(base) != serialize_transaction(mutated) + + +def test_changing_block_header_field_changes_serialization() -> None: + base = { + "version": 0, + "previous_hash": "00" * 32, + "merkle_root": "11" * 32, + "timestamp": 123_456_789, + "difficulty_target": 1_000_000, + "nonce": 7, + "block_height": 3, + } + mutated = dict(base) + mutated["nonce"] = 8 + + assert serialize_block_header(base) != serialize_block_header(mutated) + + +@pytest.mark.parametrize( + "payload,serializer,expected", + [ + ( + { + "sender": "aa" * 20, + "recipient": "bb" * 20, + "amount": 1, + "nonce": 1, + "timestamp": 1, + }, + serialize_transaction, + "Missing required fields: fee", + ), + ( + { + "version": 0, + "previous_hash": "00" * 32, + "merkle_root": "11" * 32, + "timestamp": 1, + "difficulty_target": 1, + "nonce": 1, + "block_height": 1, + "extra": "x", + }, + serialize_block_header, + "Unexpected fields: extra", + ), + ], +) +def test_required_and_unexpected_fields_are_rejected( + payload: dict[str, object], serializer: Callable[[dict[str, object]], bytes], expected: str +) -> None: + with pytest.raises(ValueError, match=expected): + serializer(payload) diff --git a/tests/test_transaction.py b/tests/test_transaction.py new file mode 100644 index 0000000..258586d --- /dev/null +++ b/tests/test_transaction.py @@ -0,0 +1,63 @@ +"""Unit tests for transaction signing and verification.""" + +from __future__ import annotations + +from dataclasses import replace + +import pytest + +pytest.importorskip("nacl") + +from minichain.crypto import derive_address, generate_key_pair, serialize_verify_key +from minichain.transaction import Transaction + + +def _build_signed_transaction() -> tuple[Transaction, object]: + signing_key, verify_key = generate_key_pair() + tx = Transaction( + sender=derive_address(verify_key), + recipient="ab" * 20, + amount=25, + nonce=0, + fee=2, + timestamp=1_739_760_000, + ) + tx.sign(signing_key) + return tx, signing_key + + +def test_valid_transaction_signing_and_verification() -> None: + tx, _ = _build_signed_transaction() + + assert tx.verify() + + +def test_tampered_transaction_amount_is_rejected() -> None: + tx, _ = _build_signed_transaction() + tampered = replace(tx, amount=tx.amount + 1) + + assert not tampered.verify() + + +def test_tampered_transaction_recipient_is_rejected() -> None: + tx, _ = _build_signed_transaction() + tampered = replace(tx, recipient="cd" * 20) + + assert not tampered.verify() + + +def test_mismatched_public_key_and_sender_is_rejected() -> None: + tx, _ = _build_signed_transaction() + other_signing_key, other_verify_key = generate_key_pair() + _ = other_signing_key + tampered = replace(tx, public_key=serialize_verify_key(other_verify_key)) + + assert not tampered.verify() + + +def test_transaction_id_changes_when_signature_changes() -> None: + tx, _ = _build_signed_transaction() + original_id = tx.transaction_id() + tampered = replace(tx, signature="00" * 64) + + assert tampered.transaction_id() != original_id