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
65 changes: 48 additions & 17 deletions minichain/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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

Expand All @@ -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:
"""
Expand All @@ -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:

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.

Magic constant here. Move it to the configuration file.

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

Expand Down
133 changes: 77 additions & 56 deletions minichain/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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.

This will be extremely inefficient. Won't it?

If so, could you create an issue to address this in the future?


def rollback_and_refund(error_message, gas_used):
self.accounts = copy.deepcopy(state_snapshot)

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.

Does the state snapshot include the storage? Or only the account balances?

If it is the latter, might we be forgetting to roll back the storage?

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)
Expand All @@ -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)

Expand All @@ -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]
Expand Down
97 changes: 97 additions & 0 deletions tests/test_contract_calls.py
Original file line number Diff line number Diff line change
@@ -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()