From 3de75c520e199ded1f927210569fbdaa05c0d7e5 Mon Sep 17 00:00:00 2001 From: siddhant Date: Fri, 10 Jul 2026 17:59:03 +0530 Subject: [PATCH] fix chain fundamentals --- minichain/block.py | 10 +- minichain/chain.py | 30 +++--- minichain/contract.py | 155 ++++++++++-------------------- minichain/persistence.py | 12 +++ minichain/pow.py | 11 ++- tests/test_core.py | 2 +- tests/test_difficulty.py | 42 +++++--- tests/test_persistence_runtime.py | 4 +- tests/test_protocol_hardening.py | 2 +- tests/test_serialization.py | 6 +- 10 files changed, 127 insertions(+), 147 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index c81dbe3..9810a8c 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -72,6 +72,14 @@ def __init__( if self.receipt_root is None and self.receipts: self.receipt_root = calculate_receipt_root(self.receipts) + @property + def target(self) -> int: + if self.difficulty is None: + return (1 << 256) - 1 + if self.difficulty <= 256: + return (1 << (256 - 4 * self.difficulty)) - 1 + return self.difficulty + # ------------------------- # HEADER (used for mining) # ------------------------- @@ -134,8 +142,6 @@ def from_dict(cls, payload: dict): 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}") else: parsed_diff = None diff --git a/minichain/chain.py b/minichain/chain.py index 9d2f6e3..1e4c8ad 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 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}") @@ -89,6 +88,8 @@ def _create_genesis_block(self, genesis_path): timestamp = config.get("timestamp") difficulty = config.get("difficulty") + if isinstance(difficulty, int) and difficulty <= 256: + difficulty = (1 << (256 - 4 * difficulty)) - 1 self.target_block_time = config.get("target_block_time", 10000) self.alpha = config.get("alpha", 0.1) @@ -133,20 +134,21 @@ 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 proportional to expected hashes (2^256 // 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 + 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 + """Advance the EMA difficulty control after a block, returning the new target.""" + ratio = avg_block_time / self.target_block_time + # Clamp ratio to prevent extreme swings + ratio = max(0.25, min(4.0, ratio)) + new_target = int(difficulty * ratio) + max_target = (1 << 256) - 1 + return max(1, min(max_target, new_target)) def _apply_block(self, prev_block, block, state, difficulty, avg_block_time): """ @@ -164,8 +166,8 @@ def _apply_block(self, prev_block, block, state, difficulty, avg_block_time): 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) + if block.target != difficulty: + logger.warning("Block %s rejected: Invalid target. Expected %s, got %s", block.index, difficulty, block.target) return ValidationStatus.INVALID, difficulty, avg_block_time receipts = [] @@ -264,7 +266,7 @@ 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_difficulty = proposed_chain[0].target temp_avg_block_time = self.target_block_time for i in range(1, len(proposed_chain)): diff --git a/minichain/contract.py b/minichain/contract.py index 6f1b2f9..2d6081b 100644 --- a/minichain/contract.py +++ b/minichain/contract.py @@ -22,71 +22,6 @@ def trace_calls(self, frame, event, arg): import json logger = logging.getLogger(__name__) -def _safe_exec_worker(code, globals_dict, context_dict, child_conn, gas_limit): - """ - Worker function to execute contract code in a separate process with gas metering. - - SECURITY: - This function relies on `globals_dict` (which has `__builtins__` stripped down - to a minimal safe allowlist) to prevent malicious code from accessing file systems - (e.g., `open()`), networking, or OS-level commands (e.g., `__import__('os')`). - Because `exec` is run with these restricted globals, any attempt to call unauthorized - builtins or standard library modules will result in a NameError or ImportError. - """ - try: - # Attempt to set resource limits (Unix only) - try: - import resource - # Limit CPU time (seconds) and memory (bytes) - example values - resource.setrlimit(resource.RLIMIT_CPU, (10, 10)) # Align with p.join timeout (10 seconds) - resource.setrlimit(resource.RLIMIT_AS, (100 * 1024 * 1024, 100 * 1024 * 1024)) - except ImportError: - logger.warning("Resource module not available. Contract will run without OS-level resource limits.") - except (OSError, ValueError) as e: - logger.warning("Failed to set resource limits: %s", e) - - transfers = [] - - def transfer_out(address, amount): - if not isinstance(amount, int) or amount <= 0: - raise ValueError("Invalid transfer amount") - if not isinstance(address, str): - raise ValueError("Invalid address type") - if not address or len(address) not in (40, 64): - raise ValueError("Invalid address format") - try: - int(address, 16) - except ValueError: - raise ValueError("Invalid address format") - transfers.append({"to": address, "amount": amount}) - - globals_dict["__builtins__"]["transfer_out"] = transfer_out - - def call_contract(address, payload, amount=0): - child_conn.send({"type": "call", "address": address, "payload": payload, "amount": amount}) - resp = child_conn.recv() - if not resp.get("success"): - raise Exception("Cross-contract call failed: " + str(resp.get("error"))) - return resp.get("result", True) - - globals_dict["__builtins__"]["call_contract"] = call_contract - - meter = GasMeter(gas_limit) - sys.settrace(meter.trace_calls) - - try: - exec(code, globals_dict, context_dict) - finally: - sys.settrace(None) - - gas_used = meter.initial_gas - meter.gas - child_conn.send({"type": "return", "status": "success", "storage": context_dict.get("storage"), "transfers": transfers, "gas_used": gas_used}) - except OutOfGasException as e: - child_conn.send({"type": "return", "status": "error", "error": "Out of gas!", "gas_used": gas_limit}) - except Exception as e: - # If it failed for another reason, we still charge the gas it consumed up to the failure - gas_used = gas_limit if 'meter' not in locals() else meter.initial_gas - meter.gas - child_conn.send({"type": "return", "status": "error", "error": str(e), "gas_used": gas_used}) class ContractMachine: """ @@ -174,45 +109,57 @@ def execute(self, contract_address, sender_address, payload, amount, gas_limit, } try: - # Execute in a subprocess with timeout - import time - parent_conn, child_conn = multiprocessing.Pipe() - p = multiprocessing.Process( - target=_safe_exec_worker, - args=(code, globals_for_exec, context, child_conn, gas_limit) - ) - p.start() + transfers = [] - start_time = time.time() - result = None + def transfer_out(address, amount): + if not isinstance(amount, int) or amount <= 0: + raise ValueError("Invalid transfer amount") + if not isinstance(address, str): + raise ValueError("Invalid address type") + if not address or len(address) not in (40, 64): + raise ValueError("Invalid address format") + try: + int(address, 16) + except ValueError: + raise ValueError("Invalid address format") + transfers.append({"to": address, "amount": amount}) + + globals_for_exec["__builtins__"]["transfer_out"] = transfer_out + + meter = GasMeter(gas_limit) + def call_contract(address, payload, amount=0): + # Execute internal sub-call recursively + sub_result = self.state.execute_internal_call( + sender=contract_address, # Caller is the current contract! + receiver_address=address, + amount=amount, + payload=payload, + gas_limit=meter.gas, # Let inner call use remaining gas + depth=depth + 1 + ) + if not sub_result.get("success"): + raise Exception("Cross-contract call failed: " + str(sub_result.get("error"))) + # Deduct gas used by the sub-call + meter.gas -= sub_result.get("gas_used", 0) + if meter.gas <= 0: + raise OutOfGasException("Out of gas!") + return sub_result.get("result", True) + + globals_for_exec["__builtins__"]["call_contract"] = call_contract + + sys.settrace(meter.trace_calls) - while p.is_alive() or parent_conn.poll(): - if time.time() - start_time > 5: - p.kill() - p.join() - logger.error("Contract execution timed out") - return self._fail("Execution timed out", gas_limit) - - if parent_conn.poll(0.1): - msg = parent_conn.recv() - if msg.get("type") == "call": - # Execute internal sub-call recursively - sub_result = self.state.execute_internal_call( - sender=contract_address, # Caller is the current contract! - receiver_address=msg["address"], - amount=msg.get("amount", 0), - payload=msg["payload"], - gas_limit=gas_limit, # Let inner call use remaining gas - depth=depth + 1 - ) - parent_conn.send(sub_result) - elif msg.get("type") == "return": - result = msg - break - - if result is None: - logger.error("Contract execution crashed without result") - return self._fail("Crashed", gas_limit) + try: + exec(code, globals_for_exec, context) + gas_used = meter.initial_gas - meter.gas + result = {"status": "success", "storage": context.get("storage"), "transfers": transfers, "gas_used": gas_used} + except OutOfGasException as e: + result = {"status": "error", "error": "Out of gas!", "gas_used": gas_limit} + except Exception as e: + gas_used = meter.initial_gas - meter.gas if 'meter' in locals() else 0 + result = {"status": "error", "error": str(e), "gas_used": gas_used} + finally: + sys.settrace(None) if result["status"] != "success": logger.error("Contract Execution Failed: %s", result.get('error')) @@ -262,8 +209,8 @@ def _validate_code_ast(self, code): if isinstance(node, ast.JoinedStr): # f-strings logger.warning("Rejected f-string usage.") return False - if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Mult, ast.Pow, ast.MatMult)): - logger.warning("Rejected contract code with potentially unbounded operator (*, **, @).") + if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Pow, ast.MatMult)): + logger.warning("Rejected contract code with potentially unbounded operator (**, @).") return False return True except SyntaxError: diff --git a/minichain/persistence.py b/minichain/persistence.py index dc989ba..3df4657 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -112,6 +112,18 @@ def load(path: str = ".") -> Blockchain: blockchain.chain = blocks blockchain.state.accounts = normalized_accounts + if len(blocks) > 1: + current_difficulty = blocks[0].difficulty if blocks[0].difficulty is not None else ((1 << 256) - 1) + avg_block_time = blockchain.target_block_time + for i in range(1, len(blocks)): + prev_block = blocks[i - 1] + block = blocks[i] + new_avg = blockchain.alpha * (block.timestamp - prev_block.timestamp) + (1 - blockchain.alpha) * avg_block_time + current_difficulty = blockchain._next_difficulty(current_difficulty, new_avg) + avg_block_time = new_avg + blockchain.current_difficulty = current_difficulty + blockchain.avg_block_time = avg_block_time + logger.info( "Loaded %d blocks and %d accounts from '%s'", len(blockchain.chain), diff --git a/minichain/pow.py b/minichain/pow.py index 6b3846b..1fc6bf0 100644 --- a/minichain/pow.py +++ b/minichain/pow.py @@ -23,18 +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 + difficulty = difficulty if difficulty is not None else block.target if not isinstance(difficulty, int) or difficulty <= 0: - raise ValueError("Difficulty must be a positive integer.") + raise ValueError("Difficulty/Target must be a positive integer.") + + target = difficulty - 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, ) @@ -57,7 +58,7 @@ def mine_block( block_hash = calculate_hash(header_dict) # Check difficulty target - if block_hash.startswith(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 e4236c6..340f5f3 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, + difficulty=(1<<256)-1, 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 index d15c853..b8830b1 100644 --- a/tests/test_difficulty.py +++ b/tests/test_difficulty.py @@ -9,56 +9,68 @@ def test_difficulty_adjustment(self): chain.target_block_time = 1000 chain.alpha = 0.5 chain.avg_block_time = 1000 - chain.current_difficulty = 3 - chain.chain[0].difficulty = 3 + + start_target = (1 << 256) - 1 + chain.current_difficulty = start_target + chain.chain[0].difficulty = start_target # 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) + # ratio = 500.5 / 1000 = 0.5005 + # new_target = int(start_target * 0.5005) 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) + expected_target_1 = int(start_target * 0.5005) + self.assertEqual(chain.current_difficulty, expected_target_1) # 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 + # ratio = 2750.25 / 1000 = 2.75025 + # new_target = int(expected_target_1 * 2.75025) 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) + expected_target_2 = min((1 << 256) - 1, int(expected_target_1 * 2.75025)) + self.assertEqual(chain.current_difficulty, expected_target_2) 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 + + start_target = (1 << 256) - 1 + chain1.current_difficulty = start_target + chain1.chain[0].difficulty = start_target 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_difficulty = start_target + chain2.chain[0].difficulty = start_target - # Chain 2 mines a fast block, difficulty goes to 2 + # Chain 2 mines a fast block 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) + + expected_target_1 = int(start_target * 0.5005) + self.assertEqual(chain2.current_difficulty, expected_target_1) # Reorg chain1 to chain2 success, orphans = chain1.resolve_conflicts(chain2.chain) self.assertTrue(success) - self.assertEqual(chain1.current_difficulty, 2) + self.assertEqual(chain1.current_difficulty, expected_target_1) # 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()) + forged_block = Block(2, chain2.last_block.hash, [], timestamp=chain2.last_block.timestamp + 1000, difficulty=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 difficulty should have been 2! + self.assertFalse(success) diff --git a/tests/test_persistence_runtime.py b/tests/test_persistence_runtime.py index 73265e5..18169dc 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, + difficulty=(1<<256)-1, state_root=temp_state.state_root(), receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], ) - mine_block(block, difficulty=1) + mine_block(block, difficulty=(1<<256)-1) bc.add_block(block) return bc diff --git a/tests/test_protocol_hardening.py b/tests/test_protocol_hardening.py index 1fa98b5..68bfe3a 100644 --- a/tests/test_protocol_hardening.py +++ b/tests/test_protocol_hardening.py @@ -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, + difficulty=(1<<256)-1, state_root="0"*64, receipts=[receipt], receipt_root=calculate_receipt_root([receipt]) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index aa5f2b7..9e6cec3 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], difficulty=(1<<256)-1, timestamp=999999, miner="a" * 40) + block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=(1<<256)-1, 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 + difficulty=(1<<256)-1, timestamp=999999, miner="a"*40 ) block.hash = block.compute_hash()