Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 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
160 changes: 94 additions & 66 deletions main.py
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 main.py, we only need to pass chain.difficulty into the Block constructor and mine_block() inside the mine_and_process_block function. We don't need the automated run_demo() loop, please keep
main.py interactive and preserve the persistence.py we added

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

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.

@anshulchikhale30-p the testnet demo heading is still there and in many other places as well there are duplicate functions I'll suggest closing this pr and open a new one making only specific changes to main.py as because of merge conflicts a lot of functions have been entered twice and even you have changed the helpbox for no reason not at all required so instead of fixing those will bring more noise to the code I'll suggest opening a new pr with minimal pid specific changes

Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import asyncio
import logging
import re
import time
from nacl.signing import SigningKey
from nacl.encoding import HexEncoder

from minichain import Transaction, Blockchain, Block, State, Mempool, P2PNetwork, mine_block
import nacl.encoding

# Local project imports
from minichain import Transaction, Blockchain, Block, mine_block, Mempool, P2PNetwork

logger = logging.getLogger(__name__)

BURN_ADDRESS = "0" * 40


# -------------------------
# Wallet Creation
# -------------------------
def create_wallet():
sk = SigningKey.generate()
pk = sk.verify_key.encode(encoder=HexEncoder).decode()
pk = sk.verify_key.encode(encoder=nacl.encoding.HexEncoder).decode()
return sk, pk


# -------------------------
# Mining + Block Processing
# -------------------------
def mine_and_process_block(chain, mempool, pending_nonce_map):
"""
Mine block and let Blockchain handle validation + state updates.
DO NOT manually apply transactions again.
"""

pending_txs = mempool.get_transactions_for_block()

block = Block(
Expand All @@ -32,69 +34,77 @@ def mine_and_process_block(chain, mempool, pending_nonce_map):
transactions=pending_txs,
)

mined_block = mine_block(block)
# Mine using current consensus difficulty; chain updates next difficulty after acceptance
block.difficulty = chain.difficulty

start_time = time.time()
mined_block = mine_block(block, difficulty=block.difficulty)
mining_time = time.time() - start_time

# Attach mining time to block (optional but useful)
mined_block.mining_time = mining_time

if not hasattr(mined_block, "miner"):
mined_block.miner = BURN_ADDRESS

deployed_contracts: list[str] = []
deployed_contracts = []

if chain.add_block(mined_block):
logger.info("Block #%s added", mined_block.index)
logger.info("Block #%s added with Difficulty: %s",
mined_block.index,
mined_block.difficulty)

miner_attr = getattr(mined_block, "miner", None)
if isinstance(miner_attr, str) and re.match(r'^[0-9a-fA-F]{40}$', miner_attr):
miner_address = miner_attr
else:
logger.warning("Invalid miner address. Crediting burn address.")
miner_address = BURN_ADDRESS
# Reward miner
miner_attr = getattr(mined_block, "miner", BURN_ADDRESS)
miner_address = (
miner_attr if re.match(r'^[0-9a-fA-F]{40}$', str(miner_attr))
else BURN_ADDRESS
)

# Reward must go through chain.state
chain.state.credit_mining_reward(miner_address)

for tx in mined_block.transactions:
sync_nonce(chain.state, pending_nonce_map, tx.sender)

# Track deployed contracts if your state.apply_transaction returns address
result = chain.state.get_account(tx.receiver) if tx.receiver else None
if isinstance(result, dict):
deployed_contracts.append(tx.receiver)

return mined_block, deployed_contracts

else:
logger.error("Block rejected by chain")
return None, []


# -------------------------
# Nonce Sync
# -------------------------
def sync_nonce(state, pending_nonce_map, address):
account = state.get_account(address)
if account and "nonce" in account:
pending_nonce_map[address] = account["nonce"]
else:
pending_nonce_map[address] = 0
pending_nonce_map[address] = account.get("nonce", 0) if account else 0


# -------------------------
# Node Logic
# -------------------------
async def node_loop():
logger.info("Starting MiniChain Node with Smart Contracts")
logger.info("Starting MiniChain Node with PID Difficulty Adjustment")

chain = Blockchain()
mempool = Mempool()

network = P2PNetwork()
pending_nonce_map = {}

def claim_nonce(address) -> int:
def get_next_nonce(address) -> int:
account = chain.state.get_account(address)
account_nonce = account.get("nonce", 0) if account else 0
local_nonce = pending_nonce_map.get(address, account_nonce)
next_nonce = max(account_nonce, local_nonce)
pending_nonce_map[address] = next_nonce + 1
return next_nonce

network = P2PNetwork()

async def _handle_network_data(data):
logger.info("Received network data: %s", data)

try:
if data["type"] == "tx":
tx = Transaction(**data["data"])
Expand All @@ -103,89 +113,107 @@ async def _handle_network_data(data):

elif data["type"] == "block":
block_data = data["data"]
transactions_raw = block_data.get("transactions", [])
transactions = [Transaction(**tx_data) for tx_data in transactions_raw]
txs = [
Transaction(**tx_d)
for tx_d in block_data.get("transactions", [])
]

block = Block(
index=block_data.get("index"),
previous_hash=block_data.get("previous_hash"),
transactions=transactions,
index=block_data["index"],
previous_hash=block_data["previous_hash"],
transactions=txs,
timestamp=block_data.get("timestamp"),
difficulty=block_data.get("difficulty")
difficulty=block_data.get("difficulty"),
)

block.nonce = block_data.get("nonce", 0)
block.hash = block_data.get("hash")

if chain.add_block(block):
logger.info("Received block added to chain: #%s", block.index)
chain.add_block(block)

except Exception:
logger.exception("Error processing network data: %s", data)
except Exception:
+ logger.exception("Network error while handling incoming data")

network.register_handler(_handle_network_data)

try:
await _run_node(network, chain, mempool, pending_nonce_map, claim_nonce)
await _run_node(network, chain, mempool, pending_nonce_map, get_next_nonce)
finally:
await network.stop()


# -------------------------
# Run Node
# -------------------------
async def _run_node(network, chain, mempool, pending_nonce_map, get_next_nonce):
await network.start()

alice_sk, alice_pk = create_wallet()
bob_sk, bob_pk = create_wallet()

logger.info("Alice Address: %s...", alice_pk[:10])
logger.info("Bob Address: %s...", bob_pk[:10])
_bob_sk, bob_pk = create_wallet()

logger.info("[1] Genesis: Crediting Alice with 100 coins")
# Initial funding
chain.state.credit_mining_reward(alice_pk, reward=100)
sync_nonce(chain.state, pending_nonce_map, alice_pk)

# -------------------------------
# Alice Payment
# -------------------------------

logger.info("[2] Transaction: Alice sends 10 coins to Bob")

nonce = get_next_nonce(alice_pk)
# Alice sends Bob 10 coins
logger.info("[2] Alice sending 10 coins to Bob")

tx_payment = Transaction(
sender=alice_pk,
receiver=bob_pk,
amount=10,
nonce=nonce,
nonce=get_next_nonce(alice_pk),
)
tx_payment.sign(alice_sk)

if mempool.add_transaction(tx_payment):
await network.broadcast_transaction(tx_payment)

# -------------------------------
# Mine Block 1
# PID Demo: Mining 5 Blocks
# -------------------------------
logger.info("[3] Mining Multiple Blocks (Watch Difficulty Adjust)")

logger.info("[3] Mining Block 1")
mine_and_process_block(chain, mempool, pending_nonce_map)
for i in range(5):
await asyncio.sleep(1)

# -------------------------------
# Final State Check
# -------------------------------
logger.info(f"\nMining Block {i+1}")

logger.info("[4] Final State Check")
mined_block, _ = mine_and_process_block(
chain, mempool, pending_nonce_map
)

if mined_block:
logger.info("Block mined in %.2f seconds",
mined_block.mining_time)

logger.info("New difficulty: %s",
chain.last_block.difficulty)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

# Final balances
alice_acc = chain.state.get_account(alice_pk)
bob_acc = chain.state.get_account(bob_pk)

logger.info("Alice Balance: %s", alice_acc.get("balance", 0) if alice_acc else 0)
logger.info("Bob Balance: %s", bob_acc.get("balance", 0) if bob_acc else 0)
logger.info(
"Final Balances -> Alice: %s, Bob: %s",
alice_acc.get("balance", 0),
bob_acc.get("balance", 0),
)


# -------------------------
# Entry Point
# -------------------------
def main():
logging.basicConfig(level=logging.INFO)
asyncio.run(node_loop())
logging.basicConfig(
level=logging.INFO,
format='%(message)s'
)

try:
asyncio.run(node_loop())
except KeyboardInterrupt:
pass


if __name__ == "__main__":
Expand Down
16 changes: 16 additions & 0 deletions minichain/chain.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .block import Block
from .state import State
from .pow import calculate_hash
from minichain.consensus.difficulty import PIDDifficultyAdjuster
import logging
import threading

Expand All @@ -13,6 +14,8 @@ class Blockchain:
"""

def __init__(self):
self.difficulty = 3
self.difficulty_adjuster = PIDDifficultyAdjuster(target_block_time=5)
self.chain = []
self.state = State()
self._lock = threading.RLock()
Expand Down Expand Up @@ -60,6 +63,17 @@ def add_block(self, block):
logger.warning("Block %s rejected: Invalid hash %s", block.index, block.hash)
return False

# Enforce PoW difficulty
if block.difficulty != self.difficulty:
logger.warning(
"Block %s rejected: Invalid difficulty %s != %s",
block.index, block.difficulty, self.difficulty
)
return False
if not block.hash.startswith("0" * self.difficulty):
logger.warning("Block %s rejected: Hash does not meet difficulty target", block.index)
return False

# Validate transactions on a temporary state copy
temp_state = self.state.copy()

Expand All @@ -74,4 +88,6 @@ def add_block(self, block):
# All transactions valid → commit state and append block
self.state = temp_state
self.chain.append(block)
self.difficulty = self.difficulty_adjuster.adjust(self.difficulty)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
logger.info("New difficulty: %s", self.difficulty)
Comment thread
anshulchikhale30-p marked this conversation as resolved.
Outdated
return True
63 changes: 63 additions & 0 deletions minichain/consensus/difficulty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import time

class PIDDifficultyAdjuster:
def __init__(self, target_block_time=5, kp=0.5, ki=0.05, kd=0.1):
self.target_block_time = target_block_time
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.

the PID controller is a really good addition but right now it relies on floating-point coefficients (0.5, 0.05). In blockchain consensus, floating-point math can lead to chain forks because different CPUs can round floats differently. Could we adapt this to use strictly integer math (like multiplying by 100 and using integer division //) to guarantee 100% determinism across all nodes?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great point — I agree that using floating-point coefficients can introduce non-determinism across nodes, which is risky in a consensus system.

We can definitely switch to fixed-point/integer arithmetic (e.g., scaling coefficients by 100 or 1000 and using integer division) to ensure deterministic behavior across all environments.

I'll refactor the PID controller to use integer math so that all nodes compute identical results. Thanks for pointing this out!

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.

hey difficulty.py is almost correct but right now it's still using a float multiplier: max_delta = max(1, int(current_difficulty * 0.1)) Since we want exactly 10%, we can skip the float completely and just use integer floor division: max_delta = max(1, current_difficulty // 10)
Once that's swapped out to use integer division, the math inside this controller will be fixed

# PID Coefficients
self.kp = kp
self.ki = ki
self.kd = kd

self.integral = 0
self.previous_error = 0
self.last_block_time = time.monotonic()

# Limit the integral to prevent "Windup"
# This stops the difficulty from tanking if the network goes offline
self.integral_limit = 100

# Max percentage the difficulty can change in one block (e.g., 10%)
self.max_change_factor = 0.1

def adjust(self, current_difficulty, actual_block_time=None):
"""
Calculates the new difficulty based on the time since the last block.
"""
# --- FIX: Handle the case where current_difficulty is None ---
if current_difficulty is None:
current_difficulty = 1000 # Default starting difficulty

if actual_block_time is None:
now = time.monotonic()
actual_block_time = now - self.last_block_time
self.last_block_time = now

# Error = Goal - Reality
error = self.target_block_time - actual_block_time

# Update Integral with clamping (Anti-Windup)
self.integral = max(min(self.integral + error, self.integral_limit), -self.integral_limit)

# Derivative: how fast is the error changing?
derivative = error - self.previous_error
self.previous_error = error

# Calculate PID Adjustment
adjustment = (
self.kp * error +
self.ki * self.integral +
self.kd * derivative
)

# Apply adjustment with a cap to maintain stability
# Now current_difficulty is guaranteed to be a number
max_delta = max(1, int(round(current_difficulty * self.max_change_factor)))
clamped_adjustment = max(min(adjustment, max_delta), -max_delta)

delta = int(round(clamped_adjustment))
if delta == 0 and clamped_adjustment != 0:
delta = 1 if clamped_adjustment > 0 else -1
new_difficulty = current_difficulty + delta

# Safety: Difficulty must never drop below 1
return max(1, new_difficulty)
Loading