Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <filepath> [amount] [gas_limit]`
2. **Call:** `call <contract_address> <payload> [amount] [gas_limit]`

Comment on lines +121 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align README CLI args with implemented command names.

Lines 121–123 document [gas_limit], but the CLI parser uses [fee] (see main.py command usage strings). Please keep naming consistent to avoid failed command attempts.

Suggested patch
-1. **Deploy:** `deploy <filepath> [amount] [gas_limit]`
-2. **Call:** `call <contract_address> <payload> [amount] [gas_limit]`
+1. **Deploy:** `deploy <filepath> [amount] [fee]`
+2. **Call:** `call <contract_address> <payload> [amount] [fee]`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
1. **Deploy:** `deploy <filepath> [amount] [gas_limit]`
2. **Call:** `call <contract_address> <payload> [amount] [gas_limit]`
1. **Deploy:** `deploy <filepath> [amount] [fee]`
2. **Call:** `call <contract_address> <payload> [amount] [fee]`
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 121 - 123, The README entries for the CLI currently
document `[gas_limit]` but the CLI parser in main.py exposes `[fee]`; update the
README lines for the `deploy` and `call` commands to use `[fee]` (or change
main.py's usage strings to `[gas_limit]` if you prefer changing the code) so the
documented arguments match the implemented usage strings in main.py; ensure the
parameter name in README exactly matches the token used in main.py's command
usage.

---

## Tech Stack
Expand Down
37 changes: 37 additions & 0 deletions examples/counter.py
Original file line number Diff line number Diff line change
@@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to documenting this here, could you also document the available built-ins in a more suitable location (e.g. in the place where these built-ins are being made available)?

# - `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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also document this in the place where this is being enforced?


if msg['data'] == 'increment':

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! this is good enough for MiniChain. For a production-ready chain, it would be interesting to find a way to allow contracts devs to write non-embedded python code, such as:

def increment():
   current_value = counter
   counter = current_value + 1

Instead of having to use the msg and storage built-ins explicitly.

Let's discuss our options in discord.

# 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")
90 changes: 90 additions & 0 deletions examples/dex.py
Original file line number Diff line number Diff line change
@@ -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:<amount>' (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

Comment on lines +60 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate sell amount is strictly positive before debiting sender balance.

tokens_to_sell is parsed but never checked for <= 0. Add an explicit guard before mutating balances.

Suggested fix
     parts = msg['data'].split(':')
+    if len(parts) != 2:
+        raise Exception("Invalid sell format")
     tokens_to_sell = int(parts[1])
+    if tokens_to_sell <= 0:
+        raise Exception("Amount must be positive")
     
     sender = msg['sender']
     sender_tokens = storage.get(sender, 0)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
parts = msg['data'].split(':')
if len(parts) != 2:
raise Exception("Invalid sell format")
tokens_to_sell = int(parts[1])
if tokens_to_sell <= 0:
raise Exception("Amount must be positive")
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
🧰 Tools
🪛 Ruff (0.15.15)

[error] 60-60: Undefined name msg

(F821)


[error] 63-63: Undefined name msg

(F821)


[error] 64-64: Undefined name storage

(F821)


[warning] 66-66: Create your own exception

(TRY002)


[warning] 66-66: Avoid specifying long messages outside the exception class

(TRY003)


[error] 69-69: Undefined name storage

(F821)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/dex.py` around lines 60 - 70, The code parses tokens_to_sell from
parts but doesn't validate it's strictly positive; add an explicit guard after
computing tokens_to_sell (from parts and before reading/modifying storage) to
raise an error if tokens_to_sell <= 0, so functions handling sell in
examples/dex.py (variables parts, tokens_to_sell, sender, storage) never debit
the sender for non-positive amounts.

# 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add an issue in our issue tracker for this?

# 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.")
41 changes: 41 additions & 0 deletions examples/stablecoin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Stablecoin (ERC-20 style) Smart Contract Example
#
# This contract implements a minimal fungible token.
#
# Valid Payloads:
# - 'mint:<amount>'
# - 'transfer:<recipient_address>:<amount>'

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:<amount>, transfer:<to>:<amount>")
93 changes: 86 additions & 7 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}

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

Expand Down Expand Up @@ -201,28 +209,33 @@ 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 <receiver_address> <amount>")
print(" Usage: send <receiver_address> <amount> [fee]")
continue
receiver = parts[1]
if not is_valid_receiver(receiver):
print(" Invalid receiver format. Expected 40 or 64 hex characters.")
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):
Expand All @@ -231,6 +244,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 <filepath> [amount] [fee]")
continue
filepath = parts[1]
try:
with open(filepath, "r") as f:
code = f.read()
except FileNotFoundError:
Comment on lines +253 to +256

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify async functions that still perform blocking open(...)
python - <<'PY'
import ast
from pathlib import Path

for p in Path(".").rglob("*.py"):
    try:
        tree = ast.parse(p.read_text(encoding="utf-8"))
    except Exception:
        continue
    for node in ast.walk(tree):
        if isinstance(node, ast.AsyncFunctionDef):
            for sub in ast.walk(node):
                if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name) and sub.func.id == "open":
                    print(f"{p}:{sub.lineno} async function '{node.name}' uses open(...)")
PY

Repository: StabilityNexus/MiniChain

Length of output: 121


Avoid blocking disk I/O on the cli_loop event loop path.

main.py’s async def cli_loop reads the contract with with open(filepath, "r") as f: code = f.read() (line ~254), which blocks the event loop while loading large files.

Suggested patch
+from pathlib import Path
@@
-            try:
-                with open(filepath, "r") as f:
-                    code = f.read()
+            try:
+                code = await asyncio.to_thread(Path(filepath).read_text, encoding="utf-8")
             except FileNotFoundError:
                 print(f"  File not found: {filepath}")
                 continue
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
with open(filepath, "r") as f:
code = f.read()
except FileNotFoundError:
try:
code = await asyncio.to_thread(Path(filepath).read_text, encoding="utf-8")
except FileNotFoundError:
print(f" File not found: {filepath}")
continue
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 254-254: Async functions should not open files with blocking methods like open

(ASYNC230)


[warning] 254-254: Unnecessary mode argument

Remove mode argument

(UP015)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` around lines 253 - 256, The cli_loop coroutine uses blocking file
I/O (with open(filepath, "r") as f: code = f.read()) which stalls the event
loop; change it to perform file reads off the event loop (e.g., await
asyncio.to_thread(lambda: open(...).read()) or use an async file library like
aiofiles and await aiofiles.open/reader) and preserve the existing
FileNotFoundError handling around the read; update the read site in async def
cli_loop to await the non-blocking read and re-raise or log the
FileNotFoundError as before so the rest of cli_loop continues to run without
blocking.

Source: Linters/SAST tools

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 <contract_address> <payload> [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)
Expand Down
Loading