Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 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
219 changes: 157 additions & 62 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
Expand Up @@ -20,35 +20,31 @@
import asyncio
import logging
import re
import sys

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 helpers
# ──────────────────────────────────────────────

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


# ──────────────────────────────────────────────
# Block mining
# ──────────────────────────────────────────────

def mine_and_process_block(chain, mempool, miner_pk):
"""Mine pending transactions into a new block."""
# -------------------------
# Mining + Block Processing
# -------------------------
def mine_and_process_block(chain, mempool, pending_nonce_map):
pending_txs = mempool.get_transactions_for_block()
if not pending_txs:
logger.info("Mempool is empty — nothing to mine.")
Expand All @@ -60,12 +56,44 @@ def mine_and_process_block(chain, mempool, miner_pk):
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 = []

if chain.add_block(mined_block):
logger.info("✅ Block #%d mined and added (%d txs)", mined_block.index, len(pending_txs))
chain.state.credit_mining_reward(miner_pk)
return mined_block
logger.info("Block #%s added with Difficulty: %s",
mined_block.index,
mined_block.difficulty)

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

chain.state.credit_mining_reward(miner_address)

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

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
Expand Down Expand Up @@ -244,76 +272,143 @@ async def cli_loop(sk, pk, chain, mempool, network, nonce_counter):
else:
print(f" Unknown command: {cmd}. Type 'help' for available commands.")

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

# ──────────────────────────────────────────────
# Main entry point
# ──────────────────────────────────────────────

async def run_node(port: int, connect_to: str | None, fund: int):
"""Boot the node, optionally connect to a peer, then enter the CLI."""
sk, pk = create_wallet()
# -------------------------
# Node Logic
# -------------------------
async def node_loop():
logger.info("Starting MiniChain Node with PID Difficulty Adjustment")

chain = Blockchain()
mempool = Mempool()
network = P2PNetwork()
pending_nonce_map = {}

handler = make_network_handler(chain, mempool)
network.register_handler(handler)

# When a new peer connects, send our state so they can sync
async def on_peer_connected(writer):
import json as _json
sync_msg = _json.dumps({
"type": "sync",
"data": {"accounts": chain.state.accounts}
}) + "\n"
writer.write(sync_msg.encode())
await writer.drain()
logger.info("🔄 Sent state sync to new peer")

network._on_peer_connected = on_peer_connected
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

await network.start(port=port)

# Fund this node's wallet so it can transact in the demo
if fund > 0:
chain.state.credit_mining_reward(pk, reward=fund)
logger.info("💰 Funded %s... with %d coins", pk[:12], fund)

# Connect to a seed peer if requested
if connect_to:
async def _handle_network_data(data):
try:
host, peer_port = connect_to.rsplit(":", 1)
await network.connect_to_peer(host, int(peer_port))
except ValueError:
logger.error("Invalid --connect format. Use host:port")
if data["type"] == "tx":
tx = Transaction(**data["data"])
if mempool.add_transaction(tx):
await network.broadcast_transaction(tx)

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

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

chain.add_block(block)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
except Exception:
logger.exception("Network error while handling incoming data")

# Nonce counter kept as a mutable list so the CLI closure can mutate it
nonce_counter = [0]

try:
await cli_loop(sk, pk, chain, mempool, network, nonce_counter)
await _run_node(network, chain, mempool, pending_nonce_map, get_next_nonce)
finally:
await network.stop()


def main():
parser = argparse.ArgumentParser(description="MiniChain Node — Testnet Demo")
parser.add_argument("--port", type=int, default=9000, help="TCP port to listen on (default: 9000)")
parser.add_argument("--connect", type=str, default=None, help="Peer address to connect to (host:port)")
parser.add_argument("--fund", type=int, default=100, help="Initial coins to fund this wallet (default: 100)")
args = parser.parse_args()
# -------------------------
# Run Node
# -------------------------
async def _run_node(network, chain, mempool, pending_nonce_map, get_next_nonce):
await network.start()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
alice_sk, alice_pk = create_wallet()
_bob_sk, bob_pk = create_wallet()

# Initial funding
chain.state.credit_mining_reward(alice_pk, reward=100)
sync_nonce(chain.state, pending_nonce_map, 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=get_next_nonce(alice_pk),
)
tx_payment.sign(alice_sk)
mempool.add_transaction(tx_payment)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
# -------------------------------
# PID Demo: Mining 5 Blocks
# -------------------------------
logger.info("[3] Mining Multiple Blocks (Watch Difficulty Adjust)")

for i in range(5):
await asyncio.sleep(1)

logger.info(f"\nMining Block {i+1}")

mined = mine_and_process_block(chain, mempool, pending_nonce_map)
if not mined:
logger.info("No pending transactions to mine in this iteration")
continue
mined_block, _ = mined


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

logger.info("New difficulty: %s",
chain.difficulty)

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

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,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
format='%(message)s'
)

try:
asyncio.run(run_node(args.port, args.connect, args.fund))
asyncio.run(node_loop())
except KeyboardInterrupt:
print("\nNode shut down.")
pass


if __name__ == "__main__":
Expand Down
24 changes: 23 additions & 1 deletion 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 @@ -72,6 +86,14 @@ def add_block(self, block):
return False

# All transactions valid → commit state and append block
previous_timestamp = self.last_block.timestamp
self.state = temp_state
self.chain.append(block)
return True
actual_block_time = max(0, (block.timestamp - previous_timestamp) / 1000)
self.difficulty = self.difficulty_adjuster.adjust(
self.difficulty,
actual_block_time=actual_block_time,
)
logger.info("New difficulty: %s", self.difficulty)
return True
return True
Comment thread
anshulchikhale30-p marked this conversation as resolved.
Outdated
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