-
-
Notifications
You must be signed in to change notification settings - Fork 18
feat: Implement synchronous cross-contract calls and robust state #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/state-gas-metering
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
|
|
@@ -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] | ||
|
|
||
| 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() |
There was a problem hiding this comment.
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.