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/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..ee00551 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -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 @@ -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 @@ -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) @@ -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: @@ -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 - 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") diff --git a/minichain/chain.py b/minichain/chain.py index cf46ce2..0b9e74f 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -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}") @@ -88,11 +90,19 @@ 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( @@ -100,7 +110,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 +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) + + 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)) - 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 +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)) @@ -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): """ @@ -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]: @@ -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, [] + current_work = self.get_total_work() new_work = self.get_total_work(proposed_chain) @@ -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, [] @@ -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 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/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 6b3846b..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): @@ -14,7 +19,7 @@ def calculate_hash(block_dict): def mine_block( block, - difficulty=None, + target=None, max_nonce=None, timeout_seconds=None, logger=None, @@ -23,26 +28,27 @@ 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.") + block.target = target - target = "0" * difficulty - 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() if logger: logger.info( - "Mining block %s (Difficulty: %s)", + "Mining block %s (Target: %s)", block.index, - difficulty, + target, ) 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") @@ -56,8 +62,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/minichain/receipt.py b/minichain/receipt.py index 60053c9..bdf7695 100644 --- a/minichain/receipt.py +++ b/minichain/receipt.py @@ -1,15 +1,15 @@ -from typing import List, Optional +from typing import List, Sequence class Receipt: """ Represents the execution result of a transaction. """ - def __init__(self, tx_hash: str, status: int, gas_used: int = 0, error_message: Optional[str] = None, logs: Optional[List[dict]] = None, contract_address: Optional[str] = None): + def __init__(self, tx_hash: str, status: int, gas_used: int = 0, error_message: str = "", logs: Sequence[dict] = (), contract_address: str = ""): self.tx_hash = tx_hash self.status = status # 1 for success, 0 for failure self.gas_used = gas_used self.error_message = error_message - self.logs = logs or [] + self.logs = list(logs) self.contract_address = contract_address def to_dict(self) -> dict: @@ -27,8 +27,8 @@ def from_dict(cls, payload: dict) -> 'Receipt': return cls( tx_hash=payload["tx_hash"], status=payload["status"], - gas_used=payload.get("gas_used", 0), - error_message=payload.get("error_message"), - logs=payload.get("logs", []), - contract_address=payload.get("contract_address") + gas_used=payload.get("gas_used") or 0, + error_message=payload.get("error_message") or "", + logs=payload.get("logs") or [], + contract_address=payload.get("contract_address") or "" ) 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_difficulty.py b/tests/test_difficulty.py deleted file mode 100644 index d15c853..0000000 --- a/tests/test_difficulty.py +++ /dev/null @@ -1,64 +0,0 @@ -import unittest -from minichain import Blockchain, Block -from minichain.pow import mine_block -from minichain.validators import ValidationStatus - -class TestEMADifficulty(unittest.TestCase): - def test_difficulty_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 - - # Fast mining: timestamps only 1ms apart - # avg = 0.5 * 1 + 0.5 * 1000 = 500.5 (which is < 1000) => difficulty increments to 4 - 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()) - mined_block1 = mine_block(block1) - self.assertEqual(chain.add_block(mined_block1), ValidationStatus.VALID) - self.assertEqual(chain.current_difficulty, 4) - - # Slow mining: timestamp 5000ms apart - # avg = 0.5 * 5000 + 0.5 * 500.5 = 2750.25 (which is > 1000) => difficulty decrements to 3 - 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()) - mined_block2 = mine_block(block2) - self.assertEqual(chain.add_block(mined_block2), ValidationStatus.VALID) - self.assertEqual(chain.current_difficulty, 3) - - def test_reorg_difficulty_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 - - 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 - - # 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()) - mine_block(block1) - chain2.add_block(block1) - self.assertEqual(chain2.current_difficulty, 2) - - # Reorg chain1 to chain2 - success, orphans = chain1.resolve_conflicts(chain2.chain) - self.assertTrue(success) - self.assertEqual(chain1.current_difficulty, 2) - - # Forging a chain with wrong difficulty 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()) - 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! 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..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], - difficulty=1, + target=bc.current_target, state_root=temp_state.state_root(), receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], ) - mine_block(block, difficulty=1) + mine_block(block, target=bc.current_target) bc.add_block(block) return bc diff --git a/tests/test_protocol_hardening.py b/tests/test_protocol_hardening.py index 5ab706c..d34de14 100644 --- a/tests/test_protocol_hardening.py +++ b/tests/test_protocol_hardening.py @@ -16,8 +16,7 @@ def test_canonical_json_is_order_independent(self): self.assertEqual(calculate_hash(left), calculate_hash(right)) def test_block_hash_matches_compute_hash(self): - block = Block(index=1, previous_hash="abc", transactions=[], timestamp=1234567890) - block.difficulty = 2 + block = Block(index=1, previous_hash="abc", target=2, transactions=[], timestamp=1234567890) block.nonce = 7 self.assertEqual(block.compute_hash(), calculate_hash(block.to_header_dict())) @@ -117,7 +116,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 +149,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_target.py b/tests/test_target.py new file mode 100644 index 0000000..1cd35c3 --- /dev/null +++ b/tests/test_target.py @@ -0,0 +1,79 @@ +import unittest +from minichain import Blockchain, Block +from minichain.pow import mine_block +from minichain.validators import ValidationStatus +from minichain.network_config import MAX_TARGET + +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 + + # 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 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) + 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 + # 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) + 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 + 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 = start_target + chain2.chain[0].target = start_target + chain2.chain[0].hash = chain2.chain[0].compute_hash() + + # Chain 2 mines a fast block + block1 = Block(1, chain2.last_block.hash, chain2.current_target, [], timestamp=chain2.last_block.timestamp + 1, state_root=chain2.state.state_root()) + mine_block(block1) + chain2.add_block(block1) + + 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, expected_target_fast) + + # Forging a chain with wrong target should be rejected + forged_chain = list(chain2.chain) + # Should be expected_target_fast but we provide start_target instead! + forged_block = Block(2, chain2.last_block.hash, start_target, [], timestamp=chain2.last_block.timestamp + 1000, 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 is wrong!