Skip to content
1 change: 1 addition & 0 deletions minichain/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def from_dict(cls, payload: dict):
transactions=transactions,
timestamp=payload.get("timestamp"),
difficulty=payload.get("difficulty"),
mining_time=payload.get("mining_time"),
)
block.nonce = payload.get("nonce", 0)
block.hash = payload.get("hash")
Expand Down
57 changes: 56 additions & 1 deletion minichain/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .pow import calculate_hash
import logging
import threading

from minichain.pid import PIDDifficultyAdjuster
logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -32,6 +32,12 @@ def __init__(self):
self.chain = []
self.state = State()
self._lock = threading.RLock()
self.difficulty_adjuster = PIDDifficultyAdjuster(target_block_time=10)
self.current_difficulty = 1000 # Initial difficulty

self.difficulty_adjuster = PIDDifficultyAdjuster(target_block_time=10)
self.current_difficulty = 1000 # Initial difficulty

self._create_genesis_block()

def _create_genesis_block(self):
Expand All @@ -44,6 +50,7 @@ def _create_genesis_block(self):
transactions=[]
)
genesis_block.hash = "0" * 64
genesis_block.difficulty = self.current_difficulty
self.chain.append(genesis_block)

@property
Expand All @@ -66,6 +73,28 @@ def add_block(self, block):
except ValueError as exc:
logger.warning("Block %s rejected: %s", block.index, exc)
return False

# Verify block meets difficulty target BEFORE mutating PID state
# Use same formula as pow.py: target = "0" * difficulty
expected_difficulty = block.difficulty if block.difficulty else self.current_difficulty
target_prefix = '0' * expected_difficulty

if not block.hash or not block.hash.startswith(target_prefix):
logger.warning(
"Block %s rejected: PoW check failed (difficulty: %d)",
block.index,
expected_difficulty
)
return False

# Only adjust PID state AFTER block passes PoW validation
if hasattr(block, 'mining_time') and block.mining_time:
block.difficulty = self.difficulty_adjuster.adjust(
self.current_difficulty,
block.mining_time
)
else:
block.difficulty = self.current_difficulty

# Validate transactions on a temporary state copy
temp_state = self.state.copy()
Expand All @@ -77,6 +106,32 @@ def add_block(self, block):
if not result:
logger.warning("Block %s rejected: Transaction failed validation", block.index)
return False
for tx in block.transactions:
result = temp_state.validate_and_apply(tx)

# Reject block if any transaction fails
if not result:
logger.warning("Block %s rejected: Transaction failed validation", block.index)
return False

# All transactions valid → commit state and append block
self.state = temp_state
self.chain.append(block)

# Adjust difficulty for next block (single adjustment per block)
old_difficulty = self.current_difficulty
self.current_difficulty = self.difficulty_adjuster.adjust(
self.current_difficulty,
block.mining_time if hasattr(block, 'mining_time') else None
)

logger.info(
"Block %s accepted. Difficulty: %d → %d",
block.index,
old_difficulty,
self.current_difficulty
)
return True

# All transactions valid → commit state and append block
self.state = temp_state
Expand Down
155 changes: 155 additions & 0 deletions minichain/pid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""
PID-based Difficulty Adjuster for MiniChain

Uses fixed-point integer arithmetic for deterministic behavior across all nodes.

Key Fix: Uses integer division (difficulty // 10) instead of float (difficulty * 0.1)
This prevents chain forks from CPU rounding differences.
"""

import time
from typing import Optional


class PIDDifficultyAdjuster:
"""
Adjusts blockchain difficulty using a PID controller to maintain target block time.

Uses fixed-point integer scaling (SCALE=1000) for deterministic behavior.
Ensures all nodes compute identical results regardless of CPU/platform.
"""

SCALE = 1000 # Fixed-point scaling factor

def __init__(
self,
target_block_time: float = 5.0,
kp: int = 500,
ki: int = 50,
kd: int = 100
):
"""
Initialize the PID difficulty adjuster.

Args:
target_block_time: Target time for block generation in seconds
kp: Proportional coefficient (pre-scaled by SCALE). Default 500 = 0.5
ki: Integral coefficient (pre-scaled by SCALE). Default 50 = 0.05
kd: Derivative coefficient (pre-scaled by SCALE). Default 100 = 0.1
"""
self.target_block_time = target_block_time
self.kp = kp # Proportional
self.ki = ki # Integral
self.kd = kd # Derivative
self.integral = 0
self.previous_error = 0
self.last_block_time = time.monotonic()
self.integral_limit = 100 * self.SCALE

def adjust(
self,
current_difficulty: Optional[int] = None,
actual_block_time: Optional[float] = None
) -> int:
"""
Calculate new difficulty based on actual block time.

Args:
current_difficulty: Current difficulty (default: 1000)
actual_block_time: Time to mine block in seconds
If None, calculated from time since last call

Returns:
New difficulty value (minimum 1)

Example:
adjuster = PIDDifficultyAdjuster(target_block_time=10)
new_difficulty = adjuster.adjust(current_difficulty=10000, actual_block_time=12.5)
"""

# Handle None difficulty
if current_difficulty is None:
current_difficulty = 1000

# Calculate actual_block_time if not provided
if actual_block_time is None:
now = time.monotonic()
actual_block_time = now - self.last_block_time
self.last_block_time = now

# ===== Fixed-Point Integer Arithmetic =====
# Convert times to scaled integers for precise calculation
actual_block_time_scaled = int(actual_block_time * self.SCALE)
target_time_scaled = int(self.target_block_time * self.SCALE)

# Calculate error: positive = too fast, negative = too slow
error = target_time_scaled - actual_block_time_scaled

# ===== Proportional Term =====
p_term = self.kp * error

# ===== Integral Term with Anti-Windup =====
self.integral += error
self.integral = max(
min(self.integral, self.integral_limit),
-self.integral_limit
)
i_term = self.ki * self.integral

# ===== Derivative Term =====
derivative = error - self.previous_error
self.previous_error = error
d_term = self.kd * derivative

# ===== PID Calculation =====
# Combine all terms and scale back to normal units
adjustment = (p_term + i_term + d_term) // self.SCALE

# ===== Safety Constraint: Limit Change to 10% =====
# ✅ FIXED: Use integer division instead of float multiplier
# Was: max_delta = max(1, int(current_difficulty * 0.1))
# Now: max_delta = max(1, current_difficulty // 10)
max_delta = max(1, current_difficulty // 10)

# Clamp adjustment to safety bounds
clamped_adjustment = max(
min(adjustment, max_delta),
-max_delta
)

# Ensure we move at least ±1 if adjustment is non-zero
delta = clamped_adjustment

# Calculate and return new difficulty
new_difficulty = current_difficulty + delta
return max(1, new_difficulty)

def reset(self) -> None:
"""Reset PID state (integral and derivative history)."""
self.integral = 0
self.previous_error = 0
self.last_block_time = time.monotonic()

def get_state(self) -> dict:
"""
Get current PID state for debugging or persistence.

Returns:
Dictionary containing integral, previous_error, and last update time
"""
return {
"integral": self.integral,
"previous_error": self.previous_error,
"last_block_time": self.last_block_time
}

def set_state(self, state: dict) -> None:
"""
Restore PID state from a dictionary (for recovery/persistence).

Args:
state: Dictionary with keys 'integral', 'previous_error', 'last_block_time'
"""
self.integral = state.get("integral", 0)
self.previous_error = state.get("previous_error", 0)
self.last_block_time = state.get("last_block_time", time.monotonic())
3 changes: 2 additions & 1 deletion minichain/pow.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ def mine_block(
if block_hash.startswith(target):
block.nonce = local_nonce # Assign only on success
block.hash = block_hash
block.mining_time = time.monotonic() - start_time
if logger:
logger.info("Success! Hash: %s", block_hash)
logger.info("Success! Hash: %s, Mining time: %.2fs", block_hash, block.mining_time)
return block

# Allow cancellation via progress callback (pass nonce explicitly)
Expand Down
Loading
Loading