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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion minichain/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def from_dict(cls, payload: dict):

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

# Recalculate and verify the Merkle root!
Expand Down
57 changes: 41 additions & 16 deletions minichain/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
logger = logging.getLogger(__name__)


def validate_block_link_and_hash(previous_block, block):
class InvalidProofOfWorkError(ValueError):
pass


def validate_difficulty(difficulty, max_difficulty):

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.

Do we really need this validation function? Don't we know that we will always call this function with arguments that satisfy the conditions in line 18?

Also, some conditions in line 18 seem redundant anyway. For example, if difficulty is greater than len(block.hash), line 39 will fail anyway.

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.

Unless I'm overlooking something important, let's remove this validation function.

if type(difficulty) is not int or difficulty < 1 or difficulty > max_difficulty:
raise ValueError(f"invalid difficulty {difficulty}")

def validate_block(previous_block, block, expected_difficulty):
if block.previous_hash != previous_block.hash:
raise ValueError(
f"invalid previous hash {block.previous_hash} != {previous_block.hash}"
Expand All @@ -26,17 +34,26 @@ 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}")
if block.difficulty != expected_difficulty:
raise ValueError(
f"invalid difficulty {block.difficulty} != expected {expected_difficulty}"
)

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.

In addition to the check in line 39, we must also check that block.difficulty is equal to the expected difficulty based on previous 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.

Otherwise, the miner can circumvent the difficulty check, by simply writing a small difficulty in block.difficulty.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@Zahnentferner you're right. When I reviewed the attack was blocked by the separate difficulty checks in add_block and resolve_conflicts in the initial commit, but the architecture was flawed which I didn't notice since the checks already protected.
The difficulty check should have been moved inside the validator at the same time as the PoW check. That would have been the proper, complete fix. I focused too narrowly on, if the PoW check now exists or not.
The validation function and related issues came in later commits.

if not block.hash.startswith("0" * expected_difficulty):
raise InvalidProofOfWorkError(
f"invalid PoW: hash {block.hash} does not satisfy difficulty {expected_difficulty}"
)

if block.timestamp <= previous_block.timestamp:
raise ValueError(f"invalid timestamp: {block.timestamp} is not strictly greater than previous block timestamp {previous_block.timestamp}")
raise ValueError(
f"invalid timestamp: {block.timestamp} is not strictly greater than previous block timestamp {previous_block.timestamp}"
)

max_allowed_time = int(time.time() * 1000) + 15000
if block.timestamp > max_allowed_time:
raise ValueError(f"invalid timestamp: {block.timestamp} is too far in the future (max allowed: {max_allowed_time})")

raise ValueError(
f"invalid timestamp: {block.timestamp} is too far in the future (max allowed: {max_allowed_time})"
)

class Blockchain:
"""
Expand Down Expand Up @@ -130,7 +147,13 @@ def get_total_work(self, chain_list=None):
if chain_list is None:
with self._lock:
chain_list = self.chain
return sum(2 ** (block.difficulty or 1) for block in chain_list)

for block in chain_list:
if not isinstance(block.hash, str):
raise ValueError(f"invalid or missing hash on block {block.index}")
validate_difficulty(block.difficulty, len(block.hash))

return sum(2 ** block.difficulty for block in chain_list)
Comment on lines +151 to +156

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 before measuring total work.

Block.from_dict() permits a missing hash, so an untrusted candidate can reach len(None). That raises TypeError, which resolve_conflicts() does not catch. Using the supplied hash length as the difficulty bound also accepts non-SHA256 lengths.

Proposed fix
     for block in chain_list:
-        validate_difficulty(block.difficulty, len(block.hash))
+        if not isinstance(block.hash, str) or len(block.hash) != 64:
+            raise ValueError("invalid block hash")
+        validate_difficulty(block.difficulty, 64)
📝 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 block in chain_list:
validate_difficulty(block.difficulty, len(block.hash))
return sum(2 ** block.difficulty for block in chain_list)
for block in chain_list:
if not isinstance(block.hash, str) or len(block.hash) != 64:
raise ValueError("invalid block hash")
validate_difficulty(block.difficulty, 64)
return sum(2 ** block.difficulty for block in chain_list)
🤖 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 151 - 154, Update the chain validation loop
before the total-work calculation to validate each block’s hash as a required
SHA256-length value, rejecting missing or malformed hashes before calling
validate_difficulty. Avoid using len(block.hash) as the difficulty bound; use
the established SHA256 hash-length constraint, while preserving the existing
work summation for valid chains.


def _next_difficulty(self, difficulty, avg_block_time):
"""Advance the EMA difficulty control after a block, returning the new value."""
Expand All @@ -150,16 +173,15 @@ def _apply_block(self, prev_block, block, state, difficulty, avg_block_time):
from .validators import ValidationStatus

try:
validate_block_link_and_hash(prev_block, block)
validate_block(prev_block, block, difficulty)
except InvalidProofOfWorkError as exc:
logger.warning("Block %s rejected: %s", block.index, exc)
return ValidationStatus.INVALID, 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

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

receipts = []
for tx in block.transactions:
status, receipt = state.validate_and_apply_with_status(tx)
Expand Down Expand Up @@ -224,9 +246,12 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:
return False, []

with self._lock:
current_work = self.get_total_work()
new_work = self.get_total_work(new_chain_list)

try:
current_work = self.get_total_work()
new_work = self.get_total_work(new_chain_list)
except ValueError as exc:
logger.warning("Reorg rejected: %s", exc)
return False, []
if new_work <= current_work:
logger.debug("Incoming chain (work: %s) is not heavier than local chain (work: %s). Rejecting.", new_work, current_work)
return False, []
Expand Down
79 changes: 46 additions & 33 deletions minichain/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from typing import Any

from .block import Block
from .chain import Blockchain, validate_block_link_and_hash
from .chain import Blockchain

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -64,7 +64,13 @@ def save(blockchain: Blockchain, path: str = ".") -> None:


def load(path: str = ".") -> Blockchain:
"""Restore a Blockchain from SQLite inside *path* (with legacy JSON fallback)."""
"""Restore a Blockchain from SQLite inside *path* (with legacy JSON fallback).

Blocks are replayed through the canonical ``Blockchain._apply_block()``
pipeline so that difficulty adjustment, PoW, receipt validation, state-root
validation and transaction validation are all performed by the same code
path used at runtime. No parallel validation logic is maintained here.
"""
db_path = os.path.join(path, _DB_FILE)
legacy_path = os.path.join(path, _LEGACY_DATA_FILE)

Expand All @@ -90,6 +96,9 @@ def load(path: str = ".") -> Blockchain:
raise ValueError(f"Invalid or empty chain data in '{path}'")
if not isinstance(raw_accounts, dict):
raise ValueError(f"Invalid accounts data in '{path}'")
for address, account in raw_accounts.items():
if not isinstance(address, str) or not isinstance(account, dict):
raise ValueError(f"Invalid accounts data in '{path}'")

blocks = []
for raw_block in raw_blocks:
Expand All @@ -100,17 +109,44 @@ def load(path: str = ".") -> Blockchain:
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"Invalid chain data in '{path}'") from exc

normalized_accounts = {}
for address, account in raw_accounts.items():
if not isinstance(address, str) or not isinstance(account, dict):
raise ValueError(f"Invalid accounts data in '{path}'")
normalized_accounts[address] = account
blockchain = Blockchain()

from .pow import calculate_hash
genesis = blocks[0]
if genesis.index != 0:
raise ValueError("Invalid genesis block")
if genesis.hash != calculate_hash(genesis.to_header_dict()):
raise ValueError("Invalid genesis block hash")
if genesis.hash != blockchain.chain[0].hash:
raise ValueError(
f"Persisted genesis hash {genesis.hash!r} does not match "
f"local genesis {blockchain.chain[0].hash!r}"
)

_verify_chain_integrity(blocks)
from .validators import ValidationStatus
from .state import State

temp_state = State()
temp_state.chain_id = blockchain.chain_id
temp_state.restore(blockchain._genesis_state_snapshot)

temp_difficulty = blocks[0].difficulty
temp_avg_block_time = blockchain.target_block_time

for i in range(1, len(blocks)):
status, temp_difficulty, temp_avg_block_time = blockchain._apply_block(
blocks[i - 1], blocks[i], temp_state, temp_difficulty, temp_avg_block_time
)
if status != ValidationStatus.VALID:
raise ValueError(
f"Block #{blocks[i].index} failed validation during load "
f"(status={status.name})"
)

blockchain = Blockchain()
blockchain.chain = blocks
blockchain.state.accounts = normalized_accounts
blockchain.state = temp_state
blockchain.current_difficulty = temp_difficulty
blockchain.avg_block_time = temp_avg_block_time

logger.info(
"Loaded %d blocks and %d accounts from '%s'",
Expand All @@ -121,29 +157,6 @@ def load(path: str = ".") -> Blockchain:
return blockchain


# ---------------------------------------------------------------------------
# Integrity verification
# ---------------------------------------------------------------------------


def _verify_chain_integrity(blocks: list[Block]) -> None:
"""Verify genesis, hash linkage, and block hashes."""
genesis = blocks[0]
if genesis.index != 0:
raise ValueError("Invalid genesis block")
from .pow import calculate_hash
if genesis.hash != calculate_hash(genesis.to_header_dict()):
raise ValueError("Invalid genesis block hash")

for i in range(1, len(blocks)):
block = blocks[i]
prev = blocks[i - 1]
try:
validate_block_link_and_hash(prev, block)
except ValueError as exc:
raise ValueError(f"Block #{block.index}: {exc}") from exc


# ---------------------------------------------------------------------------
# SQLite helpers
# ---------------------------------------------------------------------------
Expand Down
Loading