From 99da332abb1349c5d4b1b3d7cc9a7623313299f3 Mon Sep 17 00:00:00 2001 From: siddhant Date: Wed, 29 Jul 2026 09:13:46 +0530 Subject: [PATCH 1/5] refactor: change PoW difficulty to numeric target hash threshold --- genesis.json | 2 +- main.py | 2 +- minichain/block.py | 20 +++--- minichain/chain.py | 66 +++++++++++--------- minichain/network_config.py | 3 + minichain/pow.py | 17 +++-- tests/test_core.py | 2 +- tests/test_persistence.py | 4 +- tests/test_persistence_runtime.py | 4 +- tests/test_protocol_hardening.py | 6 +- tests/test_reorg.py | 2 +- tests/test_serialization.py | 6 +- tests/{test_difficulty.py => test_target.py} | 45 ++++++------- 13 files changed, 92 insertions(+), 87 deletions(-) rename tests/{test_difficulty.py => test_target.py} (55%) diff --git a/genesis.json b/genesis.json index ed1f2b5..e4d8e5d 100644 --- a/genesis.json +++ b/genesis.json @@ -1,7 +1,7 @@ { "chain_id": "minichain-default", "timestamp": 1716880000000, - "difficulty": 4, + "target": "0x0000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF", "target_block_time": 10000, "alpha": 0.1, "initial_supply": 1500000000, diff --git a/main.py b/main.py index 079f6cd..b37fce4 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/minichain/block.py b/minichain/block.py index c81dbe3..7b38117 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -41,7 +41,7 @@ def __init__( previous_hash: str, transactions: Optional[Sequence[Transaction]] = None, timestamp: Optional[float] = None, - difficulty: Optional[int] = None, + target: Optional[int] = None, state_root: Optional[str] = None, receipt_root: Optional[str] = None, receipts: Optional[Sequence[Receipt]] = None, @@ -59,7 +59,7 @@ def __init__( if timestamp is None else int(timestamp) ) - self.difficulty: Optional[int] = difficulty + self.target: Optional[int] = target self.nonce: int = 0 self.hash: Optional[str] = None self.state_root: Optional[str] = state_root @@ -83,7 +83,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) if self.target is not None else None, "nonce": self.nonce, } # Include miner in header only when present (optional field) @@ -130,14 +130,12 @@ 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: + parsed_target = int(raw_target, 16) if isinstance(raw_target, str) else int(raw_target) else: - parsed_diff = None + parsed_target = None raw_ts = payload.get("timestamp") parsed_ts = int(raw_ts) if raw_ts is not None else None @@ -146,7 +144,7 @@ def from_dict(cls, payload: dict): 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, diff --git a/minichain/chain.py b/minichain/chain.py index cf46ce2..d7ad6ab 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -27,9 +27,8 @@ 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.target is None or 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}") @@ -88,11 +87,15 @@ 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 not None: + self.current_target = int(raw_target, 16) if isinstance(raw_target, str) else int(raw_target) + else: + from .network_config import MAX_TARGET + self.current_target = MAX_TARGET 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( @@ -100,7 +103,7 @@ def _create_genesis_block(self, genesis_path): previous_hash="0", transactions=[], timestamp=timestamp, - difficulty=difficulty, + target=self.current_target, state_root=self.state.state_root(), receipt_root=None, receipts=[] @@ -133,27 +136,28 @@ 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) + return sum((1 << 256) // (block.target 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.""" + 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 if avg_block_time > self.target_block_time: - return max(1, difficulty - 1) + return min(MAX_TARGET, target + 1) if avg_block_time < self.target_block_time: - return difficulty + 1 - return difficulty + return max(MIN_TARGET, target - 1) + return target - 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) """ from .validators import ValidationStatus @@ -162,18 +166,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)) @@ -183,19 +187,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): """ @@ -207,15 +211,15 @@ 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 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 @@ -264,12 +268,12 @@ 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 status != ValidationStatus.VALID: logger.warning("Reorg failed at block %s", proposed_chain[i].index) @@ -281,7 +285,7 @@ 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 diff --git a/minichain/network_config.py b/minichain/network_config.py index 3786459..5fc5371 100644 --- a/minichain/network_config.py +++ b/minichain/network_config.py @@ -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 + diff --git a/minichain/pow.py b/minichain/pow.py index 6b3846b..18a481a 100644 --- a/minichain/pow.py +++ b/minichain/pow.py @@ -14,7 +14,7 @@ def calculate_hash(block_dict): def mine_block( block, - difficulty=None, + target=None, max_nonce=None, timeout_seconds=None, logger=None, @@ -23,20 +23,19 @@ def mine_block( """Mines a block using Proof-of-Work without mutating input block until success.""" max_nonce = max_nonce if max_nonce is not None else MINING_MAX_NONCE - difficulty = difficulty if difficulty is not None else block.difficulty - if not isinstance(difficulty, int) or difficulty <= 0: - raise ValueError("Difficulty must be a positive integer.") + target = target if target is not None else block.target + if not isinstance(target, int) or target <= 0: + raise ValueError("Target must be a positive integer.") - target = "0" * difficulty local_nonce = 0 header_dict = block.to_header_dict() # Construct header dict once outside loop start_time = time.monotonic() if logger: logger.info( - "Mining block %s (Difficulty: %s)", + "Mining block %s (Target: %s)", block.index, - difficulty, + target, ) while True: @@ -56,8 +55,8 @@ def mine_block( header_dict["nonce"] = local_nonce block_hash = calculate_hash(header_dict) - # Check difficulty target - if block_hash.startswith(target): + # Check target + if int(block_hash, 16) < target: block.nonce = local_nonce # Assign only on success block.hash = block_hash if logger: diff --git a/tests/test_core.py b/tests/test_core.py index bbf079f..d3fc357 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -86,7 +86,7 @@ def test_transaction_fee(self): index=1, previous_hash="0", transactions=[tx], - difficulty=1, + target=int("F"*64, 16), state_root=self.state.state_root(), receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 989af77..47862dc 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -50,7 +50,7 @@ def _chain_with_tx(self): index=1, previous_hash=bc.last_block.hash, transactions=[tx], - difficulty=bc.current_difficulty, + target=bc.current_target, state_root=temp_state.state_root(), receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], @@ -245,7 +245,7 @@ def test_loaded_chain_can_add_new_block(self): index=len(restored.chain), previous_hash=restored.last_block.hash, transactions=[tx2], - difficulty=restored.current_difficulty, + target=restored.current_target, state_root=temp_state.state_root(), receipt_root=calculate_receipt_root([receipt2]), receipts=[receipt2], diff --git a/tests/test_persistence_runtime.py b/tests/test_persistence_runtime.py index 73265e5..ba172ee 100644 --- a/tests/test_persistence_runtime.py +++ b/tests/test_persistence_runtime.py @@ -71,12 +71,12 @@ def _chain_with_tx(self): index=1, previous_hash=bc.last_block.hash, transactions=[tx], - difficulty=1, + target=int("F"*64, 16), state_root=temp_state.state_root(), receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], ) - mine_block(block, difficulty=1) + mine_block(block, target=int("F"*64, 16)) bc.add_block(block) return bc diff --git a/tests/test_protocol_hardening.py b/tests/test_protocol_hardening.py index 5ab706c..b5f3a04 100644 --- a/tests/test_protocol_hardening.py +++ b/tests/test_protocol_hardening.py @@ -17,7 +17,7 @@ def test_canonical_json_is_order_independent(self): def test_block_hash_matches_compute_hash(self): block = Block(index=1, previous_hash="abc", transactions=[], timestamp=1234567890) - block.difficulty = 2 + block.target = 2 block.nonce = 7 self.assertEqual(block.compute_hash(), calculate_hash(block.to_header_dict())) @@ -117,7 +117,7 @@ async def test_block_schema_accepts_current_block_wire_format(self): previous_hash="0" * 64, transactions=[tx], timestamp=1600000000000, - difficulty=2, + target=int("F"*64, 16), state_root="0"*64, receipts=[receipt], receipt_root=calculate_receipt_root([receipt]) @@ -150,7 +150,7 @@ async def test_duplicate_tx_and_block_detection(self): "previous_hash": "0" * 64, "transactions": [tx_message["data"]], "timestamp": 123, - "difficulty": 2, + "target": int("F"*64, 16), "nonce": 1, "hash": "f" * 64, }, diff --git a/tests/test_reorg.py b/tests/test_reorg.py index e5637fc..12d558c 100644 --- a/tests/test_reorg.py +++ b/tests/test_reorg.py @@ -21,7 +21,7 @@ def genesis_file(tmp_path): pk = sk.verify_key.encode(encoder=HexEncoder).decode() data = { "timestamp": int(time.time()), - "difficulty": 1, + "target": int("F"*64, 16), "alloc": { pk: {"balance": 1000} } diff --git a/tests/test_serialization.py b/tests/test_serialization.py index aa5f2b7..2172c0c 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -38,8 +38,8 @@ def test_block_serialization_determinism(): tx2 = Transaction(**tx_params) # Add the miner field - block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], difficulty=2, timestamp=999999, miner="a" * 40) - block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=2, timestamp=999999, miner="a" * 40) + block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], target=int("F"*64, 16), timestamp=999999, miner="a" * 40) + block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], target=int("F"*64, 16), timestamp=999999, miner="a" * 40) # Pre-compute the hashes before asserting block1.hash = block1.compute_hash() @@ -55,7 +55,7 @@ def test_block_from_dict_rejects_tampered_payload(): tx = Transaction(sender="A", receiver="B", amount=10, nonce=5, timestamp=1000) block = Block( index=1, previous_hash="0"*64, transactions=[tx], - difficulty=2, timestamp=999999, miner="a"*40 + target=int("F"*64, 16), timestamp=999999, miner="a"*40 ) block.hash = block.compute_hash() diff --git a/tests/test_difficulty.py b/tests/test_target.py similarity index 55% rename from tests/test_difficulty.py rename to tests/test_target.py index d15c853..57966d8 100644 --- a/tests/test_difficulty.py +++ b/tests/test_target.py @@ -2,63 +2,64 @@ from minichain import Blockchain, Block from minichain.pow import mine_block from minichain.validators import ValidationStatus +from minichain.network_config import MAX_TARGET -class TestEMADifficulty(unittest.TestCase): - def test_difficulty_adjustment(self): +class TestEMATarget(unittest.TestCase): + def test_target_adjustment(self): chain = Blockchain() chain.target_block_time = 1000 chain.alpha = 0.5 chain.avg_block_time = 1000 - chain.current_difficulty = 3 - chain.chain[0].difficulty = 3 + chain.current_target = MAX_TARGET - 10 + chain.chain[0].target = MAX_TARGET - 10 # Fast mining: timestamps only 1ms apart - # avg = 0.5 * 1 + 0.5 * 1000 = 500.5 (which is < 1000) => difficulty increments to 4 + # avg = 0.5 * 1 + 0.5 * 1000 = 500.5 (which is < 1000) => target decrements by 1 ts = chain.last_block.timestamp + 1 - block1 = Block(index=1, previous_hash=chain.last_block.hash, transactions=[], timestamp=ts, difficulty=chain.current_difficulty, state_root=chain.state.state_root()) + block1 = Block(index=1, previous_hash=chain.last_block.hash, transactions=[], timestamp=ts, target=chain.current_target, state_root=chain.state.state_root()) mined_block1 = mine_block(block1) self.assertEqual(chain.add_block(mined_block1), ValidationStatus.VALID) - self.assertEqual(chain.current_difficulty, 4) + self.assertEqual(chain.current_target, MAX_TARGET - 11) # Slow mining: timestamp 5000ms apart - # avg = 0.5 * 5000 + 0.5 * 500.5 = 2750.25 (which is > 1000) => difficulty decrements to 3 + # avg = 0.5 * 5000 + 0.5 * 500.5 = 2750.25 (which is > 1000) => target increments by 1 ts = chain.last_block.timestamp + 5000 - block2 = Block(index=2, previous_hash=chain.last_block.hash, transactions=[], timestamp=ts, difficulty=chain.current_difficulty, state_root=chain.state.state_root()) + block2 = Block(index=2, previous_hash=chain.last_block.hash, transactions=[], timestamp=ts, target=chain.current_target, state_root=chain.state.state_root()) mined_block2 = mine_block(block2) self.assertEqual(chain.add_block(mined_block2), ValidationStatus.VALID) - self.assertEqual(chain.current_difficulty, 3) + self.assertEqual(chain.current_target, MAX_TARGET - 10) - def test_reorg_difficulty_validation(self): + def test_reorg_target_validation(self): chain1 = Blockchain() chain1.target_block_time = 1000 chain1.alpha = 0.5 chain1.avg_block_time = 1000 - chain1.current_difficulty = 1 - chain1.chain[0].difficulty = 1 + chain1.current_target = MAX_TARGET - 10 + chain1.chain[0].target = MAX_TARGET - 10 chain2 = Blockchain() chain2.target_block_time = 1000 chain2.alpha = 0.5 chain2.avg_block_time = 1000 - chain2.current_difficulty = 1 - chain2.chain[0].difficulty = 1 + chain2.current_target = MAX_TARGET - 10 + chain2.chain[0].target = MAX_TARGET - 10 - # Chain 2 mines a fast block, difficulty goes to 2 - block1 = Block(1, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1, difficulty=chain2.current_difficulty, state_root=chain2.state.state_root()) + # Chain 2 mines a fast block, target goes to MAX_TARGET - 11 + block1 = Block(1, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1, target=chain2.current_target, state_root=chain2.state.state_root()) mine_block(block1) chain2.add_block(block1) - self.assertEqual(chain2.current_difficulty, 2) + self.assertEqual(chain2.current_target, MAX_TARGET - 11) # Reorg chain1 to chain2 success, orphans = chain1.resolve_conflicts(chain2.chain) self.assertTrue(success) - self.assertEqual(chain1.current_difficulty, 2) + self.assertEqual(chain1.current_target, MAX_TARGET - 11) - # Forging a chain with wrong difficulty should be rejected + # Forging a chain with wrong target should be rejected forged_chain = list(chain2.chain) - forged_block = Block(2, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1000, difficulty=1, state_root=chain2.state.state_root()) + forged_block = Block(2, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1000, target=MAX_TARGET - 10, state_root=chain2.state.state_root()) mine_block(forged_block) forged_chain.append(forged_block) success, _ = chain1.resolve_conflicts(forged_chain) - self.assertFalse(success) # Rejected because difficulty should have been 2! + self.assertFalse(success) # Rejected because target should have been MAX_TARGET - 11! From eb58853b647812a9dc0eb015df5f2a64dd03a22b Mon Sep 17 00:00:00 2001 From: siddhant Date: Thu, 30 Jul 2026 01:13:47 +0530 Subject: [PATCH 2/5] address copderabit copmments --- minichain/chain.py | 52 ++++++++++++++++-- minichain/pow.py | 1 + minichain/state.py | 90 +++++++++++++++++++++++++++---- tests/test_persistence_runtime.py | 4 +- tests/test_target.py | 5 +- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/minichain/chain.py b/minichain/chain.py index d7ad6ab..99437de 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -27,7 +27,10 @@ def validate_block_link_and_hash(previous_block, block): if block.hash != expected_hash: raise ValueError(f"invalid hash {block.hash}") - if block.target is None or int(block.hash, 16) >= block.target: + 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: @@ -48,6 +51,9 @@ def __init__(self, genesis_path="genesis.json"): self.state = State() self.chain_id = "minichain-default" self._lock = threading.RLock() + import collections + self._state_snapshots = collections.OrderedDict() + self._max_snapshots = 10 self._create_genesis_block(genesis_path) def _create_genesis_block(self, genesis_path): @@ -90,6 +96,10 @@ def _create_genesis_block(self, genesis_path): raw_target = config.get("target") if raw_target is not None: 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) else: from .network_config import MAX_TARGET self.current_target = MAX_TARGET @@ -124,6 +134,7 @@ def _create_genesis_block(self, genesis_path): # Snapshot the state exactly after genesis allocation for clean reorg rebuilds self._genesis_state_snapshot = self.state.snapshot() + self._state_snapshots[genesis_block.hash] = self.state.snapshot() @property def last_block(self): @@ -218,10 +229,18 @@ def add_block(self, block): 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_target = new_target self.avg_block_time = new_avg self.chain.append(block) + + self._state_snapshots[block.hash] = self.state.snapshot() + while len(self._state_snapshots) > self._max_snapshots: + self._state_snapshots.popitem(last=False) + return ValidationStatus.VALID def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: @@ -266,15 +285,34 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: temp_state = State() temp_state.chain_id = self.chain_id - temp_state.restore(self._genesis_state_snapshot) - + + fork_base_hash = self.chain[fork_idx - 1].hash if fork_idx > 0 else None + temp_target = proposed_chain[0].target temp_avg_block_time = self.target_block_time + + if fork_base_hash and fork_base_hash in self._state_snapshots: + logger.info("Reorg optimization: Restoring state from in-memory snapshot at block %s", fork_idx - 1) + temp_state.restore(self._state_snapshots[fork_base_hash]) + + # Fast forward target and avg_block_time without executing state + for i in range(1, fork_idx): + block_time = proposed_chain[i].timestamp - proposed_chain[i-1].timestamp + temp_avg_block_time = self.alpha * block_time + (1 - self.alpha) * temp_avg_block_time + temp_target = self._next_target(temp_target, temp_avg_block_time) + + start_idx = fork_idx + else: + temp_state.restore(self._genesis_state_snapshot) + start_idx = 1 - for i in range(1, len(proposed_chain)): + for i in range(start_idx, len(proposed_chain)): 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, [] @@ -287,5 +325,11 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: self.state = temp_state self.current_target = temp_target self.avg_block_time = temp_avg_block_time + + # Repopulate snapshots for the new chain tip + self._state_snapshots[self.last_block.hash] = self.state.snapshot() + while len(self._state_snapshots) > self._max_snapshots: + self._state_snapshots.popitem(last=False) + logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index) return True, orphans diff --git a/minichain/pow.py b/minichain/pow.py index 18a481a..1dbcf57 100644 --- a/minichain/pow.py +++ b/minichain/pow.py @@ -26,6 +26,7 @@ def mine_block( target = target if target is not None else block.target if not isinstance(target, int) or target <= 0: raise ValueError("Target must be a positive integer.") + block.target = target local_nonce = 0 header_dict = block.to_header_dict() # Construct header dict once outside loop diff --git a/minichain/state.py b/minichain/state.py index 0d80764..9cc0e2d 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -10,6 +10,66 @@ logger = logging.getLogger(__name__) +class StateJournal: + """ + An in-memory proxy dictionary that caches reads and writes to avoid + expensive deep copies of the entire state dictionary during transactions. + """ + def __init__(self, backing_dict): + self.backing = backing_dict + self.cache = {} + + def __getitem__(self, key): + if key not in self.cache: + if key in self.backing: + import copy + self.cache[key] = copy.deepcopy(self.backing[key]) + else: + raise KeyError(key) + return self.cache[key] + + def __setitem__(self, key, value): + self.cache[key] = value + + def __delitem__(self, key): + raise NotImplementedError("Account deletion not supported in StateJournal") + + def __contains__(self, key): + return key in self.cache or key in self.backing + + def get(self, key, default=None): + try: + return self.__getitem__(key) + except KeyError: + return default + + def items(self): + res = self.backing.copy() + res.update(self.cache) + return res.items() + + def update(self, other_dict): + if hasattr(other_dict, 'items'): + for k, v in other_dict.items(): + self[k] = v + else: + for k, v in other_dict: + self[k] = v + + def copy(self): + res = self.backing.copy() + res.update(self.cache) + return res + + def commit(self): + """Flushes cached modifications to the backing dictionary.""" + self.backing.update(self.cache) + self.cache.clear() + + def rollback(self): + """Discards modifications.""" + self.cache.clear() + class State: def __init__(self): @@ -69,9 +129,11 @@ def verify_transaction_logic(self, tx): def copy(self): """ Return an independent copy of state for transactional validation. + Uses StateJournal for O(1) cloning instead of deepcopy. """ - new_state = copy.deepcopy(self) - new_state.contract_machine = ContractMachine(new_state) # Reinitialize contract_machine + new_state = State() + new_state.accounts = StateJournal(self.accounts) + new_state.contract_machine = ContractMachine(new_state) new_state.chain_id = self.chain_id return new_state @@ -124,22 +186,22 @@ def apply_transaction(self, tx): def _apply_validated_tx(self, tx): + original_accounts = self.accounts + journal = StateJournal(original_accounts) + self.accounts = journal + sender = self.accounts[tx.sender] total_cost = tx.amount + (getattr(tx, 'gas_limit', 0) * getattr(tx, 'fee_per_gas', 0)) sender['balance'] -= total_cost sender['nonce'] += 1 - import copy - state_snapshot = copy.deepcopy(self.accounts) - def rollback_and_refund(error_message, gas_used): - self.accounts = copy.deepcopy(state_snapshot) + journal.rollback() + self.accounts = original_accounts refund_acc = self.accounts[tx.sender] - refund_acc['balance'] += tx.amount - gas_refund = getattr(tx, 'gas_limit', 0) - gas_used - if gas_refund > 0: - refund_acc['balance'] += (gas_refund * getattr(tx, 'fee_per_gas', 0)) + refund_acc['balance'] -= (gas_used * getattr(tx, 'fee_per_gas', 0)) + refund_acc['nonce'] += 1 return Receipt(tx.tx_id, status=0, error_message=error_message, gas_used=gas_used) # LOGIC BRANCH 1: Contract Deployment @@ -162,6 +224,9 @@ def rollback_and_refund(error_message, gas_used): gas_refund = gas_used - code_gas if gas_refund > 0: self.accounts[tx.sender]['balance'] += (gas_refund * getattr(tx, 'fee_per_gas', 0)) + + journal.commit() + self.accounts = original_accounts return Receipt(tx.tx_id, status=1, contract_address=contract_address, gas_used=code_gas) # LOGIC BRANCH 2: Contract Call @@ -187,12 +252,17 @@ def rollback_and_refund(error_message, gas_used): if gas_refund > 0: self.accounts[tx.sender]['balance'] += (gas_refund * getattr(tx, 'fee_per_gas', 0)) + journal.commit() + self.accounts = original_accounts return Receipt(tx.tx_id, status=1, gas_used=gas_used) # LOGIC BRANCH 3: Regular Transfer receiver = self.get_account(tx.receiver) receiver['balance'] += tx.amount gas_used = getattr(tx, 'gas_limit', 0) + + journal.commit() + self.accounts = original_accounts return Receipt(tx.tx_id, status=1, gas_used=gas_used) def execute_internal_call(self, sender, receiver_address, amount, payload, gas_limit, depth, is_top_level=False): diff --git a/tests/test_persistence_runtime.py b/tests/test_persistence_runtime.py index ba172ee..fa1dcca 100644 --- a/tests/test_persistence_runtime.py +++ b/tests/test_persistence_runtime.py @@ -71,12 +71,12 @@ def _chain_with_tx(self): index=1, previous_hash=bc.last_block.hash, transactions=[tx], - target=int("F"*64, 16), + target=bc.current_target, state_root=temp_state.state_root(), receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], ) - mine_block(block, target=int("F"*64, 16)) + mine_block(block, target=bc.current_target) bc.add_block(block) return bc diff --git a/tests/test_target.py b/tests/test_target.py index 57966d8..f58aff1 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -12,6 +12,7 @@ def test_target_adjustment(self): chain.avg_block_time = 1000 chain.current_target = MAX_TARGET - 10 chain.chain[0].target = MAX_TARGET - 10 + chain.chain[0].hash = chain.chain[0].compute_hash() # Fast mining: timestamps only 1ms apart # avg = 0.5 * 1 + 0.5 * 1000 = 500.5 (which is < 1000) => target decrements by 1 @@ -36,6 +37,7 @@ def test_reorg_target_validation(self): chain1.avg_block_time = 1000 chain1.current_target = MAX_TARGET - 10 chain1.chain[0].target = MAX_TARGET - 10 + chain1.chain[0].hash = chain1.chain[0].compute_hash() chain2 = Blockchain() chain2.target_block_time = 1000 @@ -43,6 +45,7 @@ def test_reorg_target_validation(self): chain2.avg_block_time = 1000 chain2.current_target = MAX_TARGET - 10 chain2.chain[0].target = MAX_TARGET - 10 + chain2.chain[0].hash = chain2.chain[0].compute_hash() # Chain 2 mines a fast block, target goes to MAX_TARGET - 11 block1 = Block(1, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1, target=chain2.current_target, state_root=chain2.state.state_root()) @@ -51,7 +54,7 @@ def test_reorg_target_validation(self): self.assertEqual(chain2.current_target, MAX_TARGET - 11) # Reorg chain1 to chain2 - success, orphans = chain1.resolve_conflicts(chain2.chain) + success, _ = chain1.resolve_conflicts(chain2.chain) self.assertTrue(success) self.assertEqual(chain1.current_target, MAX_TARGET - 11) From 0d900555840e1ee4873825402c8b71c2a43324c7 Mon Sep 17 00:00:00 2001 From: siddhant Date: Mon, 3 Aug 2026 02:17:45 +0530 Subject: [PATCH 3/5] Randomize initial mining nonce using configurable bounds to prevent overlap --- Roadmap.md | 9 --------- minichain/node_config.py | 9 +++++++++ minichain/pow.py | 12 +++++++++--- 3 files changed, 18 insertions(+), 12 deletions(-) delete mode 100644 Roadmap.md diff --git a/Roadmap.md b/Roadmap.md deleted file mode 100644 index e21285d..0000000 --- a/Roadmap.md +++ /dev/null @@ -1,9 +0,0 @@ -Tasks to be done in order from top to bottom, paralellizing execution when the risk of merge conflicts is low. - -- [ ] Merge PR #52 about difficulty adjustment ( @anshulchikhale ) -- [ ] Possibly merge new PR from @Anurag that will replace closed PR #54. ( @anurag + everyone may help identifying which features from #54 are worth resubmitting ) -- [X] Mempool O(N) to O(1) optimization ( @SK ) -- [ ] Clean up scattered transaction validation ( @SK ) -- [ ] Find out how the current prototype can be made more minimal/clean ( @Mpampeis + everyone ) -- [ ] Switch to SQL storage for blockchain state ( @Tukan003 ) -- [ ] Replace raw TCP by libp2p ( @Anurag ) diff --git a/minichain/node_config.py b/minichain/node_config.py index 68154a3..db4b07f 100644 --- a/minichain/node_config.py +++ b/minichain/node_config.py @@ -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 diff --git a/minichain/pow.py b/minichain/pow.py index 1dbcf57..d94192b 100644 --- a/minichain/pow.py +++ b/minichain/pow.py @@ -1,6 +1,11 @@ import time +import random from .serialization import canonical_json_hash -from .node_config import MINING_MAX_NONCE +from .node_config import ( + MINING_MAX_NONCE, + MINING_INITIAL_NONCE_MIN, + MINING_INITIAL_NONCE_MAX, +) class MiningExceededError(Exception): @@ -28,7 +33,8 @@ def mine_block( raise ValueError("Target must be a positive integer.") block.target = target - local_nonce = 0 + start_nonce = random.randint(MINING_INITIAL_NONCE_MIN, MINING_INITIAL_NONCE_MAX) + local_nonce = start_nonce header_dict = block.to_header_dict() # Construct header dict once outside loop start_time = time.monotonic() @@ -42,7 +48,7 @@ def mine_block( while True: # Enforce max_nonce limit before hashing - if local_nonce >= max_nonce: + if local_nonce - start_nonce >= max_nonce: if logger: logger.warning("Max nonce exceeded during mining.") raise MiningExceededError("Mining failed: max_nonce exceeded") From 9611a66cce5c69ba9547d500faf022119a2b7596 Mon Sep 17 00:00:00 2001 From: siddhant Date: Mon, 3 Aug 2026 23:54:36 +0530 Subject: [PATCH 4/5] Revert state rollback optimizations to separate PR --- minichain/chain.py | 35 ++---------------- minichain/state.py | 90 ++++++---------------------------------------- 2 files changed, 13 insertions(+), 112 deletions(-) diff --git a/minichain/chain.py b/minichain/chain.py index 99437de..e26418a 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -51,9 +51,6 @@ def __init__(self, genesis_path="genesis.json"): self.state = State() self.chain_id = "minichain-default" self._lock = threading.RLock() - import collections - self._state_snapshots = collections.OrderedDict() - self._max_snapshots = 10 self._create_genesis_block(genesis_path) def _create_genesis_block(self, genesis_path): @@ -134,7 +131,6 @@ def _create_genesis_block(self, genesis_path): # Snapshot the state exactly after genesis allocation for clean reorg rebuilds self._genesis_state_snapshot = self.state.snapshot() - self._state_snapshots[genesis_block.hash] = self.state.snapshot() @property def last_block(self): @@ -237,10 +233,6 @@ def add_block(self, block): self.avg_block_time = new_avg self.chain.append(block) - self._state_snapshots[block.hash] = self.state.snapshot() - while len(self._state_snapshots) > self._max_snapshots: - self._state_snapshots.popitem(last=False) - return ValidationStatus.VALID def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: @@ -285,28 +277,12 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: temp_state = State() temp_state.chain_id = self.chain_id - - fork_base_hash = self.chain[fork_idx - 1].hash if fork_idx > 0 else None - + temp_state.restore(self._genesis_state_snapshot) + temp_target = proposed_chain[0].target temp_avg_block_time = self.target_block_time - - if fork_base_hash and fork_base_hash in self._state_snapshots: - logger.info("Reorg optimization: Restoring state from in-memory snapshot at block %s", fork_idx - 1) - temp_state.restore(self._state_snapshots[fork_base_hash]) - - # Fast forward target and avg_block_time without executing state - for i in range(1, fork_idx): - block_time = proposed_chain[i].timestamp - proposed_chain[i-1].timestamp - temp_avg_block_time = self.alpha * block_time + (1 - self.alpha) * temp_avg_block_time - temp_target = self._next_target(temp_target, temp_avg_block_time) - - start_idx = fork_idx - else: - temp_state.restore(self._genesis_state_snapshot) - start_idx = 1 - for i in range(start_idx, len(proposed_chain)): + for i in range(1, len(proposed_chain)): 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 ) @@ -326,10 +302,5 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: self.current_target = temp_target self.avg_block_time = temp_avg_block_time - # Repopulate snapshots for the new chain tip - self._state_snapshots[self.last_block.hash] = self.state.snapshot() - while len(self._state_snapshots) > self._max_snapshots: - self._state_snapshots.popitem(last=False) - logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index) return True, orphans diff --git a/minichain/state.py b/minichain/state.py index 9cc0e2d..0d80764 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -10,66 +10,6 @@ logger = logging.getLogger(__name__) -class StateJournal: - """ - An in-memory proxy dictionary that caches reads and writes to avoid - expensive deep copies of the entire state dictionary during transactions. - """ - def __init__(self, backing_dict): - self.backing = backing_dict - self.cache = {} - - def __getitem__(self, key): - if key not in self.cache: - if key in self.backing: - import copy - self.cache[key] = copy.deepcopy(self.backing[key]) - else: - raise KeyError(key) - return self.cache[key] - - def __setitem__(self, key, value): - self.cache[key] = value - - def __delitem__(self, key): - raise NotImplementedError("Account deletion not supported in StateJournal") - - def __contains__(self, key): - return key in self.cache or key in self.backing - - def get(self, key, default=None): - try: - return self.__getitem__(key) - except KeyError: - return default - - def items(self): - res = self.backing.copy() - res.update(self.cache) - return res.items() - - def update(self, other_dict): - if hasattr(other_dict, 'items'): - for k, v in other_dict.items(): - self[k] = v - else: - for k, v in other_dict: - self[k] = v - - def copy(self): - res = self.backing.copy() - res.update(self.cache) - return res - - def commit(self): - """Flushes cached modifications to the backing dictionary.""" - self.backing.update(self.cache) - self.cache.clear() - - def rollback(self): - """Discards modifications.""" - self.cache.clear() - class State: def __init__(self): @@ -129,11 +69,9 @@ def verify_transaction_logic(self, tx): def copy(self): """ Return an independent copy of state for transactional validation. - Uses StateJournal for O(1) cloning instead of deepcopy. """ - new_state = State() - new_state.accounts = StateJournal(self.accounts) - new_state.contract_machine = ContractMachine(new_state) + new_state = copy.deepcopy(self) + new_state.contract_machine = ContractMachine(new_state) # Reinitialize contract_machine new_state.chain_id = self.chain_id return new_state @@ -186,22 +124,22 @@ def apply_transaction(self, tx): def _apply_validated_tx(self, tx): - original_accounts = self.accounts - journal = StateJournal(original_accounts) - self.accounts = journal - sender = self.accounts[tx.sender] total_cost = tx.amount + (getattr(tx, 'gas_limit', 0) * getattr(tx, 'fee_per_gas', 0)) sender['balance'] -= total_cost sender['nonce'] += 1 + import copy + state_snapshot = copy.deepcopy(self.accounts) + def rollback_and_refund(error_message, gas_used): - journal.rollback() - self.accounts = original_accounts + self.accounts = copy.deepcopy(state_snapshot) refund_acc = self.accounts[tx.sender] - refund_acc['balance'] -= (gas_used * getattr(tx, 'fee_per_gas', 0)) - refund_acc['nonce'] += 1 + refund_acc['balance'] += tx.amount + gas_refund = getattr(tx, 'gas_limit', 0) - gas_used + if gas_refund > 0: + refund_acc['balance'] += (gas_refund * getattr(tx, 'fee_per_gas', 0)) return Receipt(tx.tx_id, status=0, error_message=error_message, gas_used=gas_used) # LOGIC BRANCH 1: Contract Deployment @@ -224,9 +162,6 @@ def rollback_and_refund(error_message, gas_used): gas_refund = gas_used - code_gas if gas_refund > 0: self.accounts[tx.sender]['balance'] += (gas_refund * getattr(tx, 'fee_per_gas', 0)) - - journal.commit() - self.accounts = original_accounts return Receipt(tx.tx_id, status=1, contract_address=contract_address, gas_used=code_gas) # LOGIC BRANCH 2: Contract Call @@ -252,17 +187,12 @@ def rollback_and_refund(error_message, gas_used): if gas_refund > 0: self.accounts[tx.sender]['balance'] += (gas_refund * getattr(tx, 'fee_per_gas', 0)) - journal.commit() - self.accounts = original_accounts return Receipt(tx.tx_id, status=1, gas_used=gas_used) # LOGIC BRANCH 3: Regular Transfer receiver = self.get_account(tx.receiver) receiver['balance'] += tx.amount gas_used = getattr(tx, 'gas_limit', 0) - - journal.commit() - self.accounts = original_accounts return Receipt(tx.tx_id, status=1, gas_used=gas_used) def execute_internal_call(self, sender, receiver_address, amount, payload, gas_limit, depth, is_top_level=False): From 02f8d0b3fba3382aa71c643065e975cf6cbf351f Mon Sep 17 00:00:00 2001 From: siddhant Date: Tue, 4 Aug 2026 03:24:13 +0530 Subject: [PATCH 5/5] Fix difficulty bugs --- minichain/block.py | 3 +++ minichain/chain.py | 41 +++++++++++++++++++++++++++-------------- tests/test_target.py | 41 ++++++++++++++++++++++++++--------------- 3 files changed, 56 insertions(+), 29 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 7b38117..0501160 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -133,7 +133,10 @@ def from_dict(cls, payload: dict): # 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_target = None diff --git a/minichain/chain.py b/minichain/chain.py index e26418a..bf589c2 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -91,15 +91,15 @@ def _create_genesis_block(self, genesis_path): timestamp = config.get("timestamp") raw_target = config.get("target") - if raw_target is not None: - 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) - else: - from .network_config import MAX_TARGET - self.current_target = MAX_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) @@ -153,11 +153,13 @@ def get_total_work(self, chain_list=None): 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 - if avg_block_time > self.target_block_time: - return min(MAX_TARGET, target + 1) - if avg_block_time < self.target_block_time: - return max(MIN_TARGET, target - 1) - return 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)) def _apply_block(self, prev_block, block, state, target, avg_block_time): """ @@ -264,6 +266,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, [] + current_work = self.get_total_work() new_work = self.get_total_work(proposed_chain) diff --git a/tests/test_target.py b/tests/test_target.py index f58aff1..b371877 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -10,59 +10,70 @@ def test_target_adjustment(self): chain.target_block_time = 1000 chain.alpha = 0.5 chain.avg_block_time = 1000 - chain.current_target = MAX_TARGET - 10 - chain.chain[0].target = MAX_TARGET - 10 + + # Start with a target comfortably in the middle + start_target = MAX_TARGET // 2 + chain.current_target = start_target + chain.chain[0].target = start_target chain.chain[0].hash = chain.chain[0].compute_hash() # Fast mining: timestamps only 1ms apart - # avg = 0.5 * 1 + 0.5 * 1000 = 500.5 (which is < 1000) => target decrements by 1 + # avg = 0.5 * 1 + 0.5 * 1000 = 500.5 (which truncates to 500 in integer ops if needed, but in Python it's a float) + # new_target = (start_target * int(500.5)) // 1000 = (start_target * 500) // 1000 = start_target // 2 ts = chain.last_block.timestamp + 1 block1 = Block(index=1, previous_hash=chain.last_block.hash, transactions=[], timestamp=ts, target=chain.current_target, state_root=chain.state.state_root()) mined_block1 = mine_block(block1) self.assertEqual(chain.add_block(mined_block1), ValidationStatus.VALID) - self.assertEqual(chain.current_target, MAX_TARGET - 11) + expected_target_fast = (start_target * 500) // 1000 + self.assertEqual(chain.current_target, expected_target_fast) # Slow mining: timestamp 5000ms apart - # avg = 0.5 * 5000 + 0.5 * 500.5 = 2750.25 (which is > 1000) => target increments by 1 + # avg = 0.5 * 5000 + 0.5 * 500.5 = 2750.25 + # new_target = (expected_target_fast * int(2750.25)) // 1000 = (expected_target_fast * 2750) // 1000 ts = chain.last_block.timestamp + 5000 block2 = Block(index=2, previous_hash=chain.last_block.hash, transactions=[], timestamp=ts, target=chain.current_target, state_root=chain.state.state_root()) mined_block2 = mine_block(block2) self.assertEqual(chain.add_block(mined_block2), ValidationStatus.VALID) - self.assertEqual(chain.current_target, MAX_TARGET - 10) + expected_target_slow = (expected_target_fast * 2750) // 1000 + self.assertEqual(chain.current_target, expected_target_slow) def test_reorg_target_validation(self): chain1 = Blockchain() chain1.target_block_time = 1000 chain1.alpha = 0.5 chain1.avg_block_time = 1000 - chain1.current_target = MAX_TARGET - 10 - chain1.chain[0].target = MAX_TARGET - 10 + start_target = MAX_TARGET // 2 + chain1.current_target = start_target + chain1.chain[0].target = start_target chain1.chain[0].hash = chain1.chain[0].compute_hash() chain2 = Blockchain() chain2.target_block_time = 1000 chain2.alpha = 0.5 chain2.avg_block_time = 1000 - chain2.current_target = MAX_TARGET - 10 - chain2.chain[0].target = MAX_TARGET - 10 + chain2.current_target = start_target + chain2.chain[0].target = start_target chain2.chain[0].hash = chain2.chain[0].compute_hash() - # Chain 2 mines a fast block, target goes to MAX_TARGET - 11 + # Chain 2 mines a fast block block1 = Block(1, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1, target=chain2.current_target, state_root=chain2.state.state_root()) mine_block(block1) chain2.add_block(block1) - self.assertEqual(chain2.current_target, MAX_TARGET - 11) + + expected_target_fast = (start_target * 500) // 1000 + self.assertEqual(chain2.current_target, expected_target_fast) # Reorg chain1 to chain2 success, _ = chain1.resolve_conflicts(chain2.chain) self.assertTrue(success) - self.assertEqual(chain1.current_target, MAX_TARGET - 11) + self.assertEqual(chain1.current_target, expected_target_fast) # Forging a chain with wrong target should be rejected forged_chain = list(chain2.chain) - forged_block = Block(2, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1000, target=MAX_TARGET - 10, state_root=chain2.state.state_root()) + # Should be expected_target_fast but we provide start_target instead! + forged_block = Block(2, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1000, target=start_target, state_root=chain2.state.state_root()) mine_block(forged_block) forged_chain.append(forged_block) success, _ = chain1.resolve_conflicts(forged_chain) - self.assertFalse(success) # Rejected because target should have been MAX_TARGET - 11! + self.assertFalse(success) # Rejected because target is wrong!