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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions minichain/block.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ def __init__(
if self.receipt_root is None and self.receipts:
self.receipt_root = calculate_receipt_root(self.receipts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There seem to be many magic constants in this PR.

@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)
# -------------------------
Expand Down Expand Up @@ -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

Expand Down
30 changes: 16 additions & 14 deletions minichain/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

1 << 256 is a constant number. There is no need to be recalculating it here all the time.


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):
"""
Expand All @@ -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 = []
Expand Down Expand Up @@ -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)):
Expand Down
155 changes: 51 additions & 104 deletions minichain/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions minichain/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
11 changes: 6 additions & 5 deletions minichain/pow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading