diff --git a/minichain/contract.py b/minichain/contract.py index 6723335..6f1b2f9 100644 --- a/minichain/contract.py +++ b/minichain/contract.py @@ -22,7 +22,7 @@ def trace_calls(self, frame, event, arg): import json logger = logging.getLogger(__name__) -def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit): +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. @@ -62,6 +62,15 @@ def transfer_out(address, 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) @@ -71,13 +80,13 @@ def transfer_out(address, amount): sys.settrace(None) gas_used = meter.initial_gas - meter.gas - result_queue.put({"status": "success", "storage": context_dict.get("storage"), "transfers": transfers, "gas_used": gas_used}) + child_conn.send({"type": "return", "status": "success", "storage": context_dict.get("storage"), "transfers": transfers, "gas_used": gas_used}) except OutOfGasException as e: - result_queue.put({"status": "error", "error": "Out of gas!", "gas_used": gas_limit}) + 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 - result_queue.put({"status": "error", "error": str(e), "gas_used": gas_used}) + child_conn.send({"type": "return", "status": "error", "error": str(e), "gas_used": gas_used}) class ContractMachine: """ @@ -104,12 +113,15 @@ def _fail(error, gas_used=0): """Uniform failure result for execute().""" return {"success": False, "gas_used": gas_used, "error": error} - def execute(self, contract_address, sender_address, payload, amount, gas_limit): + def execute(self, contract_address, sender_address, payload, amount, gas_limit, depth=0): """ Executes the contract code associated with the contract_address. Returns a dict: {"success": bool, "gas_used": int, "error": str} """ + if depth > 10: + return self._fail("Max call depth exceeded", gas_limit) + account = self.state.get_account(contract_address) if not account: return self._fail("Account not found") @@ -163,23 +175,42 @@ def execute(self, contract_address, sender_address, payload, amount, gas_limit): try: # Execute in a subprocess with timeout - queue = multiprocessing.Queue() + import time + parent_conn, child_conn = multiprocessing.Pipe() p = multiprocessing.Process( target=_safe_exec_worker, - args=(code, globals_for_exec, context, queue, gas_limit) + args=(code, globals_for_exec, context, child_conn, gas_limit) ) p.start() - p.join(timeout=5) # 5 seconds timeout (increased for Windows compatibility) - - if p.is_alive(): - p.kill() - p.join() - logger.error("Contract execution timed out") - return self._fail("Execution timed out", gas_limit) + + start_time = time.time() + result = None + + 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 - try: - result = queue.get(timeout=1) - except Exception: + if result is None: logger.error("Contract execution crashed without result") return self._fail("Crashed", gas_limit) diff --git a/minichain/state.py b/minichain/state.py index 6dc9577..4d2487d 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -122,20 +122,26 @@ def apply_transaction(self, tx): # Backwards-compatible alias for the receipt-only entry point. validate_and_apply = apply_transaction + def _apply_validated_tx(self, tx): - """ - Apply a transaction that has already passed verify_transaction_logic. - Mutates state and returns a Receipt. Never call this directly — use - apply_transaction() or validate_and_apply_with_status() instead. - """ sender = self.accounts[tx.sender] - total_cost = tx.amount + (getattr(tx, 'gas_limit', 0) * getattr(tx, 'max_fee_per_gas', 0)) - # Deduct funds and increment nonce 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) + 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, 'max_fee_per_gas', 0)) + return Receipt(tx.tx_id, status=0, error_message=error_message, gas_used=gas_used) + # LOGIC BRANCH 1: Contract Deployment if tx.receiver is None or tx.receiver == "": contract_address = self.derive_contract_address(tx.sender, tx.nonce) @@ -146,71 +152,40 @@ def _apply_validated_tx(self, tx): code_gas = code_bytes * GAS_PER_BYTE if code_gas > gas_used: - # Restore sender balance on failure, but keep nonce incremented - sender['balance'] += tx.amount - return Receipt(tx.tx_id, status=0, error_message="Out of gas (Code size exceeded limit)", gas_used=gas_used) + return rollback_and_refund("Out of gas (Code size exceeded limit)", gas_used) - # Prevent redeploy collision existing = self.accounts.get(contract_address) if existing and existing.get("code"): - # Restore sender balance on failure, but keep nonce incremented - sender['balance'] += tx.amount - return Receipt(tx.tx_id, status=0, error_message="Contract collision", gas_used=gas_used) + return rollback_and_refund("Contract collision", gas_used) self.create_contract(contract_address, tx.data, initial_balance=tx.amount) - return Receipt(tx.tx_id, status=1, contract_address=contract_address, gas_used=gas_used) + gas_refund = gas_used - code_gas + if gas_refund > 0: + self.accounts[tx.sender]['balance'] += (gas_refund * getattr(tx, 'max_fee_per_gas', 0)) + return Receipt(tx.tx_id, status=1, contract_address=contract_address, gas_used=code_gas) # LOGIC BRANCH 2: Contract Call - # If data is provided (non-empty), treat as contract call if tx.data: - receiver = self.accounts.get(tx.receiver) gas_limit = getattr(tx, 'gas_limit', 0) - - # Fail if contract does not exist or has no code - if not receiver or not receiver.get("code"): - # Rollback sender balance on failure, but keep nonce incremented - sender['balance'] += tx.amount # Refund amount - return Receipt(tx.tx_id, status=0, error_message="Contract not found", gas_used=gas_limit) - - # Credit contract balance - receiver['balance'] += tx.amount - - # Undo the value transfer while keeping the nonce increment (used on failure paths). - def revert_transfer(): - receiver['balance'] -= tx.amount - sender['balance'] += tx.amount - - result = self.contract_machine.execute( - contract_address=tx.receiver, - sender_address=tx.sender, - payload=tx.data, + + result = self.execute_internal_call( + sender=tx.sender, + receiver_address=tx.receiver, amount=tx.amount, - gas_limit=gas_limit + payload=tx.data, + gas_limit=gas_limit, + depth=0, + is_top_level=True ) gas_used = result.get("gas_used", gas_limit) - gas_refund = gas_limit - gas_used - if gas_refund > 0: - sender['balance'] += (gas_refund * getattr(tx, 'max_fee_per_gas', 0)) if not result.get("success"): - revert_transfer() - return Receipt(tx.tx_id, status=0, error_message=result.get("error", "Execution failed"), gas_used=gas_used) - - transfers = result.get("transfers", []) - total_transferred_out = sum(t["amount"] for t in transfers) + return rollback_and_refund(result.get("error", "Execution failed"), gas_used) - if total_transferred_out > receiver['balance']: - revert_transfer() - return Receipt(tx.tx_id, status=0, error_message="Insufficient contract balance for transfers", gas_used=gas_used) - - # Execution & transfers valid: commit state changes atomically - self.update_contract_storage(tx.receiver, result["storage"]) - - receiver['balance'] -= total_transferred_out - for t in transfers: - target_acc = self.get_account(t["to"]) - target_acc['balance'] += t["amount"] + gas_refund = gas_limit - gas_used + if gas_refund > 0: + self.accounts[tx.sender]['balance'] += (gas_refund * getattr(tx, 'max_fee_per_gas', 0)) return Receipt(tx.tx_id, status=1, gas_used=gas_used) @@ -220,6 +195,52 @@ def revert_transfer(): gas_used = getattr(tx, 'gas_limit', 0) 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): + receiver = self.accounts.get(receiver_address) + if not receiver or not receiver.get("code"): + return {"success": False, "error": "Contract not found", "gas_used": gas_limit} + + sender_acc = self.accounts[sender] + + if not is_top_level: + if sender_acc['balance'] < amount: + return {"success": False, "error": "Insufficient balance", "gas_used": gas_limit} + sender_acc['balance'] -= amount + + receiver['balance'] += amount + + result = self.contract_machine.execute( + contract_address=receiver_address, + sender_address=sender, + payload=payload, + amount=amount, + gas_limit=gas_limit, + depth=depth + ) + + if not result.get("success"): + receiver['balance'] -= amount + if not is_top_level: + sender_acc['balance'] += amount + return result + + transfers = result.get("transfers", []) + total_transferred_out = sum(t["amount"] for t in transfers) + if total_transferred_out > receiver['balance']: + receiver['balance'] -= amount + if not is_top_level: + sender_acc['balance'] += amount + return {"success": False, "error": "Insufficient contract balance for transfers", "gas_used": result.get("gas_used", gas_limit)} + + self.update_contract_storage(receiver_address, result["storage"]) + + receiver['balance'] -= total_transferred_out + for t in transfers: + target_acc = self.get_account(t["to"]) + target_acc['balance'] += t["amount"] + + return result + def derive_contract_address(self, sender, nonce): raw = f"{sender}:{nonce}".encode() return sha256(raw, encoder=HexEncoder).decode()[:40] diff --git a/tests/test_contract_calls.py b/tests/test_contract_calls.py new file mode 100644 index 0000000..7aa086e --- /dev/null +++ b/tests/test_contract_calls.py @@ -0,0 +1,97 @@ +import unittest +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder +from minichain.state import State +from minichain.block import Transaction + +class TestCrossContractCalls(unittest.TestCase): + def setUp(self): + self.state = State() + self.sender_sk = SigningKey.generate() + self.sender_pk = self.sender_sk.verify_key.encode(encoder=HexEncoder).decode() + + # Credit sender with enough balance to deploy and call + self.state.credit_mining_reward(self.sender_pk, 1000000) + + def _sign(self, tx): + tx.sign(self.sender_sk) + return tx + + def test_successful_cross_contract_call(self): + # 1. Deploy target contract + code_target = """ +msg_data = msg.get('data') +if msg_data == 'increment': + storage['count'] = storage.get('count', 0) + 1 + # Returning the current count doesn't exist natively, + # but we can set the success return value in theory, but python exec doesn't return values directly. + # We will just verify it updated storage! +""" + deploy_tx1 = self._sign(Transaction(self.sender_pk, None, amount=0, nonce=0, data=code_target, gas_limit=50000, max_fee_per_gas=1)) + receipt1 = self.state.apply_transaction(deploy_tx1) + self.assertEqual(receipt1.status, 1) + target_addr = receipt1.contract_address + + # 2. Deploy caller contract + code_caller = f""" +target = "{target_addr}" +# Call target contract +call_contract(target, 'increment', 0) +storage['called'] = True +""" + deploy_tx2 = self._sign(Transaction(self.sender_pk, None, amount=0, nonce=1, data=code_caller, gas_limit=50000, max_fee_per_gas=1)) + receipt2 = self.state.apply_transaction(deploy_tx2) + self.assertEqual(receipt2.status, 1) + caller_addr = receipt2.contract_address + + # 3. Call caller contract + call_tx = self._sign(Transaction(self.sender_pk, caller_addr, amount=0, nonce=2, data="go", gas_limit=50000, max_fee_per_gas=1)) + receipt3 = self.state.apply_transaction(call_tx) + + self.assertEqual(receipt3.status, 1) + + # Verify Caller Storage + self.assertEqual(self.state.get_account(caller_addr)['storage']['called'], True) + + # Verify Target Storage + self.assertEqual(self.state.get_account(target_addr)['storage']['count'], 1) + + def test_cross_contract_call_failure_reverts(self): + # 1. Deploy target contract that fails + code_target = """ +storage['count'] = storage.get('count', 0) + 1 +raise Exception("Deliberate failure") +""" + deploy_tx1 = self._sign(Transaction(self.sender_pk, None, amount=0, nonce=0, data=code_target, gas_limit=50000, max_fee_per_gas=1)) + receipt1 = self.state.apply_transaction(deploy_tx1) + self.assertEqual(receipt1.status, 1) + target_addr = receipt1.contract_address + + # 2. Deploy caller contract + code_caller = f""" +target = "{target_addr}" +storage['called'] = True +# This should fail and revert everything +call_contract(target, 'increment', 0) +""" + deploy_tx2 = self._sign(Transaction(self.sender_pk, None, amount=0, nonce=1, data=code_caller, gas_limit=50000, max_fee_per_gas=1)) + receipt2 = self.state.apply_transaction(deploy_tx2) + self.assertEqual(receipt2.status, 1) + caller_addr = receipt2.contract_address + + # 3. Call caller contract + call_tx = self._sign(Transaction(self.sender_pk, caller_addr, amount=0, nonce=2, data="go", gas_limit=50000, max_fee_per_gas=1)) + receipt3 = self.state.apply_transaction(call_tx) + + # Must fail + self.assertEqual(receipt3.status, 0) + self.assertIn("Cross-contract call failed", receipt3.error_message) + + # Verify Caller Storage reverted (or wasn't applied) + self.assertEqual(self.state.get_account(caller_addr)['storage'], {}) + + # Verify Target Storage reverted (or wasn't applied) + self.assertEqual(self.state.get_account(target_addr)['storage'], {}) + +if __name__ == '__main__': + unittest.main()