diff --git a/README.md b/README.md index 58fea8e..b14934e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,26 @@ It is encouraged that you develop an initial prototype during the application ph * Read this book: https://www.marabu.dev/blockchain-foundations.pdf +--- + +## Smart Contracts + +MiniChain supports fully-functional smart contracts written directly in Python! +The execution engine uses `sys.settrace` for precise **Gas Metering** (charging 1 gas per executed opcode) and `multiprocessing` for **Sandboxed Execution** to ensure network security. + +### Writing a Contract +Smart contracts in MiniChain have access to a persistent `storage` dictionary and a `msg` dictionary containing transaction context (`sender`, `value`, `data`). + +Check out the `/examples` directory for tutorials: +- `examples/counter.py` - A basic state mutation example. +- `examples/stablecoin.py` - A minimal ERC-20 style fungible token. +- `examples/dex.py` - An Automated Market Maker (AMM) using the constant product formula (x * y = k). + +### Interacting via CLI +Start the interactive node using `python main.py` and use the following commands: +1. **Deploy:** `deploy [amount] [gas_limit]` +2. **Call:** `call [amount] [gas_limit]` + --- ## Tech Stack diff --git a/examples/counter.py b/examples/counter.py new file mode 100644 index 0000000..e689460 --- /dev/null +++ b/examples/counter.py @@ -0,0 +1,37 @@ +# Counter Smart Contract Example +# +# This is a simple counter contract designed to demonstrate the basic +# structure of smart contracts in MiniChain. +# +# Available built-ins in the MiniChain Sandbox: +# - `storage`: A dictionary persisting state across executions. +# - `msg`: A dictionary containing transaction info: +# - `msg['sender']` : The address of the caller. +# - `msg['value']` : The amount of coins attached to the call. +# - `msg['data']` : The payload string. +# +# Available functions: range(), len(), min(), max(), abs(), str(), bool(), float(), int(), list(), dict(), tuple(), sum() +# +# NOTE: The sandbox does NOT allow imports, print(), or any double-underscore methods. + +if msg['data'] == 'increment': + # Retrieve the current counter value, defaulting to 0 if it doesn't exist + current_value = storage.get('counter', 0) + + # Increment the counter + storage['counter'] = current_value + 1 + +elif msg['data'] == 'decrement': + current_value = storage.get('counter', 0) + storage['counter'] = current_value - 1 + +elif msg['data'] == 'reset': + # You can restrict who can reset the counter by checking the sender! + # (Just an example, anyone can call this one) + storage['counter'] = 0 + +else: + # If the payload doesn't match any known command, raise an exception. + # This will fail the transaction and refund the 'amount' to the sender, + # but the network will keep the 'fee' as gas. + raise Exception("Unknown command. Valid commands: increment, decrement, reset") diff --git a/examples/dex.py b/examples/dex.py new file mode 100644 index 0000000..6e2025b --- /dev/null +++ b/examples/dex.py @@ -0,0 +1,90 @@ +# MiniSwap (DEX) Smart Contract Example +# +# This contract implements a minimal Automated Market Maker (AMM) +# using the x * y = k constant product formula. +# It trades the native MiniChain coin (msg['value']) against a minted DEX Token. +# +# Valid Payloads: +# - 'init' (Must send initial native coins to provide liquidity) +# - 'buy' (Sends native coins, receives DEX tokens) +# - 'sell:' (Sells DEX tokens, receives native coins) +# +# Note: Since native coins sent to the contract are automatically added to the +# contract's balance by the state manager BEFORE execution, msg['value'] is already +# inside the contract's physical balance. + +if msg['data'] == 'init': + # Initialize the liquidity pool + if storage.get('k') is not None: + raise Exception("Already initialized") + if msg['value'] <= 0: + raise Exception("Must provide initial native coin liquidity") + + # We will arbitrarily mint 1000 DEX tokens to match the initial coin liquidity + storage['native_reserve'] = msg['value'] + storage['token_reserve'] = 1000 + storage['k'] = storage['native_reserve'] * storage['token_reserve'] + + # Give the initial tokens to the creator + storage[msg['sender']] = 1000 + +elif msg['data'] == 'buy': + # User sends native coins to buy DEX tokens + if storage.get('k') is None: + raise Exception("Not initialized") + if msg['value'] <= 0: + raise Exception("Must send coins to buy tokens") + + # Calculate how many tokens to give using x * y = k + # (native_reserve + msg['value']) * (token_reserve - tokens_out) = k + new_native_reserve = storage['native_reserve'] + msg['value'] + new_token_reserve = storage['k'] // new_native_reserve + + tokens_out = storage['token_reserve'] - new_token_reserve + if tokens_out <= 0: + raise Exception("Not enough tokens to dispense") + + # Update reserves + storage['native_reserve'] = new_native_reserve + storage['token_reserve'] = new_token_reserve + + # Credit tokens to buyer + sender = msg['sender'] + storage[sender] = storage.get(sender, 0) + tokens_out + +elif msg['data'].startswith('sell:'): + # User sells DEX tokens to get native coins back + if storage.get('k') is None: + raise Exception("Not initialized") + + parts = msg['data'].split(':') + tokens_to_sell = int(parts[1]) + + sender = msg['sender'] + sender_tokens = storage.get(sender, 0) + if sender_tokens < tokens_to_sell: + raise Exception("Insufficient token balance") + + # Deduct tokens from user + storage[sender] -= tokens_to_sell + + # Calculate how many native coins to give using x * y = k + # (token_reserve + tokens_to_sell) * (native_reserve - coins_out) = k + new_token_reserve = storage['token_reserve'] + tokens_to_sell + new_native_reserve = storage['k'] // new_token_reserve + + coins_out = storage['native_reserve'] - new_native_reserve + if coins_out <= 0: + raise Exception("Not enough coins to dispense") + + # Update reserves + storage['native_reserve'] = new_native_reserve + storage['token_reserve'] = new_token_reserve + + # Wait! In MiniChain, smart contracts cannot arbitrarily initiate outgoing transactions yet. + # To properly implement 'sell', the contract engine would need a 'transfer_out' API. + # For now, we will just record their native coin balance in storage. + storage[f"{sender}_native_credit"] = storage.get(f"{sender}_native_credit", 0) + coins_out + +else: + raise Exception("Unknown command.") diff --git a/examples/stablecoin.py b/examples/stablecoin.py new file mode 100644 index 0000000..ed07e7d --- /dev/null +++ b/examples/stablecoin.py @@ -0,0 +1,41 @@ +# Stablecoin (ERC-20 style) Smart Contract Example +# +# This contract implements a minimal fungible token. +# +# Valid Payloads: +# - 'mint:' +# - 'transfer::' + +if msg['data'].startswith('mint:'): + # In a real contract, you would restrict this to an owner address! + # For this example, anyone can mint tokens to themselves. + amount = int(msg['data'].split(':')[1]) + if amount <= 0: + raise Exception("Amount must be positive") + + sender = msg['sender'] + storage[sender] = storage.get(sender, 0) + amount + storage['total_supply'] = storage.get('total_supply', 0) + amount + +elif msg['data'].startswith('transfer:'): + parts = msg['data'].split(':') + if len(parts) != 3: + raise Exception("Invalid transfer format") + + to_address = parts[1] + amount = int(parts[2]) + + if amount <= 0: + raise Exception("Amount must be positive") + + sender = msg['sender'] + sender_balance = storage.get(sender, 0) + + if sender_balance >= amount: + storage[sender] -= amount + storage[to_address] = storage.get(to_address, 0) + amount + else: + raise Exception("Insufficient token balance") + +else: + raise Exception("Unknown command. Valid commands: mint:, transfer::") diff --git a/main.py b/main.py index 838b3aa..1fd917b 100644 --- a/main.py +++ b/main.py @@ -27,11 +27,11 @@ from minichain import Transaction, Blockchain, Block, State, Mempool, P2PNetwork, mine_block from minichain.validators import is_valid_receiver +from minichain.block import calculate_receipt_root logger = logging.getLogger(__name__) -BURN_ADDRESS = "0" * 40 TRUSTED_PEERS = set() LOCALHOST_PEERS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"} @@ -61,13 +61,17 @@ def mine_and_process_block(chain, mempool, miner_pk): temp_state = chain.state.copy() mineable_txs = [] stale_txs = [] + receipts = [] for tx in pending_txs: expected_nonce = temp_state.get_account(tx.sender).get("nonce", 0) if tx.nonce < expected_nonce: stale_txs.append(tx) continue - if temp_state.validate_and_apply(tx): + + receipt = temp_state.validate_and_apply(tx) + if receipt is not None: mineable_txs.append(tx) + receipts.append(receipt) if stale_txs: mempool.remove_transactions(stale_txs) @@ -76,12 +80,16 @@ def mine_and_process_block(chain, mempool, miner_pk): logger.info("No mineable transactions in current queue window.") return None - temp_state.credit_mining_reward(miner_pk) + total_fees = sum(getattr(r, 'gas_used', 0) for r in receipts) + temp_state.credit_mining_reward(miner_pk, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) block = Block( index=chain.last_block.index + 1, previous_hash=chain.last_block.hash, transactions=mineable_txs, + state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root(receipts), + receipts=receipts, miner=miner_pk, ) @@ -105,7 +113,7 @@ def mine_and_process_block(chain, mempool, miner_pk): # Network message handler # ────────────────────────────────────────────── -def make_network_handler(chain, mempool): +def make_network_handler(chain, mempool, network): """Return an async callback that processes incoming P2P messages.""" async def handler(data): @@ -151,7 +159,33 @@ async def handler(data): # Drop only confirmed transactions so higher nonces can remain queued. mempool.remove_transactions(block.transactions) else: - logger.warning("📥 Received Block #%s — rejected", block.index) + if block.index > chain.last_block.index: + logger.warning("📥 Received Block #%s — ahead of us (tip: %s). Requesting chain sync...", block.index, chain.last_block.index) + asyncio.create_task(network.broadcast_chain_request()) + else: + logger.warning("📥 Received Block #%s — rejected", block.index) + + elif msg_type == "chain_request": + logger.info("📡 Peer requested chain sync. Broadcasting our chain...") + blocks_dicts = [b.to_dict() for b in chain.chain] + payload = {"type": "chain_response", "data": {"blocks": blocks_dicts}} + asyncio.create_task(network._broadcast_raw(payload)) + + elif msg_type == "chain_response": + blocks_payload = payload.get("blocks", []) + new_chain = [] + try: + new_chain = [Block.from_dict(b) for b in blocks_payload] + except Exception as e: + logger.warning("❌ Failed to parse chain_response: %s", e) + return + + if new_chain: + success, orphans = chain.resolve_conflicts(new_chain) + if success: + logger.info("🔄 Reorg complete! Restoring %d orphaned txs to mempool.", len(orphans)) + for tx in orphans: + mempool.add_transaction(tx) return handler @@ -201,12 +235,13 @@ async def cli_loop(sk, pk, chain, mempool, network): print(" (no accounts yet)") for addr, acc in accounts.items(): tag = " (you)" if addr == pk else "" - print(f" {addr[:12]}... balance={acc['balance']} nonce={acc['nonce']}{tag}") + contract_tag = " [Contract]" if acc.get("code") else "" + print(f" {addr[:12]}... balance={acc['balance']} nonce={acc['nonce']}{tag}{contract_tag}") # ── send ── elif cmd == "send": if len(parts) < 3: - print(" Usage: send ") + print(" Usage: send [fee]") continue receiver = parts[1] if not is_valid_receiver(receiver): @@ -214,15 +249,19 @@ async def cli_loop(sk, pk, chain, mempool, network): continue try: amount = int(parts[2]) + fee = int(parts[3]) if len(parts) > 3 else 0 except ValueError: - print(" Amount must be an integer.") + print(" Amount and fee must be integers.") continue if amount <= 0: print(" Amount must be greater than 0.") continue + if fee < 0: + print(" Fee cannot be negative.") + continue nonce = chain.state.get_account(pk).get("nonce", 0) - tx = Transaction(sender=pk, receiver=receiver, amount=amount, nonce=nonce) + tx = Transaction(sender=pk, receiver=receiver, amount=amount, nonce=nonce, fee=fee) tx.sign(sk) if mempool.add_transaction(tx): @@ -231,6 +270,72 @@ async def cli_loop(sk, pk, chain, mempool, network): else: print(" ❌ Transaction rejected (invalid sig, duplicate, or mempool full).") + # ── deploy ── + elif cmd == "deploy": + if len(parts) < 2: + print(" Usage: deploy [amount] [fee]") + continue + filepath = parts[1] + try: + with open(filepath, "r") as f: + code = f.read() + except FileNotFoundError: + print(f" File not found: {filepath}") + continue + + try: + amount = int(parts[2]) if len(parts) > 2 else 0 + fee = int(parts[3]) if len(parts) > 3 else 0 + except ValueError: + print(" Amount and fee must be integers.") + continue + + if amount < 0 or fee < 0: + print(" Amount and fee cannot be negative.") + continue + + nonce = chain.state.get_account(pk).get("nonce", 0) + tx = Transaction(sender=pk, receiver=None, amount=amount, nonce=nonce, fee=fee, data=code) + tx.sign(sk) + + if mempool.add_transaction(tx): + await network.broadcast_transaction(tx) + print(f" ✅ Deploy Tx sent (nonce={nonce}). Mine a block to confirm.") + else: + print(" ❌ Deploy Transaction rejected.") + + # ── call ── + elif cmd == "call": + if len(parts) < 3: + print(" Usage: call [amount] [fee]") + continue + receiver = parts[1] + if not is_valid_receiver(receiver): + print(" Invalid receiver format. Expected 40 or 64 hex characters.") + continue + payload = parts[2] + + try: + amount = int(parts[3]) if len(parts) > 3 else 0 + fee = int(parts[4]) if len(parts) > 4 else 0 + except ValueError: + print(" Amount and fee must be integers.") + continue + + if amount < 0 or fee < 0: + print(" Amount and fee cannot be negative.") + continue + + nonce = chain.state.get_account(pk).get("nonce", 0) + tx = Transaction(sender=pk, receiver=receiver, amount=amount, nonce=nonce, fee=fee, data=payload) + tx.sign(sk) + + if mempool.add_transaction(tx): + await network.broadcast_transaction(tx) + print(f" ✅ Call Tx sent to {receiver[:12]}... (payload='{payload}'). Mine a block to confirm.") + else: + print(" ❌ Call Transaction rejected.") + # ── mine ── elif cmd == "mine": mined = mine_and_process_block(chain, mempool, pk) @@ -310,7 +415,7 @@ async def run_node(port: int, host: str, connect_to: str | None, fund: int, data mempool = Mempool() network = P2PNetwork() - handler = make_network_handler(chain, mempool) + handler = make_network_handler(chain, mempool, network) network.register_handler(handler) # When a new peer connects, send our state so they can sync diff --git a/minichain/block.py b/minichain/block.py index e087c5a..12bc141 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -4,36 +4,36 @@ from collections.abc import Sequence from .transaction import Transaction +from .receipt import Receipt from .serialization import canonical_json_hash, canonical_json_bytes - def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() -# <-- Updated to Sequence to accept the frozen tuple -def _calculate_merkle_root(transactions: Sequence[Transaction]) -> Optional[str]: - if not transactions: +def _calculate_merkle_tree(hashes: Sequence[str]) -> Optional[str]: + if not hashes: return None - - # Hash each transaction deterministically - tx_hashes = [ - tx.tx_id - for tx in transactions - ] - - # Build Merkle tree - while len(tx_hashes) > 1: - if len(tx_hashes) % 2 != 0: - tx_hashes.append(tx_hashes[-1]) # duplicate last if odd - + hashes_list = list(hashes) + while len(hashes_list) > 1: + if len(hashes_list) % 2 != 0: + hashes_list.append(hashes_list[-1]) new_level = [] - for i in range(0, len(tx_hashes), 2): - combined = tx_hashes[i] + tx_hashes[i + 1] + for i in range(0, len(hashes_list), 2): + combined = hashes_list[i] + hashes_list[i + 1] new_level.append(_sha256(combined)) + hashes_list = new_level + return hashes_list[0] - tx_hashes = new_level +# <-- Updated to Sequence to accept the frozen tuple +def _calculate_merkle_root(transactions: Sequence[Transaction]) -> Optional[str]: + if not transactions: + return None + return _calculate_merkle_tree([tx.tx_id for tx in transactions]) - return tx_hashes[0] +def calculate_receipt_root(receipts: Sequence[Receipt]) -> Optional[str]: + if not receipts: + return None + return _calculate_merkle_tree([canonical_json_hash(r.to_dict()) for r in receipts]) class Block: def __init__( @@ -43,12 +43,16 @@ def __init__( transactions: Optional[Sequence[Transaction]] = None, timestamp: Optional[float] = None, difficulty: Optional[int] = None, - miner: Optional[str] = None + state_root: Optional[str] = None, + receipt_root: Optional[str] = None, + receipts: Optional[Sequence[Receipt]] = None, + miner: Optional[str] = None, ): self.index = index self.previous_hash = previous_hash # Freeze transactions into an immutable tuple to prevent header/body mismatch self.transactions = tuple(transactions) if transactions else () + self.receipts = tuple(receipts) if receipts else () self.miner = miner # Deterministic timestamp (ms) self.timestamp: int = ( @@ -60,10 +64,15 @@ def __init__( self.nonce: int = 0 self.hash: Optional[str] = None self.state_root: Optional[str] = state_root + self.receipt_root: Optional[str] = receipt_root self.miner: Optional[str] = miner - # NEW: compute merkle root once + # NEW: compute merkle roots once self.merkle_root: Optional[str] = _calculate_merkle_root(self.transactions) + + # If receipt_root is missing but we have receipts, calculate it. + if self.receipt_root is None and self.receipts: + self.receipt_root = calculate_receipt_root(self.receipts) # ------------------------- # HEADER (used for mining) @@ -74,10 +83,10 @@ def to_header_dict(self): "previous_hash": self.previous_hash, "merkle_root": self.merkle_root, "state_root": self.state_root, + "receipt_root": self.receipt_root, "timestamp": self.timestamp, "difficulty": self.difficulty, "nonce": self.nonce, - "miner": self.miner, } # Include miner in header only when present (optional field) <-- Reworded comment if self.miner is not None: @@ -91,6 +100,9 @@ def to_body_dict(self): return { "transactions": [ tx.to_dict() for tx in self.transactions + ], + "receipts": [ + r.to_dict() for r in self.receipts ] } @@ -115,6 +127,10 @@ def from_dict(cls, payload: dict): Transaction.from_dict(tx_payload) for tx_payload in payload.get("transactions", []) ] + receipts = [ + Receipt.from_dict(r_payload) + for r_payload in payload.get("receipts", []) + ] # Safely extract and cast difficulty if it exists raw_diff = payload.get("difficulty") @@ -123,13 +139,15 @@ def from_dict(cls, payload: dict): # Safely extract and cast timestamp if it exists <-- Added explicit timestamp casting raw_ts = payload.get("timestamp") parsed_ts = int(raw_ts) if raw_ts is not None else None - block = cls( index=int(payload["index"]), previous_hash=payload["previous_hash"], transactions=transactions, timestamp=parsed_ts, # <-- Passed the casted timestamp difficulty=parsed_diff, + state_root=payload.get("state_root"), + receipt_root=payload.get("receipt_root"), + receipts=receipts, miner=payload.get("miner"), ) block.nonce = int(payload.get("nonce", 0)) @@ -143,6 +161,12 @@ def from_dict(cls, payload: dict): # Recalculate and verify the Merkle root! if "merkle_root" in payload and payload["merkle_root"] != block.merkle_root: raise ValueError("merkle_root does not match transactions") + + if "receipt_root" in payload: + expected_receipt_root = calculate_receipt_root(block.receipts) + if payload["receipt_root"] != expected_receipt_root: + raise ValueError("receipt_root does not match receipts") + return block @property diff --git a/minichain/chain.py b/minichain/chain.py index c7fe286..af7a43d 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -1,4 +1,4 @@ -from .block import Block +from .block import Block, calculate_receipt_root from .state import State from .pow import calculate_hash import logging @@ -47,10 +47,10 @@ def _create_genesis_block(self, genesis_path): with open(genesis_path, "r") as f: config = json.load(f) except Exception as e: - logger.error(f"Failed to load genesis config: {e}") + logger.error("Failed to load genesis config: %s", e) sys.exit(1) else: - logger.error(f"Failed to load genesis config: file {genesis_path} does not exist.") + logger.error("Failed to load genesis config: file %s does not exist.", genesis_path) sys.exit(1) # Apply genesis allocations @@ -58,7 +58,7 @@ def _create_genesis_block(self, genesis_path): for address, data in alloc.items(): balance = data.get("balance", 0) if not isinstance(balance, int) or balance < 0: - logger.error(f"Invalid genesis balance for {address}: {balance}. Must be a non-negative integer.") + logger.error("Invalid genesis balance for %s: %s. Must be a non-negative integer.", address, balance) sys.exit(1) account = self.state.get_account(address) account['balance'] = balance @@ -72,7 +72,9 @@ def _create_genesis_block(self, genesis_path): transactions=[], timestamp=timestamp, difficulty=difficulty, - state_root=self.state.state_root() + state_root=self.state.state_root(), + receipt_root=None, + receipts=[] ) computed_hash = calculate_hash(genesis_block.to_header_dict()) @@ -80,13 +82,16 @@ def _create_genesis_block(self, genesis_path): if config_hash: if config_hash != computed_hash: - logger.error(f"Genesis hash mismatch. Config hash: {config_hash}, Computed hash: {computed_hash}") + logger.error("Genesis hash mismatch. Config hash: %s, Computed hash: %s", config_hash, computed_hash) sys.exit(1) genesis_block.hash = config_hash else: genesis_block.hash = computed_hash self.chain.append(genesis_block) + + # Snapshot the state exactly after genesis allocation for clean reorg rebuilds + self._genesis_state_snapshot = self.state.snapshot() @property def last_block(self): @@ -96,6 +101,16 @@ def last_block(self): with self._lock: # Acquire lock for thread-safe access return self.chain[-1] + def get_total_work(self, chain_list=None): + """ + Calculates the cumulative PoW of a chain. + Work is proportional to 2^difficulty. + """ + if chain_list is None: + with self._lock: + chain_list = self.chain + return sum(2 ** (block.difficulty or 1) for block in chain_list) + def add_block(self, block): """ Validates and adds a block to the chain if all transactions succeed. @@ -111,17 +126,30 @@ def add_block(self, block): # Validate transactions on a temporary state copy temp_state = self.state.copy() + receipts = [] for tx in block.transactions: - result = temp_state.validate_and_apply(tx) + receipt = temp_state.validate_and_apply(tx) - # Reject block if any transaction fails - if not result: + # Reject block if any transaction fails mathematical validation (None) + if receipt is None: logger.warning("Block %s rejected: Transaction failed validation", block.index) return False + + receipts.append(receipt) + total_fees = sum(getattr(r, 'gas_used', 0) for r in receipts) if block.miner: - temp_state.credit_mining_reward(block.miner) + temp_state.credit_mining_reward(block.miner, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) + + computed_receipt_root = calculate_receipt_root(receipts) + if block.receipt_root != computed_receipt_root: + logger.warning("Block %s rejected: Invalid receipt root. Expected %s, got %s", block.index, computed_receipt_root, block.receipt_root) + return False + + if [r.to_dict() for r in block.receipts] != [r.to_dict() for r in receipts]: + logger.warning("Block %s rejected: Receipts payload mismatch", block.index) + return False # Verify state root if block.state_root != temp_state.state_root(): @@ -132,3 +160,77 @@ def add_block(self, block): self.state = temp_state self.chain.append(block) return True + + def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: + """ + Evaluates a competing chain. If it has strictly greater cumulative work, + attempts a reorg. Rebuilds state from genesis to guarantee validity. + Returns: (success_bool, list_of_orphaned_transactions) + """ + if not new_chain_list: + return False, [] + + with self._lock: + current_work = self.get_total_work() + new_work = self.get_total_work(new_chain_list) + + if new_work <= current_work: + logger.debug("Incoming chain (work: %s) is not heavier than local chain (work: %s). Rejecting.", new_work, current_work) + return False, [] + + # 1. Verify genesis block matches + if new_chain_list[0].hash != self.chain[0].hash: + logger.warning("Reorg failed: Genesis hash mismatch.") + return False, [] + + logger.info("Incoming chain is heavier (%s > %s). Attempting reorg...", new_work, current_work) + + # 2. Snapshot current state and chain in case reorg fails validation + state_snapshot = self.state.snapshot() + original_chain = list(self.chain) + + # 3. Rebuild state entirely from genesis using the new chain + temp_state = State() + temp_state.restore(self._genesis_state_snapshot) + + # Verify and apply blocks 1 to N + for i in range(1, len(new_chain_list)): + prev_block = new_chain_list[i-1] + block = new_chain_list[i] + + try: + validate_block_link_and_hash(prev_block, block) + except ValueError as exc: + logger.warning("Reorg failed at block %s: %s", block.index, exc) + return False, [] + + receipts = [] + for tx in block.transactions: + receipt = temp_state.validate_and_apply(tx) + if receipt is None: + logger.warning("Reorg failed: Transaction validation failed in block %s", block.index) + return False, [] + receipts.append(receipt) + + total_fees = sum(getattr(r, 'gas_used', 0) for r in receipts) + if block.miner: + temp_state.credit_mining_reward(block.miner, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) + + computed_receipt_root = calculate_receipt_root(receipts) + if block.receipt_root != computed_receipt_root: + logger.warning("Reorg failed: Invalid receipt root at block %s. Expected %s, got %s", block.index, computed_receipt_root, block.receipt_root) + return False, [] + + if block.state_root != temp_state.state_root(): + logger.warning("Reorg failed: Invalid state root at block %s", block.index) + return False, [] + + # 4. Success! Compute orphaned transactions. + old_txs = {tx.tx_id: tx for b in original_chain[1:] for tx in b.transactions} + new_tx_ids = {tx.tx_id for b in new_chain_list[1:] for tx in b.transactions} + orphans = [tx for tx_id, tx in old_txs.items() if tx_id not in new_tx_ids] + + self.chain = new_chain_list + self.state = temp_state + logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index) + return True, orphans diff --git a/minichain/contract.py b/minichain/contract.py index c88a20f..4ad8233 100644 --- a/minichain/contract.py +++ b/minichain/contract.py @@ -1,13 +1,37 @@ import logging import multiprocessing import ast +import sys + +class OutOfGasException(Exception): + pass + +class GasMeter: + def __init__(self, limit): + self.gas = limit + self.initial_gas = limit + + def trace_calls(self, frame, event, arg): + frame.f_trace_opcodes = True + if event == 'opcode': + self.gas -= 1 + if self.gas <= 0: + raise OutOfGasException("Out of gas!") + return self.trace_calls import json # Moved to module-level import logger = logging.getLogger(__name__) -def _safe_exec_worker(code, globals_dict, context_dict, result_queue): +def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit): """ - Worker function to execute contract code in a separate process. + 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) @@ -21,29 +45,52 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue): except (OSError, ValueError) as e: logger.warning("Failed to set resource limits: %s", e) - exec(code, globals_dict, context_dict) - # Return the updated storage - result_queue.put({"status": "success", "storage": context_dict.get("storage")}) + 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 + result_queue.put({"status": "success", "storage": context_dict.get("storage"), "gas_used": gas_used}) + except OutOfGasException as e: + result_queue.put({"status": "error", "error": "Out of gas!", "gas_used": gas_limit}) except Exception as e: - result_queue.put({"status": "error", "error": str(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}) class ContractMachine: """ A minimal execution environment for Python-based smart contracts. WARNING: Still not production-safe. For educational use only. + + SANDBOX ENFORCEMENT: + 1. Builtins Restriction: `__builtins__` is aggressively filtered. Functions like + `open`, `exec`, `eval`, `__import__`, `print`, and `input` are completely removed. + This inherently prevents file deletion, network requests, or OS command execution. + 2. AST Validation: `_validate_code_ast` statically analyzes the code before execution + to block double-underscore access (preventing sandbox escape via introspection) + and entirely blocks the `import` statement. + + Allowed Builtins: range(), len(), min(), max(), abs(), str(), bool(), float(), int(), list(), dict(), tuple(), sum(), Exception + Blocked Builtins: Imports, File IO (open), OS modules, Networking, Introspection. """ def __init__(self, state): self.state = state - def execute(self, contract_address, sender_address, payload, amount): + def execute(self, contract_address, sender_address, payload, amount, gas_limit): """ Executes the contract code associated with the contract_address. + Returns a dict: {"success": bool, "gas_used": int, "error": str} """ account = self.state.get_account(contract_address) if not account: - return False + return {"success": False, "gas_used": 0, "error": "Account not found"} code = account.get("code") @@ -51,11 +98,11 @@ def execute(self, contract_address, sender_address, payload, amount): storage = dict(account.get("storage", {})) if not code: - return False + return {"success": False, "gas_used": 0, "error": "No code"} # AST Validation to prevent introspection if not self._validate_code_ast(code): - return False + return {"success": False, "gas_used": 0, "error": "AST Validation Failed"} # Restricted builtins (explicit allowlist) safe_builtins = { @@ -97,7 +144,7 @@ def execute(self, contract_address, sender_address, payload, amount): queue = multiprocessing.Queue() p = multiprocessing.Process( target=_safe_exec_worker, - args=(code, globals_for_exec, context, queue) + args=(code, globals_for_exec, context, queue, gas_limit) ) p.start() p.join(timeout=2) # 2 second timeout @@ -106,23 +153,24 @@ def execute(self, contract_address, sender_address, payload, amount): p.kill() p.join() logger.error("Contract execution timed out") - return False + return {"success": False, "gas_used": gas_limit, "error": "Execution timed out"} try: result = queue.get(timeout=1) except Exception: logger.error("Contract execution crashed without result") - return False + return {"success": False, "gas_used": gas_limit, "error": "Crashed"} + if result["status"] != "success": - logger.error(f"Contract Execution Failed: {result.get('error')}") - return False + logger.error("Contract Execution Failed: %s", result.get('error')) + return {"success": False, "gas_used": result.get("gas_used", gas_limit), "error": result.get('error')} # Validate storage is JSON serializable try: json.dumps(result["storage"]) except (TypeError, ValueError): logger.error("Contract storage not JSON serializable") - return False + return {"success": False, "gas_used": result.get("gas_used", gas_limit), "error": "Storage not JSON serializable"} # Commit updated storage only after successful execution self.state.update_contract_storage( @@ -130,11 +178,11 @@ def execute(self, contract_address, sender_address, payload, amount): result["storage"] ) - return True + return {"success": True, "gas_used": result["gas_used"], "error": None} except Exception as e: logger.error("Contract Execution Failed", exc_info=True) - return False + return {"success": False, "gas_used": gas_limit, "error": "System Error"} def _validate_code_ast(self, code): """Reject code that uses double underscores or introspection.""" @@ -155,7 +203,7 @@ def _validate_code_ast(self, code): logger.warning("Rejected type() call.") return False if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in {"getattr", "setattr", "delattr"}: - logger.warning(f"Rejected direct call to {node.func.id}.") + logger.warning("Rejected direct call to %s.", node.func.id) return False if isinstance(node, ast.Constant) and isinstance(node.value, str): if "__" in node.value: diff --git a/minichain/mempool.py b/minichain/mempool.py index 4b71e08..6e3d8d9 100644 --- a/minichain/mempool.py +++ b/minichain/mempool.py @@ -51,7 +51,9 @@ def get_transactions_for_block(self): for sender, txs in snapshot.items(): if txs: - if best_tx is None or (txs[0].timestamp, sender, txs[0].nonce) < (best_tx.timestamp, best_sender, best_tx.nonce): + current_criteria = (-getattr(txs[0], 'fee', 0), txs[0].timestamp, sender, txs[0].nonce) + best_criteria = (-getattr(best_tx, 'fee', 0), best_tx.timestamp, best_sender, best_tx.nonce) if best_tx else None + if best_tx is None or current_criteria < best_criteria: best_tx = txs[0] best_sender = sender diff --git a/minichain/mpt.py b/minichain/mpt.py index ee30b8d..019ed50 100644 --- a/minichain/mpt.py +++ b/minichain/mpt.py @@ -1,164 +1,24 @@ -import hashlib -import json -from typing import Optional, List +from typing import Optional +from trie import HexaryTrie -def hash_data(data: bytes) -> bytes: - return hashlib.sha256(data).digest() - -def to_nibbles(key_hex: str) -> List[int]: - """Converts a hex string key into a list of integer nibbles (0-15).""" - try: - return [int(c, 16) for c in key_hex] - except ValueError: - raise ValueError(f"Invalid MPT key: '{key_hex}'. Keys must be valid hex strings.") - -class Node: - def hash(self) -> bytes: - raise NotImplementedError - -class LeafNode(Node): - def __init__(self, path: List[int], value: str): - self.path = path - self.value = value - - def hash(self) -> bytes: - data = json.dumps({"type": "leaf", "path": self.path, "value": self.value}, sort_keys=True) - return hash_data(data.encode()) - -class ExtensionNode(Node): - def __init__(self, path: List[int], child: Node): - self.path = path - self.child = child - - def hash(self) -> bytes: - child_hash = self.child.hash().hex() - data = json.dumps({"type": "extension", "path": self.path, "child": child_hash}, sort_keys=True) - return hash_data(data.encode()) - -class BranchNode(Node): - def __init__(self): - self.branches: List[Optional[Node]] = [None] * 16 - self.value: Optional[str] = None - - def hash(self) -> bytes: - b_hashes = [b.hash().hex() if b else None for b in self.branches] - data = json.dumps({"type": "branch", "branches": b_hashes, "value": self.value}, sort_keys=True) - return hash_data(data.encode()) class Trie: """ - A simplified Merkle Patricia Trie (MPT) for MiniChain. + A Merkle Patricia Trie (MPT) for MiniChain backed by the `trie` library. Provides O(log N) state verification via cryptographic state roots. """ def __init__(self): - self.root: Optional[Node] = None + self._trie = HexaryTrie({}) def root_hash(self) -> str: """Returns the 32-byte hex hash of the trie's root.""" - if not self.root: - return "0" * 64 - return self.root.hash().hex() + return self._trie.root_hash.hex() def get(self, key_hex: str) -> Optional[str]: - if not self.root: - return None - return self._get(self.root, to_nibbles(key_hex)) - - def _get(self, node: Optional[Node], path: List[int]) -> Optional[str]: - if not node: - return None - - if isinstance(node, LeafNode): - if node.path == path: - return node.value - return None - - elif isinstance(node, ExtensionNode): - if path[:len(node.path)] == node.path: - return self._get(node.child, path[len(node.path):]) - return None - - elif isinstance(node, BranchNode): - if not path: - return node.value - nibble = path[0] - return self._get(node.branches[nibble], path[1:]) - - return None + key = bytes.fromhex(key_hex) + val = self._trie.get(key) + return val.decode() if val is not None else None def put(self, key_hex: str, value: str): - path = to_nibbles(key_hex) - self.root = self._put(self.root, path, value) - - def _put(self, node: Optional[Node], path: List[int], value: str) -> Node: - if node is None: - return LeafNode(path, value) - - if isinstance(node, LeafNode): - if node.path == path: - node.value = value - return node - - # Paths diverge. Find common prefix. - common = 0 - while common < len(node.path) and common < len(path) and node.path[common] == path[common]: - common += 1 - - branch = BranchNode() - - # Handle the leaf's remaining path - leaf_remaining = node.path[common:] - if not leaf_remaining: - branch.value = node.value - else: - branch.branches[leaf_remaining[0]] = LeafNode(leaf_remaining[1:], node.value) - - # Handle the new value's remaining path - new_remaining = path[common:] - if not new_remaining: - branch.value = value - else: - branch.branches[new_remaining[0]] = LeafNode(new_remaining[1:], value) - - if common > 0: - return ExtensionNode(node.path[:common], branch) - return branch - - elif isinstance(node, ExtensionNode): - common = 0 - while common < len(node.path) and common < len(path) and node.path[common] == path[common]: - common += 1 - - if common == len(node.path): - # Path matches extension exactly, continue to child - node.child = self._put(node.child, path[common:], value) - return node - - # Divergence inside the extension node - branch = BranchNode() - ext_remaining = node.path[common:] - - # The child of the extension becomes a branch's branch - if len(ext_remaining) == 1: - branch.branches[ext_remaining[0]] = node.child - else: - branch.branches[ext_remaining[0]] = ExtensionNode(ext_remaining[1:], node.child) - - # Insert the new value - new_remaining = path[common:] - if not new_remaining: - branch.value = value - else: - branch.branches[new_remaining[0]] = LeafNode(new_remaining[1:], value) - - if common > 0: - return ExtensionNode(node.path[:common], branch) - return branch - - elif isinstance(node, BranchNode): - if not path: - node.value = value - else: - nibble = path[0] - node.branches[nibble] = self._put(node.branches[nibble], path[1:], value) - return node + key = bytes.fromhex(key_hex) + self._trie.set(key, value.encode()) diff --git a/minichain/p2p.py b/minichain/p2p.py index cb65c2e..262d102 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) TOPIC = "minichain-global" -SUPPORTED_MESSAGE_TYPES = {"sync", "tx", "block"} +SUPPORTED_MESSAGE_TYPES = {"sync", "tx", "block", "chain_request", "chain_response"} class P2PNetwork: @@ -118,6 +118,7 @@ def _validate_transaction_payload(self, payload): required_fields = { "sender": str, "amount": int, + "fee": int, "nonce": int, "timestamp": int, "signature": str, @@ -139,7 +140,7 @@ def _validate_transaction_payload(self, payload): if not isinstance(payload.get(field), expected_type): return False - if payload["amount"] <= 0: + if payload["amount"] < 0: return False receiver = payload.get("receiver") @@ -182,6 +183,8 @@ def _validate_block_payload(self, payload): "previous_hash": str, "merkle_root": (str, type(None)), "state_root": str, + "receipt_root": (str, type(None)), + "receipts": list, "transactions": list, "timestamp": int, "difficulty": (int, type(None)), @@ -204,11 +207,42 @@ def _validate_block_payload(self, payload): if "miner" in payload and not isinstance(payload["miner"], (str, type(None))): return False + for r_payload in payload.get("receipts", []): + if not isinstance(r_payload, dict): + return False + if "tx_hash" not in r_payload or not isinstance(r_payload["tx_hash"], str): + return False + if "status" not in r_payload or not isinstance(r_payload["status"], int): + return False + if "gas_used" in r_payload and not isinstance(r_payload["gas_used"], int): + return False + if "error_message" in r_payload and not isinstance(r_payload["error_message"], (str, type(None))): + return False + if "logs" in r_payload and not isinstance(r_payload["logs"], list): + return False + if "contract_address" in r_payload and not isinstance(r_payload["contract_address"], (str, type(None))): + return False + return all( self._validate_transaction_payload(tx_payload) for tx_payload in payload["transactions"] ) + def _validate_chain_request(self, payload): + if not isinstance(payload, dict): + return False + return True + + def _validate_chain_response(self, payload): + if not isinstance(payload, dict) or "blocks" not in payload: + return False + if not isinstance(payload["blocks"], list): + return False + for block_payload in payload["blocks"]: + if not self._validate_block_payload(block_payload): + return False + return True + def _validate_message(self, message): # FIX: Check if message is a dictionary first to prevent crashes if not isinstance(message, dict): @@ -230,6 +264,8 @@ def _validate_message(self, message): "sync": self._validate_sync_payload, "tx": self._validate_transaction_payload, "block": self._validate_block_payload, + "chain_request": self._validate_chain_request, + "chain_response": self._validate_chain_response, } return validators[msg_type](payload) @@ -366,6 +402,21 @@ async def broadcast_block(self, block): self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) + async def broadcast_chain_request(self): + logger.info("Network: Broadcasting chain request") + payload = {"type": "chain_request", "data": {}} + await self._broadcast_raw(payload) + + async def send_chain_response(self, blocks_dicts, writer): + logger.info("Network: Sending chain response with %d blocks", len(blocks_dicts)) + payload = {"type": "chain_response", "data": {"blocks": blocks_dicts}} + line = (canonical_json_dumps(payload) + "\n").encode() + try: + writer.write(line) + await writer.drain() + except Exception as e: + logger.error("Network: Failed to send chain response: %s", e) + @property def peer_count(self) -> int: return len(self._peers) diff --git a/minichain/persistence.py b/minichain/persistence.py index 6fa1fd6..d142879 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -93,7 +93,7 @@ def load(path: str = ".") -> Blockchain: if not isinstance(raw_block, dict): raise ValueError(f"Invalid chain data in '{path}'") try: - blocks.append(_deserialize_block(raw_block)) + blocks.append(Block.from_dict(raw_block)) except (KeyError, TypeError, ValueError) as exc: raise ValueError(f"Invalid chain data in '{path}'") from exc @@ -267,10 +267,4 @@ def _read_legacy_json(filepath: str) -> dict[str, Any]: return json.load(f) -# --------------------------------------------------------------------------- -# Block deserialisation -# --------------------------------------------------------------------------- - -def _deserialize_block(data: dict[str, Any]) -> Block: - return Block.from_dict(data) diff --git a/minichain/receipt.py b/minichain/receipt.py new file mode 100644 index 0000000..60053c9 --- /dev/null +++ b/minichain/receipt.py @@ -0,0 +1,34 @@ +from typing import List, Optional + +class Receipt: + """ + Represents the execution result of a transaction. + """ + def __init__(self, tx_hash: str, status: int, gas_used: int = 0, error_message: Optional[str] = None, logs: Optional[List[dict]] = None, contract_address: Optional[str] = None): + self.tx_hash = tx_hash + self.status = status # 1 for success, 0 for failure + self.gas_used = gas_used + self.error_message = error_message + self.logs = logs or [] + self.contract_address = contract_address + + def to_dict(self) -> dict: + return { + "tx_hash": self.tx_hash, + "status": self.status, + "gas_used": self.gas_used, + "error_message": self.error_message, + "logs": self.logs, + "contract_address": self.contract_address + } + + @classmethod + def from_dict(cls, payload: dict) -> 'Receipt': + return cls( + tx_hash=payload["tx_hash"], + status=payload["status"], + gas_used=payload.get("gas_used", 0), + error_message=payload.get("error_message"), + logs=payload.get("logs", []), + contract_address=payload.get("contract_address") + ) diff --git a/minichain/state.py b/minichain/state.py index e718534..73759f8 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -1,8 +1,11 @@ +import copy +import json +import logging from nacl.hash import sha256 from nacl.encoding import HexEncoder from .contract import ContractMachine -import copy -import logging +from .mpt import Trie +from .receipt import Receipt logger = logging.getLogger(__name__) @@ -18,8 +21,6 @@ def state_root(self) -> str: Dynamically builds the Merkle Patricia Trie from the current state dictionary and returns the cryptographic state root hash. """ - import json - from .mpt import Trie trie = Trie() # Sort items to ensure deterministic insertion order if necessary (though MPT is order-independent) for addr, acc in sorted(self.accounts.items()): @@ -42,17 +43,18 @@ def get_account(self, address): def verify_transaction_logic(self, tx): if not tx.verify(): - logger.error(f"Error: Invalid signature for tx from {tx.sender[:8]}...") + logger.error("Error: Invalid signature for tx from %s...", tx.sender[:8]) return False sender_acc = self.get_account(tx.sender) - if sender_acc['balance'] < tx.amount: - logger.error(f"Error: Insufficient balance for {tx.sender[:8]}...") + total_cost = tx.amount + getattr(tx, 'fee', 0) + if sender_acc['balance'] < total_cost: + logger.warning("Invalid tx %s: insufficient balance", tx.tx_id) return False if sender_acc['nonce'] != tx.nonce: - logger.error(f"Error: Invalid nonce. Expected {sender_acc['nonce']}, got {tx.nonce}") + logger.error("Error: Invalid nonce. Expected %s, got %s", sender_acc['nonce'], tx.nonce) return False return True @@ -65,6 +67,18 @@ def copy(self): new_state.contract_machine = ContractMachine(new_state) # Reinitialize contract_machine return new_state + def snapshot(self): + """ + Returns a deep copy of the current accounts dictionary for rollback safety. + """ + return copy.deepcopy(self.accounts) + + def restore(self, snapshot_data): + """ + Restores the state's accounts dictionary from a snapshot. + """ + self.accounts = copy.deepcopy(snapshot_data) + def validate_and_apply(self, tx): """ Validate and apply a transaction. @@ -74,76 +88,81 @@ def validate_and_apply(self, tx): """ # Semantic validation: amount must be an integer and non-negative if not isinstance(tx.amount, int) or tx.amount < 0: - return False + return None # Further checks can be added here return self.apply_transaction(tx) def apply_transaction(self, tx): """ Applies transaction and mutates state. - Returns: - - Contract address (str) if deployment - - True if successful execution - - False if failed + Returns: Receipt object if mathematically valid, None if invalid. """ if not self.verify_transaction_logic(tx): - return False + return None sender = self.accounts[tx.sender] + total_cost = tx.amount + getattr(tx, 'fee', 0) + # Deduct funds and increment nonce - sender['balance'] -= tx.amount + sender['balance'] -= total_cost sender['nonce'] += 1 # LOGIC BRANCH 1: Contract Deployment if tx.receiver is None or tx.receiver == "": contract_address = self.derive_contract_address(tx.sender, tx.nonce) + gas_used = getattr(tx, 'fee', 0) # Prevent redeploy collision existing = self.accounts.get(contract_address) if existing and existing.get("code"): - # Restore sender state on failure + # Restore sender balance on failure, but keep nonce incremented sender['balance'] += tx.amount - sender['nonce'] -= 1 - return False + return Receipt(tx.tx_id, status=0, error_message="Contract collision", gas_used=gas_used) - return self.create_contract(contract_address, tx.data, initial_balance=tx.amount) + 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) # 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, 'fee', 0) # Fail if contract does not exist or has no code if not receiver or not receiver.get("code"): - # Rollback sender balance and nonce on failure + # Rollback sender balance on failure, but keep nonce incremented sender['balance'] += tx.amount # Refund amount - sender['nonce'] -= 1 - return False + return Receipt(tx.tx_id, status=0, error_message="Contract not found", gas_used=gas_limit) # Credit contract balance receiver['balance'] += tx.amount - success = self.contract_machine.execute( - contract_address=tx.receiver, # Pass receiver as contract_address + result = self.contract_machine.execute( + contract_address=tx.receiver, sender_address=tx.sender, payload=tx.data, - amount=tx.amount + amount=tx.amount, + gas_limit=gas_limit ) - if not success: - # Rollback transfer and nonce if execution fails + gas_used = result.get("gas_used", gas_limit) + gas_refund = gas_limit - gas_used + if gas_refund > 0: + sender['balance'] += gas_refund + + if not result.get("success"): + # Rollback transfer if execution fails, but keep nonce incremented receiver['balance'] -= tx.amount sender['balance'] += tx.amount # Refund amount - sender['nonce'] -= 1 - return False + return Receipt(tx.tx_id, status=0, error_message=result.get("error", "Execution failed"), gas_used=gas_used) - return True + return Receipt(tx.tx_id, status=1, gas_used=gas_used) # LOGIC BRANCH 3: Regular Transfer receiver = self.get_account(tx.receiver) receiver['balance'] += tx.amount - return True + return Receipt(tx.tx_id, status=1, gas_used=getattr(tx, 'fee', 0)) def derive_contract_address(self, sender, nonce): raw = f"{sender}:{nonce}".encode() diff --git a/minichain/transaction.py b/minichain/transaction.py index 4abe268..ca282ea 100644 --- a/minichain/transaction.py +++ b/minichain/transaction.py @@ -6,7 +6,7 @@ class Transaction: - _TX_FIELDS = frozenset({"sender", "receiver", "amount", "nonce", "data", "timestamp", "signature"}) + _TX_FIELDS = frozenset({"sender", "receiver", "amount", "fee", "nonce", "data", "timestamp", "signature"}) def __setattr__(self, name, value) -> None: if name in self._TX_FIELDS and getattr(self, "_sealed", False): @@ -23,10 +23,11 @@ def _normalize_ts(ts) -> int: # If it's already in milliseconds (>= 1e12), just ensure it's an integer return int(ts) - def __init__(self, sender, receiver, amount, nonce, data=None, signature=None, timestamp=None): + def __init__(self, sender, receiver, amount, nonce, fee=0, data=None, signature=None, timestamp=None): self.sender = sender self.receiver = receiver self.amount = amount + self.fee = fee self.nonce = nonce self.data = data self.timestamp = self._normalize_ts(timestamp) if timestamp is not None else round(time.time() * 1000) @@ -35,18 +36,18 @@ def __init__(self, sender, receiver, amount, nonce, data=None, signature=None, t self._sealed = False def to_dict(self): - return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, + return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, "fee": self.fee, "nonce": self.nonce, "data": self.data, "timestamp": self.timestamp, "signature": self.signature} def to_signing_dict(self): - return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, + return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, "fee": self.fee, "nonce": self.nonce, "data": self.data, "timestamp": self.timestamp} @classmethod def from_dict(cls, payload: dict): return cls(sender=payload["sender"], receiver=payload.get("receiver"), - amount=payload["amount"], nonce=payload["nonce"], + amount=payload["amount"], nonce=payload["nonce"], fee=payload["fee"], data=payload.get("data"), signature=payload.get("signature"), timestamp=payload.get("timestamp")) diff --git a/requirements.txt b/requirements.txt index 819e170..99cd065 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ pynacl==1.6.2 -libp2p==0.5.0 +trie>=3.1.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 1edff7b..0000000 --- a/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="minichain", - version="0.1.0", - packages=find_packages(), - py_modules=["main"], - install_requires=[ - "PyNaCl>=1.5.0", - "libp2p>=0.5.0", # Correct PyPI package name - ], - entry_points={ - "console_scripts": [ - "minichain=main:main", - ], - }, - python_requires=">=3.9", -) diff --git a/tests/test_contract.py b/tests/test_contract.py index 2ac6e9f..c8a5571 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -13,7 +13,7 @@ def setUp(self): self.state = State() self.sk = SigningKey.generate() self.pk = self.sk.verify_key.encode(encoder=HexEncoder).decode() - self.state.credit_mining_reward(self.pk, 100) + self.state.credit_mining_reward(self.pk, 10000) def test_deploy_and_execute(self): """Happy path: deploy and increment counter.""" @@ -23,17 +23,21 @@ def test_deploy_and_execute(self): storage['counter'] = storage.get('counter', 0) + 1 """ - tx_deploy = Transaction(self.pk, None, 0, 0, data=code) + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) tx_deploy.sign(self.sk) - contract_addr = self.state.apply_transaction(tx_deploy) + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertIsNotNone(receipt_deploy) + self.assertEqual(receipt_deploy.status, 1) + contract_addr = receipt_deploy.contract_address self.assertTrue(isinstance(contract_addr, str)) - tx_call = Transaction(self.pk, contract_addr, 0, 1, data="increment") + tx_call = Transaction(self.pk, contract_addr, 0, 1, fee=1000, data="increment") tx_call.sign(self.sk) - success = self.state.apply_transaction(tx_call) - self.assertTrue(success) + receipt_call = self.state.apply_transaction(tx_call) + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 1) contract_acc = self.state.get_account(contract_addr) self.assertEqual(contract_acc["storage"]["counter"], 1) @@ -46,11 +50,12 @@ def test_deploy_insufficient_balance(self): code = "storage['x'] = 1" - tx = Transaction(poor_pk, None, 1000, 0, data=code) + tx = Transaction(poor_pk, None, 1000, 0, fee=500, data=code) tx.sign(poor_sk) - result = self.state.apply_transaction(tx) - self.assertFalse(result) + receipt = self.state.apply_transaction(tx) + # deploy with insufficient balance should fail mathematical validation entirely + self.assertIsNone(receipt) def test_call_non_existent_contract(self): """Calling unknown contract should fail with valid hex receiver.""" @@ -58,11 +63,13 @@ def test_call_non_existent_contract(self): fake_sk = SigningKey.generate() fake_receiver = fake_sk.verify_key.encode(encoder=HexEncoder).decode() - tx = Transaction(self.pk, fake_receiver, 0, 0, data="increment") + tx = Transaction(self.pk, fake_receiver, 0, 0, fee=500, data="increment") tx.sign(self.sk) - result = self.state.apply_transaction(tx) - self.assertFalse(result) + receipt = self.state.apply_transaction(tx) + self.assertIsNotNone(receipt) + self.assertEqual(receipt.status, 0) + self.assertEqual(receipt.error_message, "Contract not found") def test_contract_runtime_exception(self): """Contract raising exception should fail and not mutate storage.""" @@ -71,17 +78,22 @@ def test_contract_runtime_exception(self): raise Exception("boom") """ - tx_deploy = Transaction(self.pk, None, 0, 0, data=code) + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) tx_deploy.sign(self.sk) - contract_addr = self.state.apply_transaction(tx_deploy) + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertIsNotNone(receipt_deploy) + self.assertEqual(receipt_deploy.status, 1) + contract_addr = receipt_deploy.contract_address self.assertTrue(isinstance(contract_addr, str)) - tx_call = Transaction(self.pk, contract_addr, 0, 1, data="anything") + tx_call = Transaction(self.pk, contract_addr, 0, 1, fee=1000, data="anything") tx_call.sign(self.sk) - result = self.state.apply_transaction(tx_call) - self.assertFalse(result) + receipt_call = self.state.apply_transaction(tx_call) + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 0) + self.assertEqual(receipt_call.error_message, "boom") contract_acc = self.state.get_account(contract_addr) self.assertEqual(contract_acc["storage"], {}) @@ -92,10 +104,13 @@ def test_redeploy_same_address(self): code = "storage['x'] = 1" # First deploy - tx1 = Transaction(self.pk, None, 0, 0, data=code) + tx1 = Transaction(self.pk, None, 0, 0, fee=500, data=code) tx1.sign(self.sk) - addr = self.state.apply_transaction(tx1) + receipt1 = self.state.apply_transaction(tx1) + self.assertIsNotNone(receipt1) + self.assertEqual(receipt1.status, 1) + addr = receipt1.contract_address self.assertTrue(isinstance(addr, str)) # Compute the address that a second deploy would use @@ -106,11 +121,13 @@ def test_redeploy_same_address(self): self.state.create_contract(collision_addr, "storage['y'] = 2") # Attempt redeploy - tx2 = Transaction(self.pk, None, 0, next_nonce, data=code) + tx2 = Transaction(self.pk, None, 0, next_nonce, fee=500, data=code) tx2.sign(self.sk) - result = self.state.apply_transaction(tx2) - self.assertFalse(result) + receipt2 = self.state.apply_transaction(tx2) + self.assertIsNotNone(receipt2) + self.assertEqual(receipt2.status, 0) + self.assertEqual(receipt2.error_message, "Contract collision") def test_balance_and_nonce_updates(self): """Verify sender balance and nonce after deploy and call.""" @@ -121,16 +138,81 @@ def test_balance_and_nonce_updates(self): code = "storage['x'] = 1" - tx_deploy = Transaction(self.pk, None, 10, initial_nonce, data=code) + tx_deploy = Transaction(self.pk, None, 10, initial_nonce, fee=500, data=code) tx_deploy.sign(self.sk) - # Corrected typo: contract_add_ to contract_addr - contract_addr = self.state.apply_transaction(tx_deploy) + receipt = self.state.apply_transaction(tx_deploy) + self.assertIsNotNone(receipt) + self.assertEqual(receipt.status, 1) + contract_addr = receipt.contract_address self.assertTrue(isinstance(contract_addr, str)) # Verify balance and nonce after deploy + # We spent 10 amount + 500 gas for deploy = 510 total deduction sender_after = self.state.get_account(self.pk) - self.assertEqual(sender_after["balance"], initial_balance - 10) + self.assertEqual(sender_after["balance"], initial_balance - 510) self.assertEqual(sender_after["nonce"], initial_nonce + 1) - # Further test calls if needed + def test_out_of_gas(self): + """Contract with infinite loop should run out of gas and revert.""" + + code = "while True: pass" + + # 1. Deploy code + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) + tx_deploy.sign(self.sk) + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertEqual(receipt_deploy.status, 1) + contract_addr = receipt_deploy.contract_address + + # 2. Call code with specific fee (gas limit) + tx_call = Transaction(self.pk, contract_addr, 0, 1, fee=1000, data="loop") + tx_call.sign(self.sk) + + balance_before = self.state.get_account(self.pk)["balance"] + + receipt_call = self.state.apply_transaction(tx_call) + + self.assertEqual(receipt_call.status, 0) + self.assertEqual(receipt_call.error_message, "Out of gas!") + self.assertEqual(receipt_call.gas_used, 1000) + + balance_after = self.state.get_account(self.pk)["balance"] + # Entire fee should be deducted because gas was completely consumed + self.assertEqual(balance_after, balance_before - 1000) + + def test_malicious_import(self): + """Contract attempting to import a module should fail AST validation.""" + code = "import os\nstorage['x'] = 1" + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) + tx_deploy.sign(self.sk) + + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertEqual(receipt_deploy.status, 1) # Deploy succeeds, saves code + + tx_call = Transaction(self.pk, receipt_deploy.contract_address, 0, 1, fee=500, data="call") + tx_call.sign(self.sk) + receipt_call = self.state.apply_transaction(tx_call) + + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 0) + self.assertEqual(receipt_call.error_message, "AST Validation Failed") + + def test_malicious_file_deletion(self): + """Contract attempting to use open() or file IO should fail at runtime due to missing builtins.""" + # Using open() which is stripped from __builtins__ + code = "f = open('critical_file.txt', 'w')\nf.write('hacked')" + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) + tx_deploy.sign(self.sk) + + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertEqual(receipt_deploy.status, 1) + + tx_call = Transaction(self.pk, receipt_deploy.contract_address, 0, 1, fee=500, data="call") + tx_call.sign(self.sk) + receipt_call = self.state.apply_transaction(tx_call) + + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 0) + # Should throw a NameError because 'open' is not defined in safe_builtins + self.assertIn("name 'open' is not defined", receipt_call.error_message) diff --git a/tests/test_core.py b/tests/test_core.py index 0a818ed..93d68d6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -30,7 +30,7 @@ def test_transaction_signature(self): self.assertTrue(tx.verify()) # Tamper with amount - tx.amount = 100 + object.__setattr__(tx, 'amount', 100) self.assertFalse(tx.verify()) def test_state_transfer(self): @@ -62,6 +62,56 @@ def test_insufficient_funds(self): self.assertEqual(self.state.get_account(self.alice_pk)['balance'], 10) self.assertEqual(self.state.get_account(self.bob_pk)['balance'], 0) + def test_transaction_fee(self): + """Test that transaction fees are properly deducted and credited.""" + self.state.credit_mining_reward(self.alice_pk, 100) + + tx = Transaction(self.alice_pk, self.bob_pk, 40, 0, fee=5) + tx.sign(self.alice_sk) + + receipt = self.state.validate_and_apply(tx) + self.assertIsNotNone(receipt) + + # Check sender balance (100 - 40 - 5 = 55) + self.assertEqual(self.state.get_account(self.alice_pk)['balance'], 55) + # Check receiver balance (40) + self.assertEqual(self.state.get_account(self.bob_pk)['balance'], 40) + + # Test miner reward with fee + from minichain.block import Block, calculate_receipt_root + import main + + # Manually create a block to simulate chain processing + block = Block( + index=1, + previous_hash="0", + transactions=[tx], + difficulty=1, + state_root=self.state.state_root(), + receipt_root=calculate_receipt_root([receipt]), + receipts=[receipt], + miner=self.bob_pk + ) + + # We need to simulate the chain.add_block logic for the miner credit + # Actually, let's just use Blockchain.add_block logic directly by mining + self.chain.state = self.state.copy() + + from minichain.mempool import Mempool + mempool = Mempool() + mempool.add_transaction(tx) + + # Revert state to before tx since mine_and_process_block will re-apply it + self.chain.state.accounts[self.alice_pk]['balance'] = 100 + self.chain.state.accounts[self.alice_pk]['nonce'] = 0 + self.chain.state.accounts[self.bob_pk]['balance'] = 0 + + mined_block = main.mine_and_process_block(self.chain, mempool, self.bob_pk) + self.assertIsNotNone(mined_block) + + # Bob was the miner. Bob gets amount(40) + mining_reward(50) + fee(5) = 95 + self.assertEqual(self.chain.state.get_account(self.bob_pk)['balance'], 95) + def test_transaction_wrong_signer(self): """Test that a transaction signed with the wrong key is invalid.""" tx = Transaction(self.alice_pk, self.bob_pk, 10, 0) # Alice is sender diff --git a/tests/test_persistence.py b/tests/test_persistence.py index fe2d78f..c215712 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -42,14 +42,17 @@ def _chain_with_tx(self): tx.sign(alice_sk) temp_state = bc.state.copy() - temp_state.validate_and_apply(tx) + receipt = temp_state.validate_and_apply(tx) + from minichain.block import calculate_receipt_root block = Block( index=1, previous_hash=bc.last_block.hash, transactions=[tx], difficulty=1, state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root([receipt]), + receipts=[receipt], ) mine_block(block, difficulty=1) bc.add_block(block) @@ -88,6 +91,18 @@ def test_transaction_data_preserved(self): self.assertEqual(original_tx.nonce, loaded_tx.nonce) self.assertEqual(original_tx.signature, loaded_tx.signature) + def test_receipt_data_preserved(self): + bc, _, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + restored = load(path=self.tmpdir) + original_receipt = bc.chain[1].receipts[0] + loaded_receipt = restored.chain[1].receipts[0] + self.assertEqual(original_receipt.tx_hash, loaded_receipt.tx_hash) + self.assertEqual(original_receipt.status, loaded_receipt.status) + self.assertEqual(original_receipt.gas_used, loaded_receipt.gas_used) + self.assertEqual(original_receipt.error_message, loaded_receipt.error_message) + self.assertEqual(original_receipt.contract_address, loaded_receipt.contract_address) + def test_genesis_only_chain(self): bc = Blockchain() save(bc, path=self.tmpdir) @@ -221,14 +236,17 @@ def test_loaded_chain_can_add_new_block(self): tx2.sign(new_sk) temp_state = restored.state.copy() - temp_state.validate_and_apply(tx2) + receipt2 = temp_state.validate_and_apply(tx2) + from minichain.block import calculate_receipt_root block2 = Block( index=len(restored.chain), previous_hash=restored.last_block.hash, transactions=[tx2], difficulty=1, state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root([receipt2]), + receipts=[receipt2], ) mine_block(block2, difficulty=1) diff --git a/tests/test_persistence_runtime.py b/tests/test_persistence_runtime.py index 21d437b..894ccca 100644 --- a/tests/test_persistence_runtime.py +++ b/tests/test_persistence_runtime.py @@ -63,11 +63,18 @@ def _chain_with_tx(self): tx = Transaction(alice_pk, bob_pk, 30, 0) tx.sign(alice_sk) + temp_state = bc.state.copy() + receipt = temp_state.validate_and_apply(tx) + + from minichain.block import calculate_receipt_root block = Block( index=1, previous_hash=bc.last_block.hash, transactions=[tx], difficulty=1, + state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root([receipt]), + receipts=[receipt], ) mine_block(block, difficulty=1) bc.add_block(block) diff --git a/tests/test_protocol_hardening.py b/tests/test_protocol_hardening.py index ba9028c..538c8cc 100644 --- a/tests/test_protocol_hardening.py +++ b/tests/test_protocol_hardening.py @@ -108,7 +108,20 @@ async def test_block_schema_accepts_current_block_wire_format(self): tx = Transaction(sender_pk, receiver_pk, 1, 0, timestamp=123) tx.sign(sender_sk) - block = Block(index=1, previous_hash="0" * 64, transactions=[tx], timestamp=456, difficulty=2, state_root="0"*64) + from minichain.receipt import Receipt + from minichain.block import calculate_receipt_root + receipt = Receipt(tx_hash=tx.tx_id, status=1) + + block = Block( + index=1, + previous_hash="0" * 64, + transactions=[tx], + timestamp=456, + difficulty=2, + state_root="0"*64, + receipts=[receipt], + receipt_root=calculate_receipt_root([receipt]) + ) block.nonce = 9 block.hash = block.compute_hash() diff --git a/tests/test_reorg.py b/tests/test_reorg.py new file mode 100644 index 0000000..f678bea --- /dev/null +++ b/tests/test_reorg.py @@ -0,0 +1,117 @@ +import pytest +import os +import json +import time + +from minichain.chain import Blockchain +from minichain.transaction import Transaction +from minichain.mempool import Mempool + +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from main import mine_and_process_block + +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder + +@pytest.fixture +def genesis_file(tmp_path): + path = tmp_path / "genesis_reorg.json" + sk = SigningKey.generate() + pk = sk.verify_key.encode(encoder=HexEncoder).decode() + data = { + "timestamp": int(time.time()), + "difficulty": 0, + "alloc": { + pk: {"balance": 1000} + } + } + with open(path, "w") as f: + json.dump(data, f) + return str(path), sk, pk + +def test_resolve_conflicts_heavier_chain(genesis_file): + g_path, sk, pk = genesis_file + + node_a = Blockchain(genesis_path=g_path) + node_b = Blockchain(genesis_path=g_path) + + assert node_a.get_total_work() == node_b.get_total_work() + + pool_b = Mempool() + tx = Transaction(sender=pk, receiver="b"*64, amount=10, nonce=0, fee=1) + tx.sign(sk) + pool_b.add_transaction(tx) + + mined_b = mine_and_process_block(node_b, pool_b, pk) + assert mined_b is not None + assert node_b.get_total_work() > node_a.get_total_work() + + # Node A receives Node B's chain + success, orphans = node_a.resolve_conflicts(node_b.chain) + + assert success is True + assert node_a.last_block.hash == node_b.last_block.hash + assert node_a.state.accounts == node_b.state.accounts + assert len(orphans) == 0 + +def test_resolve_conflicts_reorg_with_orphans(genesis_file): + g_path, sk, pk = genesis_file + + node_a = Blockchain(genesis_path=g_path) + node_b = Blockchain(genesis_path=g_path) + + pool_a = Mempool() + pool_b = Mempool() + + # Node A mines tx1 (nonce 0) + tx1 = Transaction(sender=pk, receiver="a"*64, amount=10, nonce=0, fee=1) + tx1.sign(sk) + pool_a.add_transaction(tx1) + mine_and_process_block(node_a, pool_a, pk) + + # Node B mines tx2 (nonce 0, competing transaction) + tx2 = Transaction(sender=pk, receiver="b"*64, amount=20, nonce=0, fee=1) + tx2.sign(sk) + pool_b.add_transaction(tx2) + mine_and_process_block(node_b, pool_b, pk) + + # Node B mines tx3 (nonce 1) to become the heavier chain + tx3 = Transaction(sender=pk, receiver="c"*64, amount=30, nonce=1, fee=1) + tx3.sign(sk) + pool_b.add_transaction(tx3) + block_b2 = mine_and_process_block(node_b, pool_b, pk) + + assert node_b.get_total_work() > node_a.get_total_work() + + # Node A attempts reorg using B's heavier chain + success, orphans = node_a.resolve_conflicts(node_b.chain) + + assert success is True + assert node_a.last_block.hash == block_b2.hash + + # tx1 was in A's chain but NOT in B's chain. It should be orphaned. + assert len(orphans) == 1 + assert orphans[0].tx_id == tx1.tx_id + +def test_resolve_conflicts_rejects_lighter_chain(genesis_file): + g_path, sk, pk = genesis_file + + node_a = Blockchain(genesis_path=g_path) + node_b = Blockchain(genesis_path=g_path) + + pool_a = Mempool() + + # Node A mines a block + tx1 = Transaction(sender=pk, receiver="a"*64, amount=10, nonce=0, fee=1) + tx1.sign(sk) + pool_a.add_transaction(tx1) + mine_and_process_block(node_a, pool_a, pk) + + # Node B is empty. It tries to reorg Node A with its shorter chain. + success, orphans = node_a.resolve_conflicts(node_b.chain) + + assert success is False + assert len(orphans) == 0 + assert node_a.get_total_work() > node_b.get_total_work() diff --git a/tests/test_transaction_signing.py b/tests/test_transaction_signing.py index 05b79d0..ee3c845 100644 --- a/tests/test_transaction_signing.py +++ b/tests/test_transaction_signing.py @@ -71,7 +71,7 @@ def test_tampered_amount_fails_verification(alice, bob): tx = Transaction(alice_pk, bob_pk, 10, nonce=0) tx.sign(alice_sk) - tx.amount = 9999 # tamper + object.__setattr__(tx, "amount", 9999) # tamper assert not tx.verify(), "A transaction with a tampered amount must not verify." @@ -85,7 +85,7 @@ def test_tampered_receiver_fails_verification(alice, bob): tx.sign(alice_sk) attacker_sk = SigningKey.generate() - tx.receiver = attacker_sk.verify_key.encode(encoder=HexEncoder).decode() # tamper + object.__setattr__(tx, "receiver", attacker_sk.verify_key.encode(encoder=HexEncoder).decode()) # tamper assert not tx.verify(), "A transaction with a tampered receiver must not verify." @@ -97,7 +97,7 @@ def test_tampered_nonce_fails_verification(alice, bob): tx = Transaction(alice_pk, bob_pk, 10, nonce=0) tx.sign(alice_sk) - tx.nonce = 99 # tamper + object.__setattr__(tx, "nonce", 99) # tamper assert not tx.verify(), "A transaction with a tampered nonce must not verify." @@ -124,7 +124,7 @@ def test_forged_sender_field_fails_verification(alice, bob): tx = Transaction(alice_pk, bob_pk, 10, nonce=0) tx.sign(alice_sk) - tx.sender = bob_pk # forge sender + object.__setattr__(tx, "sender", bob_pk) # forge sender assert not tx.verify(), "A transaction with a forged sender field must not verify."