Skip to content

Commit 5ec7c0b

Browse files
feat: implement API for contracts to initiate outgoing transfers
1 parent 398d1fb commit 5ec7c0b

3 files changed

Lines changed: 135 additions & 8 deletions

File tree

minichain/contract.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,17 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit)
4545
except (OSError, ValueError) as e:
4646
logger.warning("Failed to set resource limits: %s", e)
4747

48+
transfers = []
49+
50+
def transfer_out(address, amount):
51+
if not isinstance(amount, int) or amount <= 0:
52+
raise ValueError("Invalid transfer amount")
53+
if not isinstance(address, str):
54+
raise ValueError("Invalid address type")
55+
transfers.append({"to": address, "amount": amount})
56+
57+
globals_dict["__builtins__"]["transfer_out"] = transfer_out
58+
4859
meter = GasMeter(gas_limit)
4960
sys.settrace(meter.trace_calls)
5061

@@ -54,7 +65,7 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit)
5465
sys.settrace(None)
5566

5667
gas_used = meter.initial_gas - meter.gas
57-
result_queue.put({"status": "success", "storage": context_dict.get("storage"), "gas_used": gas_used})
68+
result_queue.put({"status": "success", "storage": context_dict.get("storage"), "transfers": transfers, "gas_used": gas_used})
5869
except OutOfGasException as e:
5970
result_queue.put({"status": "error", "error": "Out of gas!", "gas_used": gas_limit})
6071
except Exception as e:
@@ -172,13 +183,7 @@ def execute(self, contract_address, sender_address, payload, amount, gas_limit):
172183
logger.error("Contract storage not JSON serializable")
173184
return {"success": False, "gas_used": result.get("gas_used", gas_limit), "error": "Storage not JSON serializable"}
174185

175-
# Commit updated storage only after successful execution
176-
self.state.update_contract_storage(
177-
contract_address,
178-
result["storage"]
179-
)
180-
181-
return {"success": True, "gas_used": result["gas_used"], "error": None}
186+
return {"success": True, "gas_used": result["gas_used"], "transfers": result.get("transfers", []), "storage": result["storage"], "error": None}
182187

183188
except Exception as e:
184189
logger.error("Contract Execution Failed", exc_info=True)

minichain/state.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,23 @@ def apply_transaction(self, tx):
157157
sender['balance'] += tx.amount # Refund amount
158158
return Receipt(tx.tx_id, status=0, error_message=result.get("error", "Execution failed"), gas_used=gas_used)
159159

160+
transfers = result.get("transfers", [])
161+
total_transferred_out = sum(t["amount"] for t in transfers)
162+
163+
if total_transferred_out > receiver['balance']:
164+
# Rollback transfer if execution attempts to spend more than balance
165+
receiver['balance'] -= tx.amount
166+
sender['balance'] += tx.amount # Refund amount
167+
return Receipt(tx.tx_id, status=0, error_message="Insufficient contract balance for transfers", gas_used=gas_used)
168+
169+
# Execution & transfers valid: commit state changes atomically
170+
self.update_contract_storage(tx.receiver, result["storage"])
171+
172+
receiver['balance'] -= total_transferred_out
173+
for t in transfers:
174+
target_acc = self.get_account(t["to"])
175+
target_acc['balance'] += t["amount"]
176+
160177
return Receipt(tx.tx_id, status=1, gas_used=gas_used)
161178

162179
# LOGIC BRANCH 3: Regular Transfer

tests/test_contract_transfers.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import unittest
2+
from nacl.signing import SigningKey
3+
from nacl.encoding import HexEncoder
4+
from minichain.state import State
5+
from minichain.block import Transaction
6+
7+
class TestContractTransfers(unittest.TestCase):
8+
def setUp(self):
9+
self.state = State()
10+
self.sender_sk = SigningKey.generate()
11+
self.sender_pk = self.sender_sk.verify_key.encode(encoder=HexEncoder).decode()
12+
13+
self.target_pk = SigningKey.generate().verify_key.encode(encoder=HexEncoder).decode()
14+
15+
# Credit sender with enough balance to deploy and call
16+
self.state.credit_mining_reward(self.sender_pk, 10000)
17+
18+
def _sign(self, tx):
19+
tx.sign(self.sender_sk)
20+
return tx
21+
22+
def test_successful_transfer_out(self):
23+
# 1. Deploy Contract
24+
code = """
25+
target = msg['data']['target']
26+
transfer_out(target, 50)
27+
transfer_out(target, 25)
28+
"""
29+
deploy_tx = self._sign(Transaction(self.sender_pk, None, amount=100, nonce=0, data=code, fee=1000))
30+
receipt = self.state.apply_transaction(deploy_tx)
31+
self.assertEqual(receipt.status, 1)
32+
contract_addr = receipt.contract_address
33+
34+
# Sender sent 100 to contract, plus 10 fee
35+
self.assertEqual(self.state.get_account(contract_addr)['balance'], 100)
36+
self.assertEqual(self.state.get_account(self.target_pk)['balance'], 0)
37+
38+
# 2. Call Contract to transfer out 75 coins
39+
call_tx = self._sign(Transaction(self.sender_pk, contract_addr, amount=0, nonce=1, data={"target": self.target_pk}, fee=1000))
40+
receipt2 = self.state.apply_transaction(call_tx)
41+
42+
self.assertEqual(receipt2.status, 1)
43+
44+
# Contract balance should be 100 - 75 = 25
45+
self.assertEqual(self.state.get_account(contract_addr)['balance'], 25)
46+
47+
# Target should have 75
48+
self.assertEqual(self.state.get_account(self.target_pk)['balance'], 75)
49+
50+
def test_failed_transfer_out_insufficient_balance(self):
51+
# 1. Deploy Contract
52+
code = """
53+
target = msg['data']['target']
54+
# Try to transfer 500, but contract only has 100
55+
transfer_out(target, 500)
56+
storage['malicious_state'] = 'corrupted'
57+
"""
58+
deploy_tx = self._sign(Transaction(self.sender_pk, None, amount=100, nonce=0, data=code, fee=1000))
59+
receipt = self.state.apply_transaction(deploy_tx)
60+
self.assertEqual(receipt.status, 1)
61+
contract_addr = receipt.contract_address
62+
63+
# 2. Call Contract
64+
call_tx = self._sign(Transaction(self.sender_pk, contract_addr, amount=0, nonce=1, data={"target": self.target_pk}, fee=1000))
65+
receipt2 = self.state.apply_transaction(call_tx)
66+
67+
# Should fail with status 0
68+
self.assertEqual(receipt2.status, 0)
69+
self.assertEqual(receipt2.error_message, "Insufficient contract balance for transfers")
70+
71+
# State should be completely rolled back (target balance 0, contract balance remains 100)
72+
self.assertEqual(self.state.get_account(contract_addr)['balance'], 100)
73+
self.assertEqual(self.state.get_account(self.target_pk)['balance'], 0)
74+
75+
# Storage should NOT be updated
76+
self.assertEqual(self.state.get_account(contract_addr)['storage'], {})
77+
78+
def test_transfer_with_incoming_funds(self):
79+
# 1. Deploy Contract (0 initial balance)
80+
code = """
81+
target = msg['data']['target']
82+
# We use the incoming funds to instantly transfer out!
83+
transfer_out(target, msg['value'])
84+
"""
85+
deploy_tx = self._sign(Transaction(self.sender_pk, None, amount=0, nonce=0, data=code, fee=1000))
86+
receipt = self.state.apply_transaction(deploy_tx)
87+
self.assertEqual(receipt.status, 1)
88+
contract_addr = receipt.contract_address
89+
90+
self.assertEqual(self.state.get_account(contract_addr)['balance'], 0)
91+
92+
# 2. Call Contract sending 50 coins
93+
call_tx = self._sign(Transaction(self.sender_pk, contract_addr, amount=50, nonce=1, data={"target": self.target_pk}, fee=1000))
94+
receipt2 = self.state.apply_transaction(call_tx)
95+
96+
self.assertEqual(receipt2.status, 1)
97+
98+
# Contract balance should be 0 (received 50, sent 50)
99+
self.assertEqual(self.state.get_account(contract_addr)['balance'], 0)
100+
101+
# Target should have exactly 50
102+
self.assertEqual(self.state.get_account(self.target_pk)['balance'], 50)
103+
104+
if __name__ == '__main__':
105+
unittest.main()

0 commit comments

Comments
 (0)