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
-
-
-

-

-
+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:
-[](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+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
----
-
-
-
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..01f8a1e
--- /dev/null
+++ b/src/minichain/block.py
@@ -0,0 +1,84 @@
+"""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
+
+
+class BlockValidationError(ValueError):
+ """Raised when a block fails structural or semantic validation."""
+
+
+@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 validate_coinbase(self, *, block_reward: int) -> None:
+ """Validate coinbase placement and reward accounting."""
+ if block_reward < 0:
+ raise BlockValidationError("block_reward must be non-negative")
+ if not self.transactions:
+ raise BlockValidationError("Block must contain a coinbase transaction")
+ if not self.has_valid_merkle_root():
+ raise BlockValidationError("Block merkle_root does not match body")
+
+ coinbase = self.transactions[0]
+ if not coinbase.is_coinbase():
+ raise BlockValidationError("First transaction must be a valid coinbase")
+
+ for transaction in self.transactions[1:]:
+ if transaction.is_coinbase():
+ raise BlockValidationError("Coinbase transaction must only appear once")
+
+ total_fees = sum(transaction.fee for transaction in self.transactions[1:])
+ expected_amount = block_reward + total_fees
+ if coinbase.amount != expected_amount:
+ raise BlockValidationError(
+ f"Invalid coinbase amount: expected {expected_amount}, got {coinbase.amount}"
+ )
+
+ def hash(self) -> bytes:
+ return self.header.hash()
diff --git a/src/minichain/chain.py b/src/minichain/chain.py
new file mode 100644
index 0000000..acfc4a4
--- /dev/null
+++ b/src/minichain/chain.py
@@ -0,0 +1,224 @@
+"""Chain manager and fork-resolution logic."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from minichain.block import Block, BlockHeader
+from minichain.consensus import compute_next_difficulty_target, is_valid_pow
+from minichain.genesis import GENESIS_PREVIOUS_HASH
+from minichain.state import State, StateTransitionError
+
+
+class ChainValidationError(ValueError):
+ """Raised when a block or branch is invalid."""
+
+
+@dataclass(frozen=True)
+class ChainConfig:
+ """Configuration for chain validation and state transitions."""
+
+ block_reward: int = 50
+ difficulty_adjustment_interval: int = 10
+ target_block_time_seconds: int = 30
+
+ def validate(self) -> None:
+ if self.block_reward < 0:
+ raise ValueError("block_reward must be non-negative")
+ if self.difficulty_adjustment_interval <= 0:
+ raise ValueError("difficulty_adjustment_interval must be positive")
+ if self.target_block_time_seconds <= 0:
+ raise ValueError("target_block_time_seconds must be positive")
+
+
+class ChainManager:
+ """Maintains canonical chain, block index, and current canonical state."""
+
+ def __init__(
+ self,
+ *,
+ genesis_block: Block,
+ genesis_state: State,
+ config: ChainConfig | None = None,
+ ) -> None:
+ self.config = config or ChainConfig()
+ self.config.validate()
+ self._validate_genesis(genesis_block)
+
+ genesis_hash = genesis_block.hash().hex()
+ self._genesis_hash = genesis_hash
+ self._blocks_by_hash: dict[str, Block] = {genesis_hash: genesis_block}
+ self._heights: dict[str, int] = {genesis_hash: 0}
+ self._canonical_hashes: list[str] = [genesis_hash]
+ self._tip_hash = genesis_hash
+
+ self._genesis_state = genesis_state.copy()
+ self.state = genesis_state.copy()
+
+ @property
+ def tip_hash(self) -> str:
+ return self._tip_hash
+
+ @property
+ def height(self) -> int:
+ return self._heights[self._tip_hash]
+
+ @property
+ def tip_block(self) -> Block:
+ return self._blocks_by_hash[self._tip_hash]
+
+ def contains_block(self, block_hash: str) -> bool:
+ return block_hash in self._blocks_by_hash
+
+ def canonical_chain(self) -> list[Block]:
+ return [self._blocks_by_hash[block_hash] for block_hash in self._canonical_hashes]
+
+ def get_block_by_hash(self, block_hash: str) -> Block | None:
+ return self._blocks_by_hash.get(block_hash)
+
+ def get_canonical_block_by_height(self, height: int) -> Block | None:
+ if height < 0 or height >= len(self._canonical_hashes):
+ return None
+ return self._blocks_by_hash[self._canonical_hashes[height]]
+
+ def expected_next_difficulty(self, *, parent_hash: str | None = None) -> int:
+ """Compute the expected next block target after the given parent."""
+ path_hashes = (
+ self._canonical_hashes
+ if parent_hash is None
+ else self._path_from_genesis(parent_hash)
+ )
+ headers = [self._blocks_by_hash[block_hash].header for block_hash in path_hashes]
+ return compute_next_difficulty_target(
+ headers,
+ adjustment_interval=self.config.difficulty_adjustment_interval,
+ target_block_time_seconds=self.config.target_block_time_seconds,
+ )
+
+ def add_block(self, block: Block) -> str:
+ """Add a block to chain storage and update canonical tip when appropriate."""
+ block_hash = block.hash().hex()
+ if block_hash in self._blocks_by_hash:
+ return "duplicate"
+
+ parent_hash = block.header.previous_hash
+ if parent_hash not in self._blocks_by_hash:
+ raise ChainValidationError(f"Unknown parent block: {parent_hash}")
+
+ self._blocks_by_hash[block_hash] = block
+ self._heights[block_hash] = block.header.block_height
+
+ try:
+ candidate_path, candidate_state = self._replay_state_for_tip(block_hash)
+ except ChainValidationError:
+ self._blocks_by_hash.pop(block_hash, None)
+ self._heights.pop(block_hash, None)
+ raise
+
+ parent_is_tip = parent_hash == self._tip_hash
+ candidate_height = len(candidate_path) - 1
+ canonical_height = self.height
+
+ if parent_is_tip and candidate_height == canonical_height + 1:
+ self._canonical_hashes.append(block_hash)
+ self._tip_hash = block_hash
+ self.state = candidate_state
+ return "extended"
+
+ if candidate_height > canonical_height:
+ self._canonical_hashes = candidate_path
+ self._tip_hash = block_hash
+ self.state = candidate_state
+ return "reorg"
+
+ return "stored_fork"
+
+ def _replay_state_for_tip(self, tip_hash: str) -> tuple[list[str], State]:
+ path_hashes = self._path_from_genesis(tip_hash)
+ replay_state = self._genesis_state.copy()
+ replayed_headers = [self._blocks_by_hash[path_hashes[0]].header]
+
+ for index, block_hash in enumerate(path_hashes[1:], start=1):
+ block = self._blocks_by_hash[block_hash]
+ parent_hash = path_hashes[index - 1]
+ parent_header = replayed_headers[-1]
+
+ self._validate_link(
+ parent_hash=parent_hash,
+ parent_height=parent_header.block_height,
+ block=block,
+ )
+ self._validate_consensus(block=block, parent_headers=replayed_headers)
+
+ try:
+ replay_state.apply_block(block, block_reward=self.config.block_reward)
+ except StateTransitionError as exc:
+ raise ChainValidationError(f"State transition failed: {exc}") from exc
+
+ replayed_headers.append(block.header)
+
+ return path_hashes, replay_state
+
+ def _path_from_genesis(self, tip_hash: str) -> list[str]:
+ if tip_hash not in self._blocks_by_hash:
+ raise ChainValidationError(f"Unknown block hash: {tip_hash}")
+
+ path: list[str] = []
+ seen: set[str] = set()
+ cursor = tip_hash
+ while True:
+ if cursor in seen:
+ raise ChainValidationError("Cycle detected in block ancestry")
+ seen.add(cursor)
+ path.append(cursor)
+
+ if cursor == self._genesis_hash:
+ break
+
+ parent_hash = self._blocks_by_hash[cursor].header.previous_hash
+ if parent_hash not in self._blocks_by_hash:
+ raise ChainValidationError(
+ f"Missing ancestor for block {cursor}: {parent_hash}"
+ )
+ cursor = parent_hash
+
+ path.reverse()
+ if path[0] != self._genesis_hash:
+ raise ChainValidationError("Candidate chain does not start at genesis")
+ return path
+
+ def _validate_consensus(self, *, block: Block, parent_headers: list[BlockHeader]) -> None:
+ if not block.has_valid_merkle_root():
+ raise ChainValidationError("Block merkle_root does not match transaction body")
+
+ expected_target = compute_next_difficulty_target(
+ parent_headers,
+ adjustment_interval=self.config.difficulty_adjustment_interval,
+ target_block_time_seconds=self.config.target_block_time_seconds,
+ )
+ if block.header.difficulty_target != expected_target:
+ raise ChainValidationError(
+ "Invalid difficulty target: "
+ f"expected {expected_target}, got {block.header.difficulty_target}"
+ )
+ if not is_valid_pow(block.header):
+ raise ChainValidationError("Block does not satisfy Proof-of-Work target")
+
+ @staticmethod
+ def _validate_link(*, parent_hash: str, parent_height: int, block: Block) -> None:
+ if block.header.previous_hash != parent_hash:
+ raise ChainValidationError("Block previous_hash does not match parent hash")
+ expected_height = parent_height + 1
+ if block.header.block_height != expected_height:
+ raise ChainValidationError(
+ f"Invalid block height: expected {expected_height}, got {block.header.block_height}"
+ )
+
+ @staticmethod
+ def _validate_genesis(genesis_block: Block) -> None:
+ if genesis_block.header.block_height != 0:
+ raise ValueError("Genesis block height must be 0")
+ if genesis_block.header.previous_hash != GENESIS_PREVIOUS_HASH:
+ raise ValueError("Genesis previous_hash must be all zeros")
+ if genesis_block.transactions:
+ raise ValueError("Genesis block must not include transactions")
diff --git a/src/minichain/consensus.py b/src/minichain/consensus.py
new file mode 100644
index 0000000..7339569
--- /dev/null
+++ b/src/minichain/consensus.py
@@ -0,0 +1,101 @@
+"""Consensus and Proof-of-Work mining primitives."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from threading import Event
+from typing import Sequence
+
+from minichain.block import BlockHeader
+
+MAX_TARGET = (1 << 256) - 1
+
+
+class MiningInterrupted(Exception):
+ """Raised when mining is cancelled via a stop signal."""
+
+
+def hash_to_int(block_hash: bytes) -> int:
+ """Convert a hash digest into a big-endian integer."""
+ return int.from_bytes(block_hash, byteorder="big", signed=False)
+
+
+def validate_difficulty_target(target: int) -> None:
+ """Validate difficulty target bounds."""
+ if target <= 0:
+ raise ValueError("difficulty_target must be positive")
+ if target > MAX_TARGET:
+ raise ValueError("difficulty_target exceeds hash space")
+
+
+def is_valid_pow(header: BlockHeader) -> bool:
+ """Return whether a header satisfies its own difficulty target."""
+ if header.difficulty_target <= 0 or header.difficulty_target > MAX_TARGET:
+ return False
+ return hash_to_int(header.hash()) <= header.difficulty_target
+
+
+def compute_next_difficulty_target(
+ chain: Sequence[BlockHeader],
+ *,
+ adjustment_interval: int = 10,
+ target_block_time_seconds: int = 30,
+) -> int:
+ """Compute the next difficulty target using bounded proportional retargeting."""
+ if adjustment_interval <= 0:
+ raise ValueError("adjustment_interval must be positive")
+ if target_block_time_seconds <= 0:
+ raise ValueError("target_block_time_seconds must be positive")
+ if not chain:
+ raise ValueError("chain must contain at least one header")
+
+ tip = chain[-1]
+ validate_difficulty_target(tip.difficulty_target)
+
+ if tip.block_height == 0:
+ return tip.difficulty_target
+ if tip.block_height % adjustment_interval != 0:
+ return tip.difficulty_target
+ if len(chain) <= adjustment_interval:
+ return tip.difficulty_target
+
+ start_header = chain[-(adjustment_interval + 1)]
+ elapsed_seconds = tip.timestamp - start_header.timestamp
+ if elapsed_seconds <= 0:
+ elapsed_seconds = 1
+
+ expected_seconds = adjustment_interval * target_block_time_seconds
+ unbounded_target = (tip.difficulty_target * elapsed_seconds) // expected_seconds
+
+ min_target = max(1, tip.difficulty_target // 2)
+ max_target = min(MAX_TARGET, tip.difficulty_target * 2)
+ bounded_target = min(max(unbounded_target, min_target), max_target)
+
+ validate_difficulty_target(bounded_target)
+ return bounded_target
+
+
+def mine_block_header(
+ header_template: BlockHeader,
+ *,
+ start_nonce: int = 0,
+ max_nonce: int = (1 << 64) - 1,
+ stop_event: Event | None = None,
+) -> tuple[int, bytes]:
+ """Search nonces until a header hash satisfies the difficulty target."""
+ validate_difficulty_target(header_template.difficulty_target)
+ if start_nonce < 0:
+ raise ValueError("start_nonce must be non-negative")
+ if max_nonce < start_nonce:
+ raise ValueError("max_nonce must be greater than or equal to start_nonce")
+
+ for nonce in range(start_nonce, max_nonce + 1):
+ if stop_event is not None and stop_event.is_set():
+ raise MiningInterrupted("Mining interrupted by stop event")
+
+ candidate = replace(header_template, nonce=nonce)
+ digest = candidate.hash()
+ if hash_to_int(digest) <= candidate.difficulty_target:
+ return nonce, digest
+
+ raise RuntimeError("No valid nonce found within nonce range")
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/genesis.py b/src/minichain/genesis.py
new file mode 100644
index 0000000..e8e253d
--- /dev/null
+++ b/src/minichain/genesis.py
@@ -0,0 +1,81 @@
+"""Genesis block/state creation and application."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from minichain.block import Block, BlockHeader
+from minichain.crypto import blake2b_digest
+from minichain.state import Account, State
+
+GENESIS_PREVIOUS_HASH = "00" * 32
+
+
+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(frozen=True)
+class GenesisConfig:
+ """Configurable parameters for building genesis artifacts."""
+
+ initial_balances: dict[str, int] = field(default_factory=dict)
+ timestamp: int = 1_739_000_000
+ difficulty_target: int = (1 << 255) - 1
+ version: int = 0
+
+ def validate(self) -> None:
+ if self.timestamp < 0:
+ raise ValueError("Genesis timestamp must be non-negative")
+ if self.difficulty_target <= 0:
+ raise ValueError("Genesis difficulty_target must be positive")
+ for address, balance in self.initial_balances.items():
+ if not _is_lower_hex(address, 40):
+ raise ValueError(f"Invalid genesis address: {address}")
+ if balance < 0:
+ raise ValueError(f"Negative genesis balance for {address}")
+
+
+def create_genesis_block(config: GenesisConfig) -> Block:
+ """Build the genesis block (height 0, no PoW check required)."""
+ config.validate()
+ header = BlockHeader(
+ version=config.version,
+ previous_hash=GENESIS_PREVIOUS_HASH,
+ merkle_root=blake2b_digest(b"").hex(),
+ timestamp=config.timestamp,
+ difficulty_target=config.difficulty_target,
+ nonce=0,
+ block_height=0,
+ )
+ return Block(header=header, transactions=[])
+
+
+def apply_genesis_block(state: State, block: Block, config: GenesisConfig) -> None:
+ """Apply genesis allocations to an empty state."""
+ config.validate()
+ if state.accounts:
+ raise ValueError("Genesis can only be applied to an empty state")
+ if block.header.block_height != 0:
+ raise ValueError("Genesis block height must be 0")
+ if block.header.previous_hash != GENESIS_PREVIOUS_HASH:
+ raise ValueError("Genesis previous_hash must be all zeros")
+ if block.transactions:
+ raise ValueError("Genesis block must not contain transactions")
+
+ expected_merkle_root = blake2b_digest(b"").hex()
+ if block.header.merkle_root != expected_merkle_root:
+ raise ValueError("Genesis merkle_root must commit to an empty tx list")
+
+ for address, balance in config.initial_balances.items():
+ state.set_account(address, Account(balance=balance, nonce=0))
+
+
+def create_genesis_state(config: GenesisConfig) -> tuple[Block, State]:
+ """Create genesis block and initialized state in one step."""
+ block = create_genesis_block(config)
+ state = State()
+ apply_genesis_block(state, block, config)
+ return block, state
diff --git a/src/minichain/mempool.py b/src/minichain/mempool.py
new file mode 100644
index 0000000..a9e1db1
--- /dev/null
+++ b/src/minichain/mempool.py
@@ -0,0 +1,247 @@
+"""Mempool data structures and transaction selection logic."""
+
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field
+from heapq import heappop, heappush
+from typing import Iterable
+
+from minichain.state import Account, State
+from minichain.transaction import Transaction
+
+
+class MempoolValidationError(ValueError):
+ """Raised when a transaction cannot be accepted into the mempool."""
+
+
+@dataclass
+class _PoolEntry:
+ transaction: Transaction
+ transaction_id: str
+ received_at: int
+
+ @property
+ def fee(self) -> int:
+ return self.transaction.fee
+
+
+@dataclass
+class _SenderPool:
+ entries: dict[int, _PoolEntry] = field(default_factory=dict)
+ ready_nonces: set[int] = field(default_factory=set)
+ waiting_nonces: set[int] = field(default_factory=set)
+
+
+class Mempool:
+ """Holds validated pending transactions and exposes mining selection."""
+
+ def __init__(self, *, max_size: int = 1_000, max_age_seconds: int = 3_600) -> None:
+ if max_size <= 0:
+ raise ValueError("max_size must be positive")
+ if max_age_seconds <= 0:
+ raise ValueError("max_age_seconds must be positive")
+
+ self.max_size = max_size
+ self.max_age_seconds = max_age_seconds
+ self._entries_by_id: dict[str, _PoolEntry] = {}
+ self._sender_pools: dict[str, _SenderPool] = {}
+ self._id_by_sender_nonce: dict[tuple[str, int], str] = {}
+
+ def size(self) -> int:
+ return len(self._entries_by_id)
+
+ def ready_count(self) -> int:
+ return sum(len(pool.ready_nonces) for pool in self._sender_pools.values())
+
+ def waiting_count(self) -> int:
+ return sum(len(pool.waiting_nonces) for pool in self._sender_pools.values())
+
+ def contains(self, transaction_id: str) -> bool:
+ return transaction_id in self._entries_by_id
+
+ def add_transaction(
+ self,
+ transaction: Transaction,
+ state: State,
+ *,
+ received_at: int | None = None,
+ ) -> str:
+ """Validate and enqueue a transaction, returning its transaction id."""
+ if transaction.is_coinbase():
+ raise MempoolValidationError("Coinbase transactions are not accepted")
+ if not transaction.verify():
+ raise MempoolValidationError("Transaction failed signature/identity validation")
+
+ transaction_id = transaction.transaction_id().hex()
+ if transaction_id in self._entries_by_id:
+ raise MempoolValidationError("Duplicate transaction")
+
+ sender = transaction.sender
+ nonce_key = (sender, transaction.nonce)
+ if nonce_key in self._id_by_sender_nonce:
+ raise MempoolValidationError("Duplicate sender nonce in mempool")
+
+ sender_account = state.accounts.get(sender, Account())
+ if transaction.nonce < sender_account.nonce:
+ raise MempoolValidationError("Transaction nonce is stale")
+
+ if transaction.nonce == sender_account.nonce:
+ immediate_cost = transaction.amount + transaction.fee
+ if immediate_cost > sender_account.balance:
+ raise MempoolValidationError("Insufficient balance for pending transaction")
+
+ entry = _PoolEntry(
+ transaction=transaction,
+ transaction_id=transaction_id,
+ received_at=int(time.time()) if received_at is None else received_at,
+ )
+
+ pool = self._sender_pools.setdefault(sender, _SenderPool())
+ pool.entries[transaction.nonce] = entry
+ self._entries_by_id[transaction_id] = entry
+ self._id_by_sender_nonce[nonce_key] = transaction_id
+ self._recompute_sender_pool(sender, state)
+ self.evict(state, current_time=entry.received_at)
+ return transaction_id
+
+ def get_transactions_for_mining(
+ self, state: State, *, limit: int, current_time: int | None = None
+ ) -> list[Transaction]:
+ """Return up to `limit` ready transactions, prioritized by fee."""
+ if limit <= 0:
+ return []
+
+ now = int(time.time()) if current_time is None else current_time
+ self.evict(state, current_time=now)
+
+ sender_ready: dict[str, list[_PoolEntry]] = {}
+ for sender, pool in self._sender_pools.items():
+ self._recompute_sender_pool(sender, state)
+ ready_entries = sorted(
+ (pool.entries[nonce] for nonce in pool.ready_nonces),
+ key=lambda entry: entry.transaction.nonce,
+ )
+ if ready_entries:
+ sender_ready[sender] = ready_entries
+
+ heap: list[tuple[int, int, str, int]] = []
+ for sender, entries in sender_ready.items():
+ first = entries[0]
+ heappush(heap, (-first.fee, first.transaction.nonce, sender, 0))
+
+ selected: list[Transaction] = []
+ while heap and len(selected) < limit:
+ _neg_fee, _nonce, sender, index = heappop(heap)
+ entry = sender_ready[sender][index]
+ selected.append(entry.transaction)
+
+ next_index = index + 1
+ if next_index < len(sender_ready[sender]):
+ nxt = sender_ready[sender][next_index]
+ heappush(heap, (-nxt.fee, nxt.transaction.nonce, sender, next_index))
+
+ return selected
+
+ def remove_confirmed_transactions(
+ self,
+ transactions: Iterable[Transaction],
+ state: State,
+ ) -> None:
+ """Remove transactions confirmed in a block and revalidate sender queues."""
+ touched_senders: set[str] = set()
+ for transaction in transactions:
+ transaction_id = transaction.transaction_id().hex()
+ entry = self._entries_by_id.get(transaction_id)
+ if entry is None:
+ continue
+ touched_senders.add(entry.transaction.sender)
+ self._remove_entry(entry)
+
+ for sender in touched_senders:
+ self._recompute_sender_pool(sender, state)
+
+ for sender in list(self._sender_pools):
+ self._recompute_sender_pool(sender, state)
+
+ def evict(self, state: State, *, current_time: int | None = None) -> list[str]:
+ """Evict stale transactions and, if oversized, low-fee transactions."""
+ now = int(time.time()) if current_time is None else current_time
+ evicted_ids: list[str] = []
+
+ stale_ids = [
+ tx_id
+ for tx_id, entry in self._entries_by_id.items()
+ if now - entry.received_at > self.max_age_seconds
+ ]
+ for tx_id in stale_ids:
+ entry = self._entries_by_id.get(tx_id)
+ if entry is None:
+ continue
+ evicted_ids.append(tx_id)
+ self._remove_entry(entry)
+
+ while len(self._entries_by_id) > self.max_size:
+ entry = min(
+ self._entries_by_id.values(),
+ key=lambda item: (item.fee, item.received_at),
+ )
+ evicted_ids.append(entry.transaction_id)
+ self._remove_entry(entry)
+
+ for sender in list(self._sender_pools):
+ self._recompute_sender_pool(sender, state)
+
+ return evicted_ids
+
+ def _recompute_sender_pool(self, sender: str, state: State) -> None:
+ pool = self._sender_pools.get(sender)
+ if pool is None:
+ return
+
+ account = state.accounts.get(sender, Account())
+ state_nonce = account.nonce
+ available_balance = account.balance
+
+ for nonce in [nonce for nonce in pool.entries if nonce < state_nonce]:
+ self._remove_entry(pool.entries[nonce])
+
+ pool = self._sender_pools.get(sender)
+ if pool is None:
+ return
+
+ ready_nonces: set[int] = set()
+ expected_nonce = state_nonce
+ while expected_nonce in pool.entries:
+ candidate = pool.entries[expected_nonce].transaction
+ candidate_cost = candidate.amount + candidate.fee
+ if candidate_cost > available_balance:
+ break
+ ready_nonces.add(expected_nonce)
+ available_balance -= candidate_cost
+ expected_nonce += 1
+
+ all_nonces = set(pool.entries.keys())
+ pool.ready_nonces = ready_nonces
+ pool.waiting_nonces = all_nonces - ready_nonces
+
+ if not pool.entries:
+ self._sender_pools.pop(sender, None)
+
+ def _remove_entry(self, entry: _PoolEntry) -> None:
+ transaction = entry.transaction
+ sender = transaction.sender
+ nonce = transaction.nonce
+
+ self._entries_by_id.pop(entry.transaction_id, None)
+ self._id_by_sender_nonce.pop((sender, nonce), None)
+
+ pool = self._sender_pools.get(sender)
+ if pool is None:
+ return
+
+ pool.entries.pop(nonce, None)
+ pool.ready_nonces.discard(nonce)
+ pool.waiting_nonces.discard(nonce)
+ if not pool.entries:
+ self._sender_pools.pop(sender, None)
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..9238ca4
--- /dev/null
+++ b/src/minichain/state.py
@@ -0,0 +1,102 @@
+"""Account state and ledger transitions."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from minichain.block import Block, BlockValidationError
+from minichain.transaction import Transaction
+
+
+@dataclass
+class Account:
+ """Account state for an address."""
+
+ balance: int = 0
+ nonce: int = 0
+
+
+class StateTransitionError(ValueError):
+ """Raised when a transaction or block cannot be applied."""
+
+
+class State:
+ """Mutable account-state mapping and transition engine."""
+
+ def __init__(self) -> None:
+ self.accounts: dict[str, Account] = {}
+
+ def copy(self) -> State:
+ snapshot = State()
+ snapshot.accounts = {
+ address: Account(balance=account.balance, nonce=account.nonce)
+ for address, account in self.accounts.items()
+ }
+ return snapshot
+
+ def set_account(self, address: str, account: Account) -> None:
+ self.accounts[address] = account
+
+ def get_account(self, address: str) -> Account:
+ if address not in self.accounts:
+ self.accounts[address] = Account()
+ return self.accounts[address]
+
+ def apply_transaction(self, transaction: Transaction) -> None:
+ if transaction.is_coinbase():
+ raise StateTransitionError(
+ "Coinbase transaction must be applied through apply_block"
+ )
+ if not transaction.verify():
+ raise StateTransitionError("Transaction signature/identity verification failed")
+
+ sender = self.get_account(transaction.sender)
+ recipient = self.get_account(transaction.recipient)
+
+ if sender.nonce != transaction.nonce:
+ raise StateTransitionError(
+ f"Nonce mismatch for sender {transaction.sender}: "
+ f"expected {sender.nonce}, got {transaction.nonce}"
+ )
+
+ total_cost = transaction.amount + transaction.fee
+ if sender.balance < total_cost:
+ raise StateTransitionError(
+ f"Insufficient balance for sender {transaction.sender}: "
+ f"required {total_cost}, available {sender.balance}"
+ )
+
+ sender.balance -= total_cost
+ sender.nonce += 1
+ recipient.balance += transaction.amount
+
+ def apply_coinbase_transaction(self, transaction: Transaction) -> None:
+ if not transaction.is_coinbase():
+ raise StateTransitionError("Invalid coinbase transaction")
+ miner = self.get_account(transaction.recipient)
+ miner.balance += transaction.amount
+
+ def apply_block(self, block: Block, *, block_reward: int = 0) -> None:
+ try:
+ block.validate_coinbase(block_reward=block_reward)
+ except BlockValidationError as exc:
+ raise StateTransitionError(f"Block validation failed: {exc}") from exc
+
+ snapshot = self.copy()
+ try:
+ self.apply_coinbase_transaction(block.transactions[0])
+ for transaction in block.transactions[1:]:
+ self.apply_transaction(transaction)
+ except StateTransitionError as exc:
+ self.accounts = snapshot.accounts
+ raise StateTransitionError(f"Block application failed: {exc}") from exc
+
+
+def apply_transaction(state: State, transaction: Transaction) -> None:
+ """Apply a transaction to state with validation."""
+ state.apply_transaction(transaction)
+
+
+def apply_block(state: State, block: Block, *, block_reward: int = 0) -> None:
+ """Apply all block transactions atomically, rolling back on failure."""
+ state.apply_block(block, block_reward=block_reward)
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..758a659
--- /dev/null
+++ b/src/minichain/transaction.py
@@ -0,0 +1,144 @@
+"""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
+COINBASE_SENDER = "00" * 20
+
+
+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 is_coinbase(self) -> bool:
+ """Return whether this transaction follows coinbase conventions."""
+ 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.timestamp, int) or self.timestamp < 0:
+ return False
+ return (
+ self.sender == COINBASE_SENDER
+ and self.nonce == 0
+ and self.fee == 0
+ and self.signature == ""
+ and self.public_key == ""
+ )
+
+ 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 self.is_coinbase():
+ return True
+ 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)
+
+
+def create_coinbase_transaction(
+ *,
+ miner_address: str,
+ amount: int,
+ timestamp: int,
+) -> Transaction:
+ """Build a canonical coinbase transaction."""
+ coinbase = Transaction(
+ sender=COINBASE_SENDER,
+ recipient=miner_address,
+ amount=amount,
+ nonce=0,
+ fee=0,
+ timestamp=timestamp,
+ )
+ if not coinbase.is_coinbase():
+ raise ValueError("Invalid coinbase transaction fields")
+ return coinbase
diff --git a/tests/test_block.py b/tests/test_block.py
new file mode 100644
index 0000000..0be3783
--- /dev/null
+++ b/tests/test_block.py
@@ -0,0 +1,118 @@
+"""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, BlockValidationError
+from minichain.crypto import derive_address, generate_key_pair
+from minichain.transaction import Transaction, create_coinbase_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 _make_block_with_coinbase(*, block_reward: int = 50) -> Block:
+ miner_key, miner_verify = generate_key_pair()
+ _ = miner_key
+ regular_transactions = [
+ _make_signed_transaction(amount=10, nonce=0),
+ _make_signed_transaction(amount=11, nonce=1),
+ ]
+ coinbase = create_coinbase_transaction(
+ miner_address=derive_address(miner_verify),
+ amount=block_reward + sum(tx.fee for tx in regular_transactions),
+ timestamp=1_739_800_111,
+ )
+ 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=[coinbase, *regular_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()
+
+
+def test_validate_coinbase_accepts_correct_amount() -> None:
+ block = _make_block_with_coinbase(block_reward=50)
+ block.validate_coinbase(block_reward=50)
+
+
+def test_validate_coinbase_rejects_wrong_amount() -> None:
+ block = _make_block_with_coinbase(block_reward=50)
+ block.transactions[0].amount += 1
+ block.update_header_merkle_root()
+
+ with pytest.raises(BlockValidationError, match="Invalid coinbase amount"):
+ block.validate_coinbase(block_reward=50)
diff --git a/tests/test_chain.py b/tests/test_chain.py
new file mode 100644
index 0000000..0b50f5c
--- /dev/null
+++ b/tests/test_chain.py
@@ -0,0 +1,196 @@
+"""Unit tests for chain management and fork resolution."""
+
+from __future__ import annotations
+
+import pytest
+
+pytest.importorskip("nacl")
+
+from minichain.block import Block, BlockHeader
+from minichain.chain import ChainConfig, ChainManager, ChainValidationError
+from minichain.consensus import MAX_TARGET
+from minichain.genesis import GenesisConfig, create_genesis_state
+from minichain.transaction import create_coinbase_transaction
+
+
+def _build_manager(*, block_reward: int = 50) -> ChainManager:
+ genesis_block, genesis_state = create_genesis_state(
+ GenesisConfig(
+ initial_balances={},
+ timestamp=1_739_000_000,
+ difficulty_target=MAX_TARGET,
+ )
+ )
+ return ChainManager(
+ genesis_block=genesis_block,
+ genesis_state=genesis_state,
+ config=ChainConfig(
+ block_reward=block_reward,
+ difficulty_adjustment_interval=10,
+ target_block_time_seconds=30,
+ ),
+ )
+
+
+def _coinbase_block(
+ manager: ChainManager,
+ *,
+ parent: Block,
+ miner_address: str,
+ timestamp: int,
+ coinbase_amount: int | None = None,
+ difficulty_target: int | None = None,
+) -> Block:
+ reward_amount = manager.config.block_reward if coinbase_amount is None else coinbase_amount
+ target = (
+ manager.expected_next_difficulty(parent_hash=parent.hash().hex())
+ if difficulty_target is None
+ else difficulty_target
+ )
+ coinbase = create_coinbase_transaction(
+ miner_address=miner_address,
+ amount=reward_amount,
+ timestamp=timestamp,
+ )
+ header = BlockHeader(
+ version=0,
+ previous_hash=parent.hash().hex(),
+ merkle_root="",
+ timestamp=timestamp,
+ difficulty_target=target,
+ nonce=0,
+ block_height=parent.header.block_height + 1,
+ )
+ block = Block(header=header, transactions=[coinbase])
+ block.update_header_merkle_root()
+ return block
+
+
+def test_appends_valid_blocks_to_tip() -> None:
+ manager = _build_manager(block_reward=50)
+ miner = "11" * 20
+
+ block_1 = _coinbase_block(
+ manager,
+ parent=manager.tip_block,
+ miner_address=miner,
+ timestamp=1_739_000_030,
+ )
+ result_1 = manager.add_block(block_1)
+ assert result_1 == "extended"
+ assert manager.height == 1
+
+ block_2 = _coinbase_block(
+ manager,
+ parent=manager.tip_block,
+ miner_address=miner,
+ timestamp=1_739_000_060,
+ )
+ result_2 = manager.add_block(block_2)
+ assert result_2 == "extended"
+ assert manager.height == 2
+ assert manager.tip_hash == block_2.hash().hex()
+ assert manager.state.get_account(miner).balance == 100
+
+
+def test_longer_fork_triggers_reorg_and_state_replay() -> None:
+ manager = _build_manager(block_reward=50)
+ miner_a = "11" * 20
+ miner_b = "22" * 20
+
+ a1 = _coinbase_block(
+ manager,
+ parent=manager.tip_block,
+ miner_address=miner_a,
+ timestamp=1_739_000_030,
+ )
+ manager.add_block(a1)
+
+ a2 = _coinbase_block(
+ manager,
+ parent=a1,
+ miner_address=miner_a,
+ timestamp=1_739_000_060,
+ )
+ assert manager.add_block(a2) == "extended"
+ assert manager.state.get_account(miner_a).balance == 100
+
+ b2 = _coinbase_block(
+ manager,
+ parent=a1,
+ miner_address=miner_b,
+ timestamp=1_739_000_061,
+ )
+ assert manager.add_block(b2) == "stored_fork"
+
+ b3 = _coinbase_block(
+ manager,
+ parent=b2,
+ miner_address=miner_b,
+ timestamp=1_739_000_090,
+ )
+ assert manager.add_block(b3) == "reorg"
+ assert manager.tip_hash == b3.hash().hex()
+ assert manager.height == 3
+ assert manager.state.get_account(miner_a).balance == 50
+ assert manager.state.get_account(miner_b).balance == 100
+
+
+def test_rejects_block_with_unknown_parent() -> None:
+ manager = _build_manager(block_reward=50)
+ coinbase = create_coinbase_transaction(
+ miner_address="33" * 20,
+ amount=50,
+ timestamp=1_739_000_030,
+ )
+ block = Block(
+ header=BlockHeader(
+ version=0,
+ previous_hash="ff" * 32,
+ merkle_root="",
+ timestamp=1_739_000_030,
+ difficulty_target=MAX_TARGET,
+ nonce=0,
+ block_height=1,
+ ),
+ transactions=[coinbase],
+ )
+ block.update_header_merkle_root()
+
+ with pytest.raises(ChainValidationError, match="Unknown parent block"):
+ manager.add_block(block)
+
+
+def test_rejects_invalid_coinbase_amount() -> None:
+ manager = _build_manager(block_reward=50)
+
+ invalid_block = _coinbase_block(
+ manager,
+ parent=manager.tip_block,
+ miner_address="44" * 20,
+ timestamp=1_739_000_030,
+ coinbase_amount=60,
+ )
+ invalid_hash = invalid_block.hash().hex()
+ with pytest.raises(ChainValidationError, match="State transition failed"):
+ manager.add_block(invalid_block)
+
+ assert manager.height == 0
+ assert not manager.contains_block(invalid_hash)
+
+
+def test_rejects_block_with_wrong_difficulty_target() -> None:
+ manager = _build_manager(block_reward=50)
+ expected = manager.expected_next_difficulty(parent_hash=manager.tip_hash)
+ wrong_target = expected - 1
+
+ invalid_block = _coinbase_block(
+ manager,
+ parent=manager.tip_block,
+ miner_address="55" * 20,
+ timestamp=1_739_000_030,
+ difficulty_target=wrong_target,
+ )
+
+ with pytest.raises(ChainValidationError, match="Invalid difficulty target"):
+ manager.add_block(invalid_block)
diff --git a/tests/test_consensus.py b/tests/test_consensus.py
new file mode 100644
index 0000000..9e7dcfb
--- /dev/null
+++ b/tests/test_consensus.py
@@ -0,0 +1,168 @@
+"""Unit tests for Proof-of-Work mining primitives."""
+
+from __future__ import annotations
+
+from threading import Event
+
+from minichain.block import BlockHeader
+from minichain.consensus import (
+ MiningInterrupted,
+ compute_next_difficulty_target,
+ is_valid_pow,
+ mine_block_header,
+)
+
+
+def _header_template(difficulty_target: int) -> BlockHeader:
+ return BlockHeader(
+ version=0,
+ previous_hash="00" * 32,
+ merkle_root="11" * 32,
+ timestamp=1_740_000_000,
+ difficulty_target=difficulty_target,
+ nonce=0,
+ block_height=10,
+ )
+
+
+def _make_chain(
+ *,
+ heights: list[int],
+ timestamps: list[int],
+ difficulty_target: int,
+) -> list[BlockHeader]:
+ if len(heights) != len(timestamps):
+ raise ValueError("heights and timestamps must have the same length")
+ return [
+ BlockHeader(
+ version=0,
+ previous_hash=f"{height:064x}",
+ merkle_root="22" * 32,
+ timestamp=timestamp,
+ difficulty_target=difficulty_target,
+ nonce=0,
+ block_height=height,
+ )
+ for height, timestamp in zip(heights, timestamps, strict=True)
+ ]
+
+
+def test_valid_pow_is_accepted() -> None:
+ header = _header_template(difficulty_target=(1 << 256) - 1)
+ assert is_valid_pow(header)
+
+
+def test_invalid_pow_is_rejected() -> None:
+ header = _header_template(difficulty_target=1)
+ assert not is_valid_pow(header)
+
+
+def test_mining_finds_valid_nonce_for_reasonable_target() -> None:
+ header = _header_template(difficulty_target=1 << 252)
+ nonce, _digest = mine_block_header(header, max_nonce=500_000)
+
+ mined_header = BlockHeader(
+ version=header.version,
+ previous_hash=header.previous_hash,
+ merkle_root=header.merkle_root,
+ timestamp=header.timestamp,
+ difficulty_target=header.difficulty_target,
+ nonce=nonce,
+ block_height=header.block_height,
+ )
+ assert is_valid_pow(mined_header)
+
+
+def test_mining_honors_stop_event() -> None:
+ header = _header_template(difficulty_target=1 << 240)
+ stop = Event()
+ stop.set()
+
+ try:
+ mine_block_header(header, max_nonce=1_000_000, stop_event=stop)
+ except MiningInterrupted as exc:
+ assert "interrupted" in str(exc).lower()
+ else:
+ raise AssertionError("Expected mining interruption")
+
+
+def test_mining_raises_when_nonce_range_exhausted() -> None:
+ header = _header_template(difficulty_target=1)
+ try:
+ mine_block_header(header, start_nonce=0, max_nonce=10)
+ except RuntimeError as exc:
+ assert "No valid nonce found" in str(exc)
+ else:
+ raise AssertionError("Expected RuntimeError when nonce space exhausted")
+
+
+def test_difficulty_unchanged_when_not_on_adjustment_height() -> None:
+ chain = _make_chain(
+ heights=[0, 1, 2, 3, 5],
+ timestamps=[0, 10, 20, 30, 40],
+ difficulty_target=1_000_000,
+ )
+ assert (
+ compute_next_difficulty_target(
+ chain,
+ adjustment_interval=4,
+ target_block_time_seconds=10,
+ )
+ == 1_000_000
+ )
+
+
+def test_difficulty_target_decreases_when_blocks_are_fast() -> None:
+ chain = _make_chain(
+ heights=[0, 1, 2, 3, 4],
+ timestamps=[0, 5, 10, 15, 20],
+ difficulty_target=1_000_000,
+ )
+ new_target = compute_next_difficulty_target(
+ chain,
+ adjustment_interval=4,
+ target_block_time_seconds=10,
+ )
+ assert new_target == 500_000
+
+
+def test_difficulty_target_increases_when_blocks_are_slow() -> None:
+ chain = _make_chain(
+ heights=[0, 1, 2, 3, 4],
+ timestamps=[0, 20, 40, 60, 80],
+ difficulty_target=1_000_000,
+ )
+ new_target = compute_next_difficulty_target(
+ chain,
+ adjustment_interval=4,
+ target_block_time_seconds=10,
+ )
+ assert new_target == 2_000_000
+
+
+def test_difficulty_adjustment_is_capped_to_half_on_extreme_speed() -> None:
+ chain = _make_chain(
+ heights=[0, 1, 2, 3, 4],
+ timestamps=[0, 1, 2, 3, 4],
+ difficulty_target=1_000_000,
+ )
+ new_target = compute_next_difficulty_target(
+ chain,
+ adjustment_interval=4,
+ target_block_time_seconds=10,
+ )
+ assert new_target == 500_000
+
+
+def test_difficulty_adjustment_is_capped_to_double_on_extreme_delay() -> None:
+ chain = _make_chain(
+ heights=[0, 1, 2, 3, 4],
+ timestamps=[0, 100, 200, 300, 400],
+ difficulty_target=1_000_000,
+ )
+ new_target = compute_next_difficulty_target(
+ chain,
+ adjustment_interval=4,
+ target_block_time_seconds=10,
+ )
+ assert new_target == 2_000_000
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_genesis.py b/tests/test_genesis.py
new file mode 100644
index 0000000..1db85b4
--- /dev/null
+++ b/tests/test_genesis.py
@@ -0,0 +1,85 @@
+"""Unit tests for genesis block and state initialization."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+from minichain.crypto import blake2b_digest
+from minichain.genesis import (
+ GENESIS_PREVIOUS_HASH,
+ GenesisConfig,
+ apply_genesis_block,
+ create_genesis_block,
+ create_genesis_state,
+)
+from minichain.state import Account, State
+
+
+def test_create_genesis_block_uses_conventional_fields() -> None:
+ config = GenesisConfig(
+ initial_balances={"11" * 20: 1_000_000},
+ timestamp=1_739_123_456,
+ difficulty_target=123_456,
+ version=0,
+ )
+
+ block = create_genesis_block(config)
+
+ assert block.header.block_height == 0
+ assert block.header.previous_hash == GENESIS_PREVIOUS_HASH
+ assert block.header.timestamp == config.timestamp
+ assert block.header.difficulty_target == config.difficulty_target
+ assert block.header.nonce == 0
+ assert block.header.merkle_root == blake2b_digest(b"").hex()
+ assert block.transactions == []
+
+
+def test_apply_genesis_block_initializes_expected_balances() -> None:
+ balances = {"aa" * 20: 500, "bb" * 20: 300}
+ config = GenesisConfig(initial_balances=balances)
+ block = create_genesis_block(config)
+ state = State()
+
+ apply_genesis_block(state, block, config)
+
+ assert state.get_account("aa" * 20).balance == 500
+ assert state.get_account("aa" * 20).nonce == 0
+ assert state.get_account("bb" * 20).balance == 300
+ assert state.get_account("bb" * 20).nonce == 0
+
+
+def test_create_genesis_state_builds_block_and_state() -> None:
+ config = GenesisConfig(initial_balances={"cc" * 20: 42})
+
+ block, state = create_genesis_state(config)
+
+ assert block.header.block_height == 0
+ assert state.get_account("cc" * 20).balance == 42
+
+
+def test_genesis_requires_empty_state() -> None:
+ config = GenesisConfig(initial_balances={"dd" * 20: 1})
+ block = create_genesis_block(config)
+ state = State()
+ state.set_account("ff" * 20, Account(balance=1, nonce=0))
+
+ try:
+ apply_genesis_block(state, block, config)
+ except ValueError as exc:
+ assert "empty state" in str(exc)
+ else:
+ raise AssertionError("Expected ValueError for non-empty state")
+
+
+def test_genesis_block_rejects_wrong_previous_hash() -> None:
+ config = GenesisConfig(initial_balances={"ee" * 20: 10})
+ block = create_genesis_block(config)
+ block.header = replace(block.header, previous_hash="11" * 32)
+ state = State()
+
+ try:
+ apply_genesis_block(state, block, config)
+ except ValueError as exc:
+ assert "previous_hash" in str(exc)
+ else:
+ raise AssertionError("Expected ValueError for invalid previous_hash")
diff --git a/tests/test_mempool.py b/tests/test_mempool.py
new file mode 100644
index 0000000..8cc2d8a
--- /dev/null
+++ b/tests/test_mempool.py
@@ -0,0 +1,313 @@
+"""Unit tests for mempool transaction queuing and selection behavior."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+
+import pytest
+
+pytest.importorskip("nacl")
+
+from minichain.crypto import derive_address, generate_key_pair
+from minichain.mempool import Mempool, MempoolValidationError
+from minichain.state import Account, State
+from minichain.transaction import Transaction
+
+
+def _signed_transaction(
+ *,
+ sender_key: object,
+ sender_address: str,
+ recipient: str,
+ amount: int,
+ nonce: int,
+ fee: int,
+ timestamp: int = 1_739_950_000,
+) -> Transaction:
+ transaction = Transaction(
+ sender=sender_address,
+ recipient=recipient,
+ amount=amount,
+ nonce=nonce,
+ fee=fee,
+ timestamp=timestamp + nonce,
+ )
+ transaction.sign(sender_key)
+ return transaction
+
+
+def test_deduplicates_transactions_by_id() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender = derive_address(sender_verify)
+ recipient = derive_address(recipient_verify)
+ state = State()
+ state.set_account(sender, Account(balance=100, nonce=0))
+ mempool = Mempool()
+
+ tx = _signed_transaction(
+ sender_key=sender_key,
+ sender_address=sender,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=1,
+ )
+
+ mempool.add_transaction(tx, state)
+ with pytest.raises(MempoolValidationError, match="Duplicate transaction"):
+ mempool.add_transaction(tx, state)
+
+
+def test_fee_priority_respects_sender_nonce_ordering() -> None:
+ a_key, a_verify = generate_key_pair()
+ b_key, b_verify = generate_key_pair()
+ c_key, c_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender_a = derive_address(a_verify)
+ sender_b = derive_address(b_verify)
+ sender_c = derive_address(c_verify)
+ recipient = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender_a, Account(balance=100, nonce=0))
+ state.set_account(sender_b, Account(balance=100, nonce=0))
+ state.set_account(sender_c, Account(balance=100, nonce=0))
+ mempool = Mempool()
+
+ tx_a0 = _signed_transaction(
+ sender_key=a_key,
+ sender_address=sender_a,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=1,
+ )
+ tx_a1 = _signed_transaction(
+ sender_key=a_key,
+ sender_address=sender_a,
+ recipient=recipient,
+ amount=5,
+ nonce=1,
+ fee=10,
+ )
+ tx_b0 = _signed_transaction(
+ sender_key=b_key,
+ sender_address=sender_b,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=8,
+ )
+ tx_c0 = _signed_transaction(
+ sender_key=c_key,
+ sender_address=sender_c,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=4,
+ )
+
+ mempool.add_transaction(tx_a1, state)
+ mempool.add_transaction(tx_b0, state)
+ mempool.add_transaction(tx_a0, state)
+ mempool.add_transaction(tx_c0, state)
+
+ selected = mempool.get_transactions_for_mining(state, limit=4)
+
+ assert [tx.fee for tx in selected] == [8, 4, 1, 10]
+ assert selected[2].sender == sender_a and selected[2].nonce == 0
+ assert selected[3].sender == sender_a and selected[3].nonce == 1
+
+
+def test_evicts_low_fee_when_pool_exceeds_max_size() -> None:
+ s1_key, s1_verify = generate_key_pair()
+ s2_key, s2_verify = generate_key_pair()
+ s3_key, s3_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender1 = derive_address(s1_verify)
+ sender2 = derive_address(s2_verify)
+ sender3 = derive_address(s3_verify)
+ recipient = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender1, Account(balance=100, nonce=0))
+ state.set_account(sender2, Account(balance=100, nonce=0))
+ state.set_account(sender3, Account(balance=100, nonce=0))
+ mempool = Mempool(max_size=2, max_age_seconds=10_000)
+
+ tx1 = _signed_transaction(
+ sender_key=s1_key,
+ sender_address=sender1,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=1,
+ )
+ tx2 = _signed_transaction(
+ sender_key=s2_key,
+ sender_address=sender2,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=6,
+ )
+ tx3 = _signed_transaction(
+ sender_key=s3_key,
+ sender_address=sender3,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=3,
+ )
+
+ id1 = mempool.add_transaction(tx1, state, received_at=1)
+ id2 = mempool.add_transaction(tx2, state, received_at=2)
+ id3 = mempool.add_transaction(tx3, state, received_at=3)
+
+ assert mempool.size() == 2
+ assert not mempool.contains(id1)
+ assert mempool.contains(id2)
+ assert mempool.contains(id3)
+
+
+def test_nonce_gap_is_held_then_promoted_when_gap_filled() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender = derive_address(sender_verify)
+ recipient = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender, Account(balance=100, nonce=0))
+ mempool = Mempool()
+
+ tx_nonce_1 = _signed_transaction(
+ sender_key=sender_key,
+ sender_address=sender,
+ recipient=recipient,
+ amount=5,
+ nonce=1,
+ fee=5,
+ )
+ tx_nonce_0 = _signed_transaction(
+ sender_key=sender_key,
+ sender_address=sender,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=1,
+ )
+
+ mempool.add_transaction(tx_nonce_1, state)
+ assert mempool.ready_count() == 0
+ assert mempool.waiting_count() == 1
+
+ mempool.add_transaction(tx_nonce_0, state)
+ assert mempool.ready_count() == 2
+ assert mempool.waiting_count() == 0
+
+ selected = mempool.get_transactions_for_mining(state, limit=2)
+ assert [tx.nonce for tx in selected] == [0, 1]
+
+
+def test_confirmed_transaction_removal_revalidates_pending_set() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender = derive_address(sender_verify)
+ recipient = derive_address(recipient_verify)
+ state = State()
+ state.set_account(sender, Account(balance=100, nonce=0))
+ mempool = Mempool()
+
+ tx0 = _signed_transaction(
+ sender_key=sender_key,
+ sender_address=sender,
+ recipient=recipient,
+ amount=10,
+ nonce=0,
+ fee=2,
+ )
+ tx1 = _signed_transaction(
+ sender_key=sender_key,
+ sender_address=sender,
+ recipient=recipient,
+ amount=10,
+ nonce=1,
+ fee=1,
+ )
+
+ mempool.add_transaction(tx0, state)
+ mempool.add_transaction(tx1, state)
+ assert mempool.size() == 2
+ assert mempool.ready_count() == 2
+
+ state.apply_transaction(tx0)
+ mempool.remove_confirmed_transactions([tx0], state)
+
+ assert mempool.size() == 1
+ assert mempool.ready_count() == 1
+ selected = mempool.get_transactions_for_mining(state, limit=1)
+ assert selected[0].nonce == 1
+
+
+def test_rejects_duplicate_sender_nonce_even_if_tx_id_differs() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender = derive_address(sender_verify)
+ recipient = derive_address(recipient_verify)
+ state = State()
+ state.set_account(sender, Account(balance=100, nonce=0))
+ mempool = Mempool()
+
+ tx = _signed_transaction(
+ sender_key=sender_key,
+ sender_address=sender,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=1,
+ )
+ tx_modified = replace(tx, amount=6)
+ tx_modified.sign(sender_key)
+
+ mempool.add_transaction(tx, state)
+ with pytest.raises(MempoolValidationError, match="Duplicate sender nonce"):
+ mempool.add_transaction(tx_modified, state)
+
+
+def test_evicts_stale_transactions_by_age() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender = derive_address(sender_verify)
+ recipient = derive_address(recipient_verify)
+ state = State()
+ state.set_account(sender, Account(balance=100, nonce=0))
+ mempool = Mempool(max_size=10, max_age_seconds=10)
+
+ tx = _signed_transaction(
+ sender_key=sender_key,
+ sender_address=sender,
+ recipient=recipient,
+ amount=5,
+ nonce=0,
+ fee=1,
+ )
+ tx_id = mempool.add_transaction(tx, state, received_at=100)
+
+ evicted = mempool.evict(state, current_time=111)
+ assert tx_id in evicted
+ assert mempool.size() == 0
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..36d6d7c
--- /dev/null
+++ b/tests/test_scaffold.py
@@ -0,0 +1,35 @@
+"""Scaffolding checks for Issue #1."""
+
+from __future__ import annotations
+
+import importlib
+
+COMPONENT_MODULES = [
+ "crypto",
+ "transaction",
+ "block",
+ "state",
+ "mempool",
+ "consensus",
+ "network",
+ "storage",
+ "node",
+ "serialization",
+ "merkle",
+ "genesis",
+ "chain",
+]
+
+
+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_state.py b/tests/test_state.py
new file mode 100644
index 0000000..256ca84
--- /dev/null
+++ b/tests/test_state.py
@@ -0,0 +1,268 @@
+"""Unit tests for account state transitions."""
+
+from __future__ import annotations
+
+import pytest
+
+pytest.importorskip("nacl")
+
+from minichain.block import Block, BlockHeader
+from minichain.crypto import derive_address, generate_key_pair
+from minichain.state import Account, State, StateTransitionError
+from minichain.transaction import Transaction, create_coinbase_transaction
+
+
+def _signed_transaction(
+ sender_key: object,
+ sender_address: str,
+ recipient: str,
+ amount: int,
+ nonce: int,
+ fee: int = 1,
+ timestamp: int = 1_739_900_000,
+) -> Transaction:
+ tx = Transaction(
+ sender=sender_address,
+ recipient=recipient,
+ amount=amount,
+ nonce=nonce,
+ fee=fee,
+ timestamp=timestamp + nonce,
+ )
+ tx.sign(sender_key)
+ return tx
+
+
+def _block_with_transactions(
+ *,
+ miner_address: str,
+ transactions: list[Transaction],
+ block_reward: int,
+) -> Block:
+ coinbase = create_coinbase_transaction(
+ miner_address=miner_address,
+ amount=block_reward + sum(tx.fee for tx in transactions),
+ timestamp=1_739_900_100,
+ )
+ header = BlockHeader(
+ version=0,
+ previous_hash="00" * 32,
+ merkle_root="",
+ timestamp=1_739_900_100,
+ difficulty_target=1_000_000,
+ nonce=0,
+ block_height=1,
+ )
+ block = Block(header=header, transactions=[coinbase, *transactions])
+ block.update_header_merkle_root()
+ return block
+
+
+def test_successful_transfer_updates_balances_and_nonce() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=100, nonce=0))
+
+ tx = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=25, nonce=0, fee=2
+ )
+ state.apply_transaction(tx)
+
+ assert state.get_account(sender_address).balance == 73
+ assert state.get_account(sender_address).nonce == 1
+ assert state.get_account(recipient_address).balance == 25
+ assert state.get_account(recipient_address).nonce == 0
+
+
+def test_insufficient_balance_is_rejected() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=5, nonce=0))
+
+ tx = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=10, nonce=0, fee=1
+ )
+
+ with pytest.raises(StateTransitionError, match="Insufficient balance"):
+ state.apply_transaction(tx)
+
+
+def test_nonce_mismatch_is_rejected() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=100, nonce=1))
+
+ tx = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=10, nonce=0, fee=1
+ )
+
+ with pytest.raises(StateTransitionError, match="Nonce mismatch"):
+ state.apply_transaction(tx)
+
+
+def test_transfer_to_new_address_creates_recipient_account() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=50, nonce=0))
+ assert recipient_address not in state.accounts
+
+ tx = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=10, nonce=0, fee=1
+ )
+ state.apply_transaction(tx)
+
+ assert recipient_address in state.accounts
+ assert state.get_account(recipient_address).balance == 10
+
+
+def test_apply_block_is_atomic_and_rolls_back_on_failure() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ miner_key, miner_verify = generate_key_pair()
+ _ = recipient_key
+ _ = miner_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+ miner_address = derive_address(miner_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=100, nonce=0))
+
+ tx_ok = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=10, nonce=0, fee=1
+ )
+ tx_fail = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=95, nonce=1, fee=10
+ )
+ block_reward = 50
+ block = _block_with_transactions(
+ miner_address=miner_address,
+ transactions=[tx_ok, tx_fail],
+ block_reward=block_reward,
+ )
+
+ with pytest.raises(StateTransitionError, match="Block application failed"):
+ state.apply_block(block, block_reward=block_reward)
+
+ assert state.get_account(sender_address).balance == 100
+ assert state.get_account(sender_address).nonce == 0
+ assert state.get_account(recipient_address).balance == 0
+ assert state.get_account(recipient_address).nonce == 0
+ assert miner_address not in state.accounts
+
+
+def test_apply_block_with_valid_coinbase_pays_reward_and_fees() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ miner_key, miner_verify = generate_key_pair()
+ _ = recipient_key
+ _ = miner_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+ miner_address = derive_address(miner_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=100, nonce=0))
+
+ tx = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=25, nonce=0, fee=3
+ )
+ block_reward = 50
+ block = _block_with_transactions(
+ miner_address=miner_address,
+ transactions=[tx],
+ block_reward=block_reward,
+ )
+
+ state.apply_block(block, block_reward=block_reward)
+
+ assert state.get_account(sender_address).balance == 72
+ assert state.get_account(sender_address).nonce == 1
+ assert state.get_account(recipient_address).balance == 25
+ assert state.get_account(miner_address).balance == 53
+
+
+def test_block_with_incorrect_coinbase_amount_is_rejected() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ miner_key, miner_verify = generate_key_pair()
+ _ = recipient_key
+ _ = miner_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+ miner_address = derive_address(miner_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=100, nonce=0))
+
+ tx = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=10, nonce=0, fee=2
+ )
+ block_reward = 50
+ block = _block_with_transactions(
+ miner_address=miner_address,
+ transactions=[tx],
+ block_reward=block_reward,
+ )
+ block.transactions[0].amount += 1
+ block.update_header_merkle_root()
+
+ with pytest.raises(StateTransitionError, match="Invalid coinbase amount"):
+ state.apply_block(block, block_reward=block_reward)
+
+
+def test_block_without_coinbase_is_rejected() -> None:
+ sender_key, sender_verify = generate_key_pair()
+ recipient_key, recipient_verify = generate_key_pair()
+ _ = recipient_key
+
+ sender_address = derive_address(sender_verify)
+ recipient_address = derive_address(recipient_verify)
+
+ state = State()
+ state.set_account(sender_address, Account(balance=100, nonce=0))
+
+ tx = _signed_transaction(
+ sender_key, sender_address, recipient_address, amount=10, nonce=0, fee=1
+ )
+ header = BlockHeader(
+ version=0,
+ previous_hash="00" * 32,
+ merkle_root="",
+ timestamp=1_739_900_100,
+ difficulty_target=1_000_000,
+ nonce=0,
+ block_height=1,
+ )
+ block = Block(header=header, transactions=[tx])
+ block.update_header_merkle_root()
+
+ with pytest.raises(StateTransitionError, match="coinbase"):
+ state.apply_block(block, block_reward=50)
diff --git a/tests/test_transaction.py b/tests/test_transaction.py
new file mode 100644
index 0000000..d5b2540
--- /dev/null
+++ b/tests/test_transaction.py
@@ -0,0 +1,85 @@
+"""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 COINBASE_SENDER, Transaction, create_coinbase_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
+
+
+def test_coinbase_transaction_verifies_without_signature() -> None:
+ tx = create_coinbase_transaction(
+ miner_address="ef" * 20,
+ amount=55,
+ timestamp=1_739_760_111,
+ )
+
+ assert tx.sender == COINBASE_SENDER
+ assert tx.verify()
+
+
+def test_coinbase_with_auth_fields_is_rejected() -> None:
+ tx = create_coinbase_transaction(
+ miner_address="ef" * 20,
+ amount=55,
+ timestamp=1_739_760_111,
+ )
+ tampered = replace(tx, signature="00" * 64)
+
+ assert not tampered.verify()