Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions Roadmap.md

This file was deleted.

2 changes: 1 addition & 1 deletion genesis.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"chain_id": "minichain-default",
"timestamp": 1716880000000,
"difficulty": 4,
"target": "0x0000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF",
"target_block_time": 10000,
"alpha": 0.1,
"initial_supply": 1500000000,
Expand Down
2 changes: 1 addition & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ def mine_and_process_block(chain, mempool, miner_pk):
receipt_root=calculate_receipt_root(receipts),
receipts=receipts,
miner=miner_pk,
difficulty=chain.current_difficulty,
target=chain.current_target,
)

mined_block = mine_block(block)
Expand Down
48 changes: 24 additions & 24 deletions minichain/block.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import time
import hashlib
from typing import Optional
from collections.abc import Sequence
from typing import Sequence, Optional

from .transaction import Transaction
from .receipt import Receipt
Expand Down Expand Up @@ -39,27 +38,27 @@ def __init__(
self,
index: int,
previous_hash: str,
transactions: Optional[Sequence[Transaction]] = None,
timestamp: Optional[float] = None,
difficulty: Optional[int] = None,
target: int,
transactions: Sequence[Transaction] = (),
timestamp: int = 0,
state_root: Optional[str] = None,
receipt_root: Optional[str] = None,
receipts: Optional[Sequence[Receipt]] = None,
receipts: Sequence[Receipt] = (),
miner: Optional[str] = None,
):
self.index = index
self.previous_hash = previous_hash
# Freeze transactions into an immutable tuple to prevent header/body mismatch
self.transactions = tuple(transactions) if transactions else ()
self.receipts = tuple(receipts) if receipts else ()
self.transactions = tuple(transactions)
self.receipts = tuple(receipts)
self.miner = miner
# Deterministic timestamp (ms)
self.timestamp: int = (
round(time.time() * 1000)
if timestamp is None
if timestamp == 0
else int(timestamp)
)
self.difficulty: Optional[int] = difficulty
self.target: int = target
self.nonce: int = 0
self.hash: Optional[str] = None
self.state_root: Optional[str] = state_root
Expand All @@ -83,7 +82,7 @@ def to_header_dict(self):
"state_root": self.state_root,
"receipt_root": self.receipt_root,
"timestamp": self.timestamp,
"difficulty": self.difficulty,
"target": hex(self.target),
"nonce": self.nonce,
}
# Include miner in header only when present (optional field)
Expand Down Expand Up @@ -130,40 +129,41 @@ def from_dict(cls, payload: dict):
for r_payload in payload.get("receipts", [])
]

# Safely extract and cast difficulty and timestamp if they exist
raw_diff = payload.get("difficulty")
if raw_diff is not None:
parsed_diff = int(raw_diff)
if parsed_diff > 256:
raise ValueError(f"Difficulty too large: {parsed_diff}")
# Safely extract and cast target and timestamp if they exist
raw_target = payload.get("target")
if raw_target is not None:
from .network_config import MAX_TARGET
parsed_target = int(raw_target, 16) if isinstance(raw_target, str) else int(raw_target)
if not isinstance(parsed_target, int) or parsed_target <= 0 or parsed_target > MAX_TARGET:
raise ValueError(f"invalid target in payload: {parsed_target}")
else:
parsed_diff = None
raise ValueError("missing target in payload")

raw_ts = payload.get("timestamp")
parsed_ts = int(raw_ts) if raw_ts is not None else None
parsed_ts = int(raw_ts) if raw_ts is not None else 0
block = cls(
index=int(payload["index"]),
previous_hash=payload["previous_hash"],
transactions=transactions,
timestamp=parsed_ts,
difficulty=parsed_diff,
target=parsed_target,
state_root=payload.get("state_root"),
receipt_root=payload.get("receipt_root"),
receipts=receipts,
miner=payload.get("miner"),
)
block.nonce = int(payload.get("nonce", 0))
block.nonce = int(payload.get("nonce") or 0)
block.hash = payload.get("hash")

# Verify the block hash
expected_hash = block.compute_hash()
if block.hash is not None and block.hash != expected_hash:
if block.hash and block.hash != expected_hash:
raise ValueError("block hash does not match header")

# Recalculate and verify the Merkle root!
if "merkle_root" in payload and payload["merkle_root"] != block.merkle_root:
raise ValueError("merkle_root does not match transactions")

if "receipt_root" in payload:
expected_receipt_root = calculate_receipt_root(block.receipts)
if payload["receipt_root"] != expected_receipt_root:
Expand All @@ -175,7 +175,7 @@ def from_dict(cls, payload: dict):
def canonical_payload(self) -> bytes:
"""Returns the full block (header + body) as canonical bytes for networking."""
# Sanity checks to prevent broadcasting invalid blocks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will these sanity checks ever fail? If so, why? If not, then let's remove them.

if self.hash is None:
if not self.hash:
raise ValueError("block hash is missing")
if self.hash != self.compute_hash():
raise ValueError("block hash does not match header")
Expand Down
103 changes: 69 additions & 34 deletions minichain/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ def validate_block_link_and_hash(previous_block, block):
if block.hash != expected_hash:
raise ValueError(f"invalid hash {block.hash}")

target = "0" * (block.difficulty or 1)
if not block.hash.startswith(target):
raise ValueError(f"invalid Proof of Work: hash {block.hash} does not satisfy difficulty {block.difficulty}")
from .network_config import MAX_TARGET
if not isinstance(block.target, int) or block.target <= 0 or block.target > MAX_TARGET:
raise ValueError(f"invalid target: {block.target}")
if int(block.hash, 16) >= block.target:
raise ValueError(f"invalid Proof of Work: hash {block.hash} does not satisfy target {block.target}")

if block.timestamp <= previous_block.timestamp:
raise ValueError(f"invalid timestamp: {block.timestamp} is not strictly greater than previous block timestamp {previous_block.timestamp}")
Expand Down Expand Up @@ -88,19 +90,27 @@ def _create_genesis_block(self, genesis_path):
self.state.chain_id = self.chain_id

timestamp = config.get("timestamp")
difficulty = config.get("difficulty")
raw_target = config.get("target")
if raw_target is None:
logger.error("Genesis block must explicitly specify a 'target'")
sys.exit(1)

self.current_target = int(raw_target, 16) if isinstance(raw_target, str) else int(raw_target)
from .network_config import MAX_TARGET
if not isinstance(self.current_target, int) or self.current_target <= 0 or self.current_target > MAX_TARGET:
logger.error("Genesis target out of bounds: %s", self.current_target)
sys.exit(1)

self.target_block_time = config.get("target_block_time", 10000)
self.alpha = config.get("alpha", 0.1)
self.current_difficulty = difficulty
self.avg_block_time = self.target_block_time

genesis_block = Block(
index=0,
previous_hash="0",
transactions=[],
timestamp=timestamp,
difficulty=difficulty,
target=self.current_target,
state_root=self.state.state_root(),
receipt_root=None,
receipts=[]
Expand Down Expand Up @@ -133,27 +143,33 @@ def last_block(self):
def get_total_work(self, chain_list=None):
"""
Calculates the cumulative PoW of a chain.
Work is proportional to 2^difficulty.
Work is inversely proportional to target.
"""
if chain_list is None:
with self._lock:
chain_list = self.chain
return sum(2 ** (block.difficulty or 1) for block in chain_list)

def _next_difficulty(self, difficulty, avg_block_time):
"""Advance the EMA difficulty control after a block, returning the new value."""
if avg_block_time > self.target_block_time:
return max(1, difficulty - 1)
if avg_block_time < self.target_block_time:
return difficulty + 1
return difficulty
# The expected number of hashes required to find a block is (1 << 256) / target.
# This sums the expected number of hashes for all blocks in the chain,
# which represents the total computational work put into the chain.
return sum((1 << 256) // (block.target or 1) for block in chain_list)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write a comment explaining what this does.


def _next_target(self, target, avg_block_time):
"""Advance the EMA target control after a block, returning the new value."""
from .network_config import MAX_TARGET, MIN_TARGET

# Proportional difficulty adjustment:
# If blocks are too slow (avg_block_time > target_block_time), the target INCREASES (easier)
# If blocks are too fast (avg_block_time < target_block_time), the target DECREASES (harder)
new_target = (target * int(avg_block_time)) // self.target_block_time

return max(MIN_TARGET, min(MAX_TARGET, new_target))
Comment on lines +159 to +165

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate target_block_time before proportional retargeting.

If genesis config sets target_block_time to zero, Line 160 raises ZeroDivisionError. If it is a float, Line 160 can produce a non-integer target. Reject boolean, non-integer, and non-positive values when loading genesis config.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@minichain/chain.py` around lines 156 - 162, Validate genesis configuration’s
target_block_time before it reaches the proportional retargeting calculation in
the chain initialization/loading flow: reject booleans, non-integer values, and
values less than or equal to zero. Ensure only a positive integer is stored and
used by the new_target computation, preventing division by zero and fractional
targets.


def _apply_block(self, prev_block, block, state, difficulty, avg_block_time):
def _apply_block(self, prev_block, block, state, target, avg_block_time):
"""
Canonical block-application pipeline shared by add_block and resolve_conflicts.
Validates `block` against `prev_block` and applies its transactions to `state`
(mutated in place). On any non-VALID status the caller must discard `state`.
Returns: (ValidationStatus, new_difficulty, new_avg_block_time)
Returns: (ValidationStatus, new_target, new_avg_block_time)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is a new_avg_block_time being computed? I don't see it.

Potential critical bug.

"""
from .validators import ValidationStatus

Expand All @@ -162,18 +178,18 @@ def _apply_block(self, prev_block, block, state, difficulty, avg_block_time):
except ValueError as exc:
logger.warning("Block %s rejected: %s", block.index, exc)
status = ValidationStatus.INVALID if "hash" in str(exc) else ValidationStatus.FAILED
return status, difficulty, avg_block_time
return status, target, avg_block_time

if block.difficulty != difficulty:
logger.warning("Block %s rejected: Invalid difficulty. Expected %s, got %s", block.index, difficulty, block.difficulty)
return ValidationStatus.INVALID, difficulty, avg_block_time
if block.target != target:
logger.warning("Block %s rejected: Invalid target. Expected %s, got %s", block.index, target, block.target)
return ValidationStatus.INVALID, target, avg_block_time

receipts = []
for tx in block.transactions:
status, receipt = state.validate_and_apply_with_status(tx)
if status != ValidationStatus.VALID:
logger.warning("Block %s rejected: Transaction failed validation", block.index)
return status, difficulty, avg_block_time
return status, target, avg_block_time
receipts.append(receipt)

total_fees = sum(getattr(r, 'gas_used', 0) * getattr(tx, 'fee_per_gas', 0) for r, tx in zip(receipts, block.transactions))
Expand All @@ -183,19 +199,19 @@ def _apply_block(self, prev_block, block, state, difficulty, avg_block_time):
computed_receipt_root = calculate_receipt_root(receipts)
if block.receipt_root != computed_receipt_root:
logger.warning("Block %s rejected: Invalid receipt root. Expected %s, got %s", block.index, computed_receipt_root, block.receipt_root)
return ValidationStatus.INVALID, difficulty, avg_block_time
return ValidationStatus.INVALID, target, avg_block_time

if [r.to_dict() for r in block.receipts] != [r.to_dict() for r in receipts]:
logger.warning("Block %s rejected: Receipts payload mismatch", block.index)
return ValidationStatus.INVALID, difficulty, avg_block_time
return ValidationStatus.INVALID, target, avg_block_time

computed_state_root = state.state_root()
if block.state_root != computed_state_root:
logger.warning("Block %s rejected: Invalid state root. Expected %s, got %s", block.index, computed_state_root, block.state_root)
return ValidationStatus.INVALID, difficulty, avg_block_time
return ValidationStatus.INVALID, target, avg_block_time

new_avg = self.alpha * (block.timestamp - prev_block.timestamp) + (1 - self.alpha) * avg_block_time
return ValidationStatus.VALID, self._next_difficulty(difficulty, new_avg), new_avg
return ValidationStatus.VALID, self._next_target(target, new_avg), new_avg

def add_block(self, block):
"""
Expand All @@ -207,17 +223,21 @@ def add_block(self, block):
with self._lock:
temp_state = self.state.copy()
temp_state.chain_id = self.chain_id
status, new_difficulty, new_avg = self._apply_block(
self.last_block, block, temp_state, self.current_difficulty, self.avg_block_time
status, new_target, new_avg = self._apply_block(
self.last_block, block, temp_state, self.current_target, self.avg_block_time
)
if status != ValidationStatus.VALID:
return status

# All transactions valid → commit state and append block
if hasattr(temp_state.accounts, 'commit'):
temp_state.accounts.commit()
temp_state.accounts = temp_state.accounts.backing
self.state = temp_state
self.current_difficulty = new_difficulty
self.current_target = new_target
self.avg_block_time = new_avg
self.chain.append(block)

return ValidationStatus.VALID

def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:
Expand Down Expand Up @@ -249,6 +269,17 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:

proposed_chain = self.chain[:fork_idx] + new_chain_list

# Fast PoW Check: Ensure incoming blocks actually satisfy their declared target
# before we trust their target to calculate total_work and rebuild state.
from .network_config import MAX_TARGET
for b in new_chain_list:
if not isinstance(b.target, int) or b.target <= 0 or b.target > MAX_TARGET:
logger.warning("Reorg failed: Fast PoW check failed for block %s (invalid target)", b.index)
return False, []
if int(b.hash, 16) >= b.target:
logger.warning("Reorg failed: Fast PoW check failed for block %s (hash >= target)", b.index)
return False, []
Comment on lines +275 to +281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject malformed hashes without raising.

int(b.hash, 16) raises for None or malformed values. Block.from_dict in minichain/block.py Lines 157-162 permits a missing hash, so a peer can make conflict resolution fail before _apply_block returns an invalid status. Validate that b.hash is a string and catch conversion errors before this comparison.

Proposed fix
             for b in new_chain_list:
                 if not isinstance(b.target, int) or b.target <= 0 or b.target > MAX_TARGET:
                     logger.warning("Reorg failed: Fast PoW check failed for block %s (invalid target)", b.index)
                     return False, []
-                if int(b.hash, 16) >= b.target:
+                try:
+                    hash_value = int(b.hash, 16)
+                except (TypeError, ValueError):
+                    logger.warning("Reorg failed: Fast PoW check failed for block %s (invalid hash)", b.index)
+                    return False, []
+                if hash_value >= b.target:
                     logger.warning("Reorg failed: Fast PoW check failed for block %s (hash >= target)", b.index)
                     return False, []
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for b in new_chain_list:
if not isinstance(b.target, int) or b.target <= 0 or b.target > MAX_TARGET:
logger.warning("Reorg failed: Fast PoW check failed for block %s (invalid target)", b.index)
return False, []
if int(b.hash, 16) >= b.target:
logger.warning("Reorg failed: Fast PoW check failed for block %s (hash >= target)", b.index)
return False, []
for b in new_chain_list:
if not isinstance(b.target, int) or b.target <= 0 or b.target > MAX_TARGET:
logger.warning("Reorg failed: Fast PoW check failed for block %s (invalid target)", b.index)
return False, []
try:
hash_value = int(b.hash, 16)
except (TypeError, ValueError):
logger.warning("Reorg failed: Fast PoW check failed for block %s (invalid hash)", b.index)
return False, []
if hash_value >= b.target:
logger.warning("Reorg failed: Fast PoW check failed for block %s (hash >= target)", b.index)
return False, []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@minichain/chain.py` around lines 272 - 278, Update the fast PoW validation
loop in the reorg handling code to first require each b.hash to be a string,
then safely convert it from hexadecimal while handling conversion errors. For
missing or malformed hashes, log the existing invalid-target/hash validation
failure and return (False, []) instead of allowing int(b.hash, 16) to raise.


current_work = self.get_total_work()
new_work = self.get_total_work(proposed_chain)

Expand All @@ -264,13 +295,16 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:
temp_state.chain_id = self.chain_id
temp_state.restore(self._genesis_state_snapshot)

temp_difficulty = proposed_chain[0].difficulty
temp_target = proposed_chain[0].target
temp_avg_block_time = self.target_block_time

for i in range(1, len(proposed_chain)):
status, temp_difficulty, temp_avg_block_time = self._apply_block(
proposed_chain[i - 1], proposed_chain[i], temp_state, temp_difficulty, temp_avg_block_time
status, temp_target, temp_avg_block_time = self._apply_block(
proposed_chain[i - 1], proposed_chain[i], temp_state, temp_target, temp_avg_block_time
)
if hasattr(temp_state.accounts, 'commit'):
temp_state.accounts.commit()
temp_state.accounts = temp_state.accounts.backing
if status != ValidationStatus.VALID:
logger.warning("Reorg failed at block %s", proposed_chain[i].index)
return False, []
Expand All @@ -281,7 +315,8 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:

self.chain = proposed_chain
self.state = temp_state
self.current_difficulty = temp_difficulty
self.current_target = temp_target
self.avg_block_time = temp_avg_block_time

logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index)
return True, orphans
3 changes: 3 additions & 0 deletions minichain/network_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@
MAX_FUTURE_BLOCK_TIME_MS = 15000 # Max allowed ms in the future for a block timestamp
GAS_PER_BYTE = 10 # Cost per byte of state storage written
MAX_CALL_DEPTH = 10 # Maximum depth for cross-contract calls
MAX_TARGET = int("F" * 64, 16)
MIN_TARGET = 1

9 changes: 9 additions & 0 deletions minichain/node_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@

# Mining Config
MINING_MAX_NONCE = 10_000_000 # Number of hashes to attempt before yielding the mining thread

# Initial nonce range parameters (A and B).
# Miners can configure these to create unique search ranges and avoid overlapping work.
MINING_INITIAL_NONCE_MIN = 0

# Recommended upper limit: 2**32 - 1 (4,294,967,295).
# Keeping the upper limit around 32-bits ensures the nonce string in the JSON block
# doesn't become unnecessarily large, and avoids cross-language serialization issues.
MINING_INITIAL_NONCE_MAX = 2**32 - 1
Loading
Loading