From 70de638ee94a3419dcbed26bf809452eba6e164c Mon Sep 17 00:00:00 2001 From: Roman Matsuiev Date: Wed, 4 Feb 2026 14:10:35 +0200 Subject: [PATCH 1/5] add contarization --- backend/Dockerfile | 17 +++++++++++++ backend/app/__init__.py | 3 ++- docker-compose.yml | 55 +++++++++++++++++++++++++++++++++++++++++ frontend/Dockerfile | 22 +++++++++++++++++ frontend/nginx.conf | 10 ++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..23f6878 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install curl -y + +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . + +RUN pip install --no-cache -r requirements.txt + +COPY /backend /app/backend/ + +EXPOSE 5050 + +CMD ["python", "-m", "backend.app"] \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py index bb43aa2..6e2bccb 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -21,7 +21,8 @@ from backend.pubsub import PubSub app = Flask(__name__) -CORS(app, resources={ r'/*': { 'origins': 'http://localhost:3000' } }) +# Allow both dev (port 3000) and Docker frontend (port 8080) +CORS(app, resources={r'/*': {'origins': ['http://localhost:3000', 'http://localhost:8080']}}) def json_response(data, status=200): """ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..19b4fba --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + ports: + - 5050:5050 + env_file: + - backend/.env + environment: + - PUBNUB_USER_ID=node-main + - ROOT_HOST=backend-seed + - POLL_ROOT=True + - POLL_INTERVAL=10 + + backend-peer: + build: + context: . + dockerfile: backend/Dockerfile + env_file: + - backend/.env + environment: + - PUBNUB_USER_ID=node-peer-1 + - ROOT_HOST=backend + expose: + - 5050 + + + backend-seed: + build: + context: . + dockerfile: backend/Dockerfile + env_file: + - backend/.env + environment: + - SEED_DATA=True + - PUBNUB_USER_ID=node-seed-1 + - ROOT_HOST=backend + profiles: + - seed + expose: + - 5050 + + frontend: + build: + context: . + dockerfile: frontend/Dockerfile + ports: + - 8080:80 + profiles: + - client + environment: + - REACT_APP_API_URL=http://localhost:5050 + depends_on: + - backend \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..17d8306 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:23.1.0 AS builder + +WORKDIR /app + +COPY frontend/package*.json ./ + +RUN npm install + +COPY frontend/public ./public/ +COPY frontend/src ./src/ + +RUN npm run build + +FROM nginx:alpine + +COPY --from=builder /app/build /usr/share/nginx/html + +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..1a30e15 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file From c3276b0e804c847728ec505933d179abe312cf2e Mon Sep 17 00:00:00 2001 From: Roman Matsuiev Date: Sat, 7 Feb 2026 10:47:00 +0200 Subject: [PATCH 2/5] enhanced --- PUBNUB_CONFIG.md | 2 +- README.md | 111 +----------- backend/Dockerfile | 6 +- backend/app/__init__.py | 168 ------------------- backend/app/__main__.py | 57 ++++++- backend/app/context.py | 79 +++++++++ backend/app/factory.py | 39 +++++ backend/app/logging.py | 14 ++ backend/app/polling.py | 76 +++++++++ backend/app/routes.py | 106 ++++++++++++ backend/blockchain/__init__.py | 15 ++ backend/blockchain/blockchain.py | 5 +- backend/blockchain/exceptions.py | 17 ++ backend/{env.example => config/.env.example} | 1 - backend/config/__init__.py | 35 ++++ backend/{config.py => config/constants.py} | 7 +- backend/config/env.py | 47 ++++++ backend/pubsub.py | 100 ++++++----- docker-compose.yml | 9 +- frontend/nginx.conf | 14 +- frontend/src/components/App.js | 1 + frontend/src/config.js | 3 +- 22 files changed, 570 insertions(+), 342 deletions(-) create mode 100644 backend/app/context.py create mode 100644 backend/app/factory.py create mode 100644 backend/app/logging.py create mode 100644 backend/app/polling.py create mode 100644 backend/app/routes.py create mode 100644 backend/blockchain/exceptions.py rename backend/{env.example => config/.env.example} (99%) create mode 100644 backend/config/__init__.py rename backend/{config.py => config/constants.py} (59%) create mode 100644 backend/config/env.py diff --git a/PUBNUB_CONFIG.md b/PUBNUB_CONFIG.md index f8440b6..cbde669 100644 --- a/PUBNUB_CONFIG.md +++ b/PUBNUB_CONFIG.md @@ -28,7 +28,7 @@ The blockchain application uses PubNub for real-time peer-to-peer communication 1. **Copy the secrets template:** ```bash - cp env.example .env + cp backend/config/env.example backend/config/.env ``` 2. **Edit secrets.env and add your keys:** diff --git a/README.md b/README.md index ba76db1..4640904 100644 --- a/README.md +++ b/README.md @@ -1,110 +1 @@ -## Python, JS, & React | Build a Blockchain & Cryptocurrency - -![Course Logo](python_blockchain_logo.png) - -**The course is designed to help you achieve three main goals:** -- Learn Python and Backend Web Development. -- Build a Blockchain and Cryptocurrency Project that you can add to your portfolio. -- Learn JavaScript, Frontend Web Development, React.js, and React Hooks. - -The course's main project is to build a blockchain and cryptocurrency. With a blockchain and cryptocurrency system as the main goal, you will go through a course journey that starts with backend development using Python. Then, you will transaction to frontend web development with JavaScript, React.js, and React Hooks. - -Check out the course: https://www.udemy.com/course/python-js-react-blockchain/?referralCode=9051A01550E782315B77 - -**Here's an overview of the overall course journey:** -- Get an introduction of the Python Fundamentals. -- Begin building the Blockchain Application with Python. -- Test the Application using Pytest. -- Incorporate the crucial concept of Proof of Work into the Blockchain. -- Enhance the application to prepare for networking. -- Create the Blockchain network using Flask and Pub/Sub. -- Integrate the Cryptocurrency, building Wallets, Keys, and Transactions. -- Extend the network implementation with the cryptocurrency. -- Transition from Python to JavaScript with a "From Python to JavaScript" introduction. -- Establish frontend web development skills and begin coding with React.js. -- Create the frontend portion for the blockchain portion of the system. -- Complete the frontend by building a UI for the cryptocurrency portion of the system. - -**In addition, here are the skills that you'll gain from the course:** -- How to build a blockchain and cryptocurrency system from scratch. -- The fundamentals of python - data structures, object-oriented programming, modules, and more. -- The ins and outs of hashing and sha256. -- Encoding and decoding in utf-8. -- Testing Python applications with pytest. -- Python virtual environments. -- The concept of proof of work, and how it pertains to mining blocks. -- Conversion between hexadecimal to binary. -- HTTP APIs and requests. -- How to create APIs with Python Flask. -- The publish/subscribe pattern to set up networks. -- When to apply the concepts of serialization and deserialization. -- Public/private keypairs and generating data signatures. -- The fundamentals of JavaScript. -- Frontend web development and how web applications are constructed. -- The core concepts of React and React hooks. -- How the React engine works under the hood, and how React applies hooks. -- CORS - and how to get over the CORS error properly. -- How to build a pagination system. - -*** - -#### Command Reference - -**Activate the virtual environment** -``` -source blockchain-env/bin/activate -``` - -**Install all packages** -``` -pip3 install -r requirements.txt -``` - -**Run the tests** - -Make sure to activate the virtual environment. - -``` -python3 -m pytest backend/tests -``` - -**Run the application and API** - -Make sure to activate the virtual environment. - -``` -python3 -m backend.app -``` - -**Run a peer instance** - -Make sure to activate the virtual environment. -Choose a unique PUBNUB_USER_ID per peer. - -``` -export PEER=True && export PUBNUB_USER_ID=blockchain-peer-1 && python3 -m backend.app -``` - -**Run the frontend** - -In the frontend directory: -``` -npm run start -``` - -**Seed the backend with data** - -Make sure to activate the virtual environment. - -``` -export SEED_DATA=True && python3 -m backend.app -``` - -** PubNub Configuration** - -This application uses PubNub for real-time peer-to-peer communication between blockchain nodes. **You must configure PubNub to run the application.** - -**See [PUBNUB_CONFIG.md](PUBNUB_CONFIG.md) for detailed setup instructions.** - -1. Get free PubNub keys at [https://www.pubnub.com/](https://www.pubnub.com/) -2. copy `backend/env.example` to `backend/.env` and configure it with your keys. +# TODO diff --git a/backend/Dockerfile b/backend/Dockerfile index 23f6878..9cd6c81 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,10 +8,10 @@ WORKDIR /app COPY requirements.txt . -RUN pip install --no-cache -r requirements.txt +RUN pip install --no-cache-dir -r requirements.txt -COPY /backend /app/backend/ +COPY backend/ /app/backend/ EXPOSE 5050 -CMD ["python", "-m", "backend.app"] \ No newline at end of file +CMD ["python", "-m", "backend.app"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 6e2bccb..e69de29 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,168 +0,0 @@ -import os -import requests -import random -import threading -import time -from pathlib import Path -from dotenv import load_dotenv - -# Load environment variables FIRST, before any other backend imports -env_path = Path(__file__).parent.parent / '.env' -load_dotenv(dotenv_path=env_path) - -from flask import Flask, jsonify, request, Response -from flask_cors import CORS -import json - -from backend.blockchain.blockchain import Blockchain -from backend.wallet.wallet import Wallet -from backend.wallet.transaction import Transaction -from backend.wallet.transaction_pool import TransactionPool -from backend.pubsub import PubSub - -app = Flask(__name__) -# Allow both dev (port 3000) and Docker frontend (port 8080) -CORS(app, resources={r'/*': {'origins': ['http://localhost:3000', 'http://localhost:8080']}}) - -def json_response(data, status=200): - """ - Create a JSON response that preserves large integers. - Flask's default jsonify converts large ints to floats, which breaks - cryptographic signatures. This function ensures integers are preserved. - """ - return Response( - json.dumps(data, separators=(',', ':')), - status=status, - mimetype='application/json' - ) -blockchain = Blockchain() -wallet = Wallet(blockchain) -transaction_pool = TransactionPool() -pubsub = PubSub(blockchain, transaction_pool) - -@app.route('/') -def route_default(): - return 'Welcome to the blockchain' - -@app.route('/blockchain') -def route_blockchain(): - return json_response(blockchain.to_json()) - -@app.route('/blockchain/range') -def route_blockchain_range(): - # http://localhost:5050/blockchain/range?start=2&end=5 - start = int(request.args.get('start')) - end = int(request.args.get('end')) - - return jsonify(blockchain.to_json()[::-1][start:end]) - -@app.route('/blockchain/length') -def route_blockchain_length(): - return jsonify(len(blockchain.chain)) - -@app.route('/blockchain/mine') -def route_blockchain_mine(): - transaction_data = transaction_pool.transaction_data() - transaction_data.append(Transaction.reward_transaction(wallet).to_json()) - blockchain.add_block(transaction_data) - block = blockchain.chain[-1] - pubsub.broadcast_block(block) - transaction_pool.clear_blockchain_transactions(blockchain) - - return json_response(block.to_json()) - -@app.route('/wallet/transact', methods=['POST']) -def route_wallet_transact(): - transaction_data = request.get_json() - transaction = transaction_pool.existing_transaction(wallet.address) - - if transaction: - transaction.update( - wallet, - transaction_data['recipient'], - transaction_data['amount'] - ) - else: - transaction = Transaction( - wallet, - transaction_data['recipient'], - transaction_data['amount'] - ) - - pubsub.broadcast_transaction(transaction) - transaction_pool.set_transaction(transaction) - - return jsonify(transaction.to_json()) - -@app.route('/wallet/info') -def route_wallet_info(): - return jsonify({ 'address': wallet.address, 'balance': wallet.balance }) - -@app.route('/known-addresses') -def route_known_addresses(): - known_addresses = set() - - for block in blockchain.chain: - for transaction in block.data: - known_addresses.update(transaction['output'].keys()) - - return jsonify(list(known_addresses)) - -@app.route('/transactions') -def route_transactions(): - return jsonify(transaction_pool.transaction_data()) - -ROOT_PORT = 5050 -PORT = ROOT_PORT -# In Docker, use service name supplied by an env variable instead of localhost -root_host = os.environ.get('ROOT_HOST', 'localhost') - -if os.environ.get('PEER') == 'True': - PORT = random.randint(5051, 6000) - - result = requests.get(f'http://{root_host}:{ROOT_PORT}/blockchain') - result_blockchain = Blockchain.from_json(result.json()) - - try: - blockchain.replace_chain(result_blockchain.chain) - print('\n -- Successfully synchronized the local chain') - except Exception as e: - print(f'\n -- Error synchronizing: {e}') - -if os.environ.get('SEED_DATA') == 'True': - for i in range(10): - blockchain.add_block([ - Transaction(Wallet(), Wallet().address, random.randint(2, 50)).to_json(), - Transaction(Wallet(), Wallet().address, random.randint(2, 50)).to_json() - ]) - - for i in range(3): - transaction = Transaction(Wallet(), Wallet().address, random.randint(2, 50)) - pubsub.broadcast_transaction(transaction) - transaction_pool.set_transaction(transaction) - -def poll_root_blockchain(): - poll_interval = int(os.environ.get('POLL_INTERVAL', '15')) - root_host = os.environ.get('ROOT_HOST', 'localhost') - - print(f'\n -- Starting polling thread for {root_host}:{ROOT_PORT} every {poll_interval}s') - - while True: - try: - result = requests.get(f'http://{root_host}:{ROOT_PORT}/blockchain') - result_blockchain = Blockchain.from_json(result.json()) - blockchain.replace_chain(result_blockchain.chain) - print(f'\n -- Successfully polled blockchain from {root_host}') - except Exception as e: - print(f'\n -- Error polling root blockchain: {e}') - - time.sleep(poll_interval) - -if os.environ.get('POLL_ROOT') == 'True': - # Start polling in a background daemon thread so it doesn't block Flask - polling_thread = threading.Thread(target=poll_root_blockchain, daemon=True) - polling_thread.start() - -if __name__ == "__main__": - app.run(host="0.0.0.0", port=PORT, debug=True) - diff --git a/backend/app/__main__.py b/backend/app/__main__.py index 5243c9c..9c445fd 100644 --- a/backend/app/__main__.py +++ b/backend/app/__main__.py @@ -1,5 +1,58 @@ -from backend.app import app, PORT +""" +Application entry point. +Initializes and runs the Flask application with graceful shutdown handling. +""" +import sys +import signal +import time +from backend.app.logging import logger +from backend.app.factory import create_app +from backend.app.context import app_context +from backend.app.polling import start_polling_thread, shutdown_event +from backend.config import AppConfig + + +# Start the polling thread if configured +polling_thread = start_polling_thread(app_context.blockchain) + +# Create the Flask application +app = create_app() + + +# ------------------------------ +# Graceful shutdown +# ------------------------------ +def shutdown(signum, frame): + """ + Handle shutdown signals (SIGTERM, SIGINT) gracefully. + Stops PubSub, polling thread, and cleans up resources. + """ + logger.info("Received shutdown signal — exiting...") + + # Stop PubSub + if app_context.pubsub: + logger.info("Stopping PubSub") + app_context.pubsub.stop() + + # Stop polling thread + shutdown_event.set() + if polling_thread and polling_thread.is_alive(): + logger.info("Waiting for polling thread to finish...") + polling_thread.join(timeout=5) + + time.sleep(0.5) + logger.info("Cleanup complete. Exiting.") + sys.exit(0) + +signal.signal(signal.SIGTERM, shutdown) +signal.signal(signal.SIGINT, shutdown) + +# ------------------------------ +# Run the Flask app +# ------------------------------ if __name__ == "__main__": - app.run(host="0.0.0.0", port=PORT, debug=True) + port = AppConfig.get_port() + logger.info(f"Starting blockchain node on port {port}") + app.run(host="0.0.0.0", port=port) diff --git a/backend/app/context.py b/backend/app/context.py new file mode 100644 index 0000000..12d25ab --- /dev/null +++ b/backend/app/context.py @@ -0,0 +1,79 @@ +""" +Application context module. +Manages the core application state including blockchain, wallet, transaction pool, and pubsub. +""" +import random +import requests +from backend.app.logging import logger +from backend.blockchain.blockchain import Blockchain +from backend.blockchain.exceptions import ChainLengthException +from backend.wallet.wallet import Wallet +from backend.wallet.transaction import Transaction +from backend.wallet.transaction_pool import TransactionPool +from backend.pubsub import PubSub +from backend.config import AppConfig + + +class AppContext: + """ + Encapsulates all application state and provides initialization logic. + """ + + def __init__(self): + self.blockchain = Blockchain() + self.wallet = Wallet(self.blockchain) + self.transaction_pool = TransactionPool() + self.pubsub = PubSub(self.blockchain, self.transaction_pool) + + self.initialize() + + def initialize(self): + """ + Initialize application state based on environment configuration. + """ + if AppConfig.IS_PEER: + self.sync_with_root() + + if AppConfig.SEED_DATA: + self.seed_data() + + def sync_with_root(self): + """ + Synchronize blockchain with the root node on startup. + """ + try: + result = requests.get( + f"http://{AppConfig.ROOT_HOST}:{AppConfig.ROOT_PORT}/api/blockchain" + ) + result_blockchain = Blockchain.from_json(result.json()) + self.blockchain.replace_chain(result_blockchain.chain) + logger.info("\n -- Successfully synchronized the local chain") + except ChainLengthException: + logger.info("\n -- Local chain is already up to date") + except Exception as e: + logger.info(f"\n -- Error synchronizing: {e}") + + def seed_data(self): + """ + Seed the blockchain and transaction pool with test data. + """ + for i in range(10): + self.blockchain.add_block( + [ + Transaction( + Wallet(), Wallet().address, random.randint(2, 50) + ).to_json(), + Transaction( + Wallet(), Wallet().address, random.randint(2, 50) + ).to_json(), + ] + ) + + for i in range(3): + transaction = Transaction(Wallet(), Wallet().address, random.randint(2, 50)) + self.pubsub.broadcast_transaction(transaction) + self.transaction_pool.set_transaction(transaction) + + +app_context = AppContext() + diff --git a/backend/app/factory.py b/backend/app/factory.py new file mode 100644 index 0000000..d586195 --- /dev/null +++ b/backend/app/factory.py @@ -0,0 +1,39 @@ +""" +Application factory module. +Creates and configures the Flask application instance. +""" +from flask import Flask +from flask_cors import CORS + +from backend.app.routes import register_routes + + +def create_app(): + """ + Create and configure the Flask application. + + Returns: + Flask application instance with routes and CORS configured + """ + app = Flask(__name__) + + CORS( + app, + resources={ + r"/*": { + "origins": [ + "http://localhost:3000", + "http://localhost:8080", + "http://localhost", + # "http://blockchain.test", + # "https://*.trycloudflare.com", + # TODO: when saving this on MacOS, stop and restart the kubectl tunnels + "https://gale-subaru-fax-interventions.trycloudflare.com", + ] + } + }, + ) + + register_routes(app) + + return app diff --git a/backend/app/logging.py b/backend/app/logging.py new file mode 100644 index 0000000..8a5850d --- /dev/null +++ b/backend/app/logging.py @@ -0,0 +1,14 @@ +import logging +import sys + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("app.log"), + ], +) + +logger = logging.getLogger(__name__) +logger.info("App starting...") diff --git a/backend/app/polling.py b/backend/app/polling.py new file mode 100644 index 0000000..045e4da --- /dev/null +++ b/backend/app/polling.py @@ -0,0 +1,76 @@ +""" +Polling thread module for synchronizing blockchain from root node. +""" +import os +import threading +import time +import requests +from backend.app.logging import logger +from backend.blockchain.blockchain import Blockchain +from backend.blockchain.exceptions import ChainLengthException + +# Shutdown event for graceful thread termination +shutdown_event = threading.Event() + +def poll_root_blockchain(blockchain, root_host, root_port, poll_interval): + """ + Continuously poll the root blockchain node for updates. + + Args: + blockchain: The local blockchain instance to update + root_host: Hostname of the root node + root_port: Port of the root node + poll_interval: Seconds between polling attempts + """ + logger.info( + f"-- Starting polling thread for {root_host}:{root_port} every {poll_interval}s" + ) + + while not shutdown_event.is_set(): + try: + logger.info(f"-- Polling root blockchain from root_host:root_port {root_host}:{root_port}") + result = requests.get(f"http://{root_host}:{root_port}/api/blockchain") + result_blockchain = Blockchain.from_json(result.json()) + blockchain.replace_chain(result_blockchain.chain) + logger.info(f"-- Successfully polled and updated blockchain from {root_host}") + except ChainLengthException: + logger.info("\n -- Local chain is already up to date") + except Exception as e: + logger.info(f"\n -- Error polling root blockchain: {e}") + + # Sleep in 1-second increments to allow quick shutdown + # The allows the thread to check for the shutdown event every second + for _ in range(poll_interval): + if shutdown_event.is_set(): + logger.info("--Shutdown_event detected") + break + time.sleep(1) + + +def start_polling_thread(blockchain): + """ + Start the polling thread if POLL_ROOT environment variable is True. + + Args: + blockchain: The blockchain instance to keep synchronized + + Returns: + The polling thread object, or None if polling is not enabled + """ + if os.environ.get("POLL_ROOT") != "True": + return None + + poll_interval = int(os.environ.get("POLL_INTERVAL", "15")) + root_host = os.environ.get("ROOT_HOST", "localhost") + root_port = int(os.environ.get("ROOT_PORT", "5050")) + + # Start polling in a background daemon thread + polling_thread = threading.Thread( + target=poll_root_blockchain, + args=(blockchain, root_host, root_port, poll_interval), + daemon=False + ) + polling_thread.start() + + return polling_thread + diff --git a/backend/app/routes.py b/backend/app/routes.py new file mode 100644 index 0000000..dad8b46 --- /dev/null +++ b/backend/app/routes.py @@ -0,0 +1,106 @@ +""" +Flask routes module. +Defines all HTTP endpoints for the blockchain API. +""" + +import json +from flask import Flask, jsonify, request, Response + +from backend.app.logging import logger +from backend.app.context import app_context +from backend.wallet.transaction import Transaction + + +def json_response(data, status=200): + """ + Create a JSON response that preserves large integers. + Flask's default jsonify converts large ints to floats, which breaks + cryptographic signatures. This function ensures integers are preserved. + """ + return Response( + json.dumps(data, separators=(",", ":")), + status=status, + mimetype="application/json", + ) + + +def register_routes(app: Flask, prefix="/api"): + """ + Register all routes with the Flask application. + + Args: + app: Flask application instance + """ + # Get references to application context objects for convenience + blockchain = app_context.blockchain + wallet = app_context.wallet + transaction_pool = app_context.transaction_pool + pubsub = app_context.pubsub + + @app.route(f"{prefix}/") + def route_default(): + return "Welcome to the blockchain" + + @app.route(f"{prefix}/blockchain") + def route_blockchain(): + return json_response(blockchain.to_json()) + + @app.route(f"{prefix}/blockchain/range") + def route_blockchain_range(): + # http://localhost:5050/blockchain/range?start=2&end=5 + start = int(request.args.get("start")) + end = int(request.args.get("end")) + + return jsonify(blockchain.to_json()[::-1][start:end]) + + @app.route(f"{prefix}/blockchain/length") + def route_blockchain_length(): + return jsonify(len(blockchain.chain)) + + @app.route(f"{prefix}/blockchain/mine") + def route_blockchain_mine(): + transaction_data = transaction_pool.transaction_data() + transaction_data.append(Transaction.reward_transaction(wallet).to_json()) + blockchain.add_block(transaction_data) + block = blockchain.chain[-1] + pubsub.broadcast_block(block) + transaction_pool.clear_blockchain_transactions(blockchain) + + return json_response(block.to_json()) + + @app.route(f"{prefix}/wallet/transact", methods=["POST"]) + def route_wallet_transact(): + transaction_data = request.get_json() + transaction = transaction_pool.existing_transaction(wallet.address) + + if transaction: + transaction.update( + wallet, transaction_data["recipient"], transaction_data["amount"] + ) + else: + transaction = Transaction( + wallet, transaction_data["recipient"], transaction_data["amount"] + ) + + pubsub.broadcast_transaction(transaction) + transaction_pool.set_transaction(transaction) + + return jsonify(transaction.to_json()) + + @app.route(f"{prefix}/wallet/info") + def route_wallet_info(): + return jsonify({"address": wallet.address, "balance": wallet.balance}) + + @app.route(f"{prefix}/known-addresses") + def route_known_addresses(): + known_addresses = set() + + for block in blockchain.chain: + for transaction in block.data: + known_addresses.update(transaction["output"].keys()) + + return jsonify(list(known_addresses)) + + @app.route(f"{prefix}/transactions") + def route_transactions(): + return jsonify(transaction_pool.transaction_data()) diff --git a/backend/blockchain/__init__.py b/backend/blockchain/__init__.py index e69de29..b874790 100644 --- a/backend/blockchain/__init__.py +++ b/backend/blockchain/__init__.py @@ -0,0 +1,15 @@ +""" +Blockchain package. +Exports blockchain components and custom exceptions. +""" +from backend.blockchain.blockchain import Blockchain +from backend.blockchain.block import Block +from backend.blockchain.exceptions import ChainLengthException, ChainValidationException + +__all__ = [ + "Blockchain", + "Block", + "ChainLengthException", + "ChainValidationException", +] + diff --git a/backend/blockchain/blockchain.py b/backend/blockchain/blockchain.py index 9edf665..5a89fc4 100644 --- a/backend/blockchain/blockchain.py +++ b/backend/blockchain/blockchain.py @@ -1,4 +1,5 @@ from backend.blockchain.block import Block +from backend.blockchain.exceptions import ChainLengthException, ChainValidationException class Blockchain: """ @@ -21,12 +22,12 @@ def replace_chain(self, chain): - The incoming chain is formatted properly. """ if len(chain) <= len(self.chain): - raise Exception('Cannot replace. The incoming chain must be longer.') + raise ChainLengthException('Cannot replace. The incoming chain must be longer.') try: Blockchain.is_valid_chain(chain) except Exception as e: - raise Exception(f'Cannot replace. The incoming chain is invalid: {e}') + raise ChainValidationException(f'Cannot replace. The incoming chain is invalid: {e}') self.chain = chain diff --git a/backend/blockchain/exceptions.py b/backend/blockchain/exceptions.py new file mode 100644 index 0000000..8348ce6 --- /dev/null +++ b/backend/blockchain/exceptions.py @@ -0,0 +1,17 @@ +""" +Custom exceptions for blockchain operations. +""" + + +class ChainLengthException(Exception): + """ + Raised when attempting to replace a chain with one that is not longer. + """ + pass + + +class ChainValidationException(Exception): + """ + Raised when chain validation fails. + """ + pass diff --git a/backend/env.example b/backend/config/.env.example similarity index 99% rename from backend/env.example rename to backend/config/.env.example index 1094f1f..e0327ee 100644 --- a/backend/env.example +++ b/backend/config/.env.example @@ -6,4 +6,3 @@ PUBNUB_PUBLISH_KEY=pub-c-your-actual-publish-key PUBNUB_SUBSCRIBE_KEY=sub-c-your-actual-subscribe-key PUBNUB_USER_ID=blockchain-node-1 - diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..3512dbe --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1,35 @@ +""" + +Configuration package for the blockchain application. +Exports constants and environment-based configuration. +""" +from backend.config.constants import ( + NANOSECONDS, + MICROSECONDS, + MILLISECONDS, + SECONDS, + MINE_RATE, + STARTING_BALANCE, + MINING_REWARD, + MINING_REWARD_INPUT, +) + +from backend.config.env import AppConfig + +__all__ = [ + # Time constants + "NANOSECONDS", + "MICROSECONDS", + "MILLISECONDS", + "SECONDS", + # Blockchain constants + "MINE_RATE", + # Wallet constants + "STARTING_BALANCE", + # Mining constants + "MINING_REWARD", + "MINING_REWARD_INPUT", + # Environment configuration + "AppConfig", +] + diff --git a/backend/config.py b/backend/config/constants.py similarity index 59% rename from backend/config.py rename to backend/config/constants.py index dd2b385..a226f6c 100644 --- a/backend/config.py +++ b/backend/config/constants.py @@ -1,3 +1,7 @@ +""" +Static constants for the blockchain application. +""" + NANOSECONDS = 1 MICROSECONDS = 1000 * NANOSECONDS MILLISECONDS = 1000 * MICROSECONDS @@ -8,4 +12,5 @@ STARTING_BALANCE = 1000 MINING_REWARD = 50 -MINING_REWARD_INPUT = { 'address': '*--official-mining-reward--*' } +MINING_REWARD_INPUT = {'address': '*--official-mining-reward--*'} + diff --git a/backend/config/env.py b/backend/config/env.py new file mode 100644 index 0000000..7d5307e --- /dev/null +++ b/backend/config/env.py @@ -0,0 +1,47 @@ +""" +Environment-based configuration management. +Loads and validates environment variables for the application. +""" +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load environment variables from .env file if it exists +env_path = Path(__file__).parent / ".env" +load_dotenv(dotenv_path=env_path) + +class AppConfig: + """ + Application configuration loaded from environment variables. + """ + + ROOT_PORT = int(os.environ.get("ROOT_PORT", "5050")) + ROOT_HOST = os.environ.get("ROOT_HOST", "localhost") + IS_PEER = os.environ.get("PEER") == "True" + POLL_ROOT = os.environ.get("POLL_ROOT") == "True" + POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "15")) + SEED_DATA = os.environ.get("SEED_DATA") == "True" + + @classmethod + def get_port(cls): + """ + Get the port for this instance. + Peers get a random port, root gets ROOT_PORT. + """ + if cls.IS_PEER: + import random + return random.randint(5051, 6000) + return cls.ROOT_PORT + + @classmethod + def to_dict(cls): + """Return configuration as a dictionary for debugging.""" + return { + "ROOT_PORT": cls.ROOT_PORT, + "ROOT_HOST": cls.ROOT_HOST, + "IS_PEER": cls.IS_PEER, + "POLL_ROOT": cls.POLL_ROOT, + "POLL_INTERVAL": cls.POLL_INTERVAL, + "SEED_DATA": cls.SEED_DATA, + } + diff --git a/backend/pubsub.py b/backend/pubsub.py index 430ed6d..a5d2d2d 100644 --- a/backend/pubsub.py +++ b/backend/pubsub.py @@ -10,16 +10,15 @@ from backend.blockchain.blockchain import Blockchain from backend.wallet.transaction import Transaction +from backend.app.logging import logger + pnconfig = PNConfiguration() -pnconfig.publish_key = os.environ.get('PUBNUB_PUBLISH_KEY') -pnconfig.subscribe_key = os.environ.get('PUBNUB_SUBSCRIBE_KEY') -pnconfig.user_id = os.environ.get('PUBNUB_USER_ID', 'blockchain-node-default') +pnconfig.publish_key = os.environ.get("PUBNUB_PUBLISH_KEY") +pnconfig.subscribe_key = os.environ.get("PUBNUB_SUBSCRIBE_KEY") +pnconfig.user_id = os.environ.get("PUBNUB_USER_ID", "blockchain-node-default") + +CHANNELS = {"TEST": "TEST", "BLOCK": "BLOCK", "TRANSACTION": "TRANSACTION"} -CHANNELS = { - 'TEST': 'TEST', - 'BLOCK': 'BLOCK', - 'TRANSACTION': 'TRANSACTION' -} class Listener(SubscribeCallback): def __init__(self, blockchain, transaction_pool): @@ -27,9 +26,11 @@ def __init__(self, blockchain, transaction_pool): self.transaction_pool = transaction_pool def message(self, pubnub, message_object): - print(f'\n-- Channel: {message_object.channel} | Message: {message_object.message}') + print( + f"-- Channel: {message_object.channel} | Message: {message_object.message}" + ) - if message_object.channel == CHANNELS['BLOCK']: + if message_object.channel == CHANNELS["BLOCK"]: block = Block.from_json(message_object.message) potential_chain = self.blockchain.chain[:] potential_chain.append(block) @@ -37,23 +38,21 @@ def message(self, pubnub, message_object): try: self.blockchain.replace_chain(potential_chain) - self.transaction_pool.clear_blockchain_transactions( - self.blockchain - ) + self.transaction_pool.clear_blockchain_transactions(self.blockchain) - print('\n -- Successfully replaced the local chain') + print("-- Successfully replaced the local chain") except Exception as e: - print(f'\n -- Did not replace chain: {e}') + # print(f'\n -- Did not replace chain: {e}') # If we can't validate the block, try to sync the full blockchain # This handles cases where we're missing previous blocks self.sync_blockchain() - elif message_object.channel == CHANNELS['TRANSACTION']: + elif message_object.channel == CHANNELS["TRANSACTION"]: transaction = Transaction.from_json(message_object.message) self.transaction_pool.set_transaction(transaction) - print('\n -- Set the new transaction in the transaction pool') - + print("-- Set the new transaction in the transaction pool") + def sync_blockchain(self): """ Synchronize the local blockchain with the root node. @@ -61,60 +60,77 @@ def sync_blockchain(self): """ try: # Get the root backend host (main node) - root_host = os.environ.get('ROOT_HOST', 'localhost') - root_port = os.environ.get('ROOT_PORT', '5050') + root_host = os.environ.get("ROOT_HOST", "localhost") + root_port = os.environ.get("ROOT_PORT", "5050") - print(f'\n -- Attempting to sync blockchain from {root_host}:{root_port}') + print(f"-- Attempting to sync blockchain from {root_host}:{root_port}") # Request the full blockchain from the root node - response = requests.get(f'http://{root_host}:{root_port}/blockchain') + response = requests.get(f"http://{root_host}:{root_port}/api/blockchain") result_blockchain = Blockchain.from_json(response.json()) # Replace our local chain with the synchronized chain self.blockchain.replace_chain(result_blockchain.chain) - print(f'\n -- Successfully synchronized! Chain length: {len(self.blockchain.chain)}') + print( + f"-- Successfully synchronized! Chain length: {len(self.blockchain.chain)}" + ) except Exception as e: - print(f'\n -- Could not synchronize blockchain: {e}') + print(f"-- Could not synchronize blockchain: {e}") -class PubSub(): + +class PubSub: """ Handles the publish/subscribe layer of the application. - Provides communication between the nodes of the blockchain network. + Provides communication between blockchain nodes. """ + def __init__(self, blockchain, transaction_pool): + self.blockchain = blockchain + self.transaction_pool = transaction_pool + self.pubnub = None + + def start(self): + """Initialize PubNub and start subscriptions.""" + pnconfig = PNConfiguration() + pnconfig.publish_key = os.environ.get("PUBNUB_PUBLISH_KEY") + pnconfig.subscribe_key = os.environ.get("PUBNUB_SUBSCRIBE_KEY") + pnconfig.user_id = os.environ.get("PUBNUB_USER_ID", "blockchain-node-main") + self.pubnub = PubNub(pnconfig) self.pubnub.subscribe().channels(CHANNELS.values()).execute() - self.pubnub.add_listener(Listener(blockchain, transaction_pool)) + self.pubnub.add_listener(Listener(self.blockchain, self.transaction_pool)) + logger.info("-- PubNub started") + + def stop(self): + """Stop PubNub cleanly.""" + if self.pubnub: + self.pubnub.unsubscribe_all() + self.pubnub.stop() + print("-- PubNub stopped") + self.pubnub = None def publish(self, channel, message): - """ - Publish the message object to the channel. - """ try: result = self.pubnub.publish().channel(channel).message(message).sync() - print(f'\n-- Published to {channel}: {result.status.is_error()}') + print(f"-- Published to {channel}: {result.status.is_error()}") except Exception as e: - print(f'\n-- Error publishing to {channel}: {e}') + print(f"-- Error publishing to {channel}: {e}") def broadcast_block(self, block): - """ - Broadcast a block object to all nodes. - """ - self.publish(CHANNELS['BLOCK'], block.to_json()) + self.publish(CHANNELS["BLOCK"], block.to_json()) def broadcast_transaction(self, transaction): - """ - Broadcast a transaction to all nodes. - """ - self.publish(CHANNELS['TRANSACTION'], transaction.to_json()) + self.publish(CHANNELS["TRANSACTION"], transaction.to_json()) + def main(): pubsub = PubSub() time.sleep(1) - pubsub.publish(CHANNELS['TEST'], { 'foo': 'bar' }) + pubsub.publish(CHANNELS["TEST"], {"foo": "bar"}) + -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/docker-compose.yml b/docker-compose.yml index 19b4fba..1c310db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,19 +6,20 @@ services: ports: - 5050:5050 env_file: - - backend/.env + - backend/config/.env environment: - PUBNUB_USER_ID=node-main - ROOT_HOST=backend-seed - POLL_ROOT=True - POLL_INTERVAL=10 - + stop_grace_period: 20s + backend-peer: build: context: . dockerfile: backend/Dockerfile env_file: - - backend/.env + - backend/config/.env environment: - PUBNUB_USER_ID=node-peer-1 - ROOT_HOST=backend @@ -31,7 +32,7 @@ services: context: . dockerfile: backend/Dockerfile env_file: - - backend/.env + - backend/config/.env environment: - SEED_DATA=True - PUBNUB_USER_ID=node-seed-1 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 1a30e15..6520263 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,10 +1,10 @@ server { - listen 80; - server_name localhost; - root /usr/share/nginx/html; - index index.html; + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; - location / { - try_files $uri $uri/ /index.html; - } + location / { + try_files $uri $uri/ /index.html; + } } \ No newline at end of file diff --git a/frontend/src/components/App.js b/frontend/src/components/App.js index ffa22fc..ab26289 100644 --- a/frontend/src/components/App.js +++ b/frontend/src/components/App.js @@ -7,6 +7,7 @@ function App() { const [walletInfo, setWalletInfo] = useState({}); useEffect(() => { + console.log(`base url: ${API_BASE_URL}`) fetch(`${API_BASE_URL}/wallet/info`) .then(response => response.json()) .then(json => setWalletInfo(json)); diff --git a/frontend/src/config.js b/frontend/src/config.js index 953877c..ec1db5d 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -1,4 +1,5 @@ -const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5050'; +const API_BASE_URL = + process.env.REACT_APP_API_URL || "http://localhost:5050/api"; const NANOSECONDS_PY = 1; const MICROSECONDS_PY = 1000 * NANOSECONDS_PY; const MILLISECONDS_PY = 1000 * MICROSECONDS_PY; From 5799188af6f9af45a25f93b3142b9db54441ac75 Mon Sep 17 00:00:00 2001 From: Roman Matsuiev Date: Sat, 7 Feb 2026 14:49:49 +0200 Subject: [PATCH 3/5] add deployment --- k8s/backend-deployment.yml | 45 +++++++++++++++++++++++++++++++++ k8s/backend-peer-deployment.yml | 42 ++++++++++++++++++++++++++++++ k8s/backend-seed-deployment.yml | 45 +++++++++++++++++++++++++++++++++ k8s/frontend-deployment.yml | 35 +++++++++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 k8s/backend-deployment.yml create mode 100644 k8s/backend-peer-deployment.yml create mode 100644 k8s/backend-seed-deployment.yml create mode 100644 k8s/frontend-deployment.yml diff --git a/k8s/backend-deployment.yml b/k8s/backend-deployment.yml new file mode 100644 index 0000000..0c550d5 --- /dev/null +++ b/k8s/backend-deployment.yml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend +spec: + replicas: 1 + selector: + matchLabels: + app: backend + template: + metadata: + labels: + app: backend + spec: + containers: + - name: backend + image: backend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5050 + env: + - name: PUBNUB_USER_ID + value: node-main + - name: ROOT_HOST + value: backend-seed + - name: POLL_ROOT + value: "True" + - name: POLL_INTERVAL + value: "10" + envFrom: + - secretRef: + name: backend-secret +--- # Service +apiVersion: v1 +kind: Service +metadata: + name: backend +spec: + selector: + app: backend + ports: + - protocol: TCP + port: 5050 + targetPort: 5050 + type: ClusterIP diff --git a/k8s/backend-peer-deployment.yml b/k8s/backend-peer-deployment.yml new file mode 100644 index 0000000..d0aadf2 --- /dev/null +++ b/k8s/backend-peer-deployment.yml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend-peer +spec: + replicas: 6 + selector: + matchLabels: + app: backend-peer + template: + metadata: + labels: + app: backend-peer + spec: + containers: + - name: backend-peer + image: backend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5050 + env: + - name: PUBNUB_USER_ID + value: node-peer-1 + - name: ROOT_HOST + value: backend + envFrom: + - secretRef: + name: backend-secret + +--- # Service +apiVersion: v1 +kind: Service +metadata: + name: backend-peer +spec: + selector: + app: backend-peer + ports: + - protocol: TCP + port: 5050 + targetPort: 5050 + type: ClusterIP \ No newline at end of file diff --git a/k8s/backend-seed-deployment.yml b/k8s/backend-seed-deployment.yml new file mode 100644 index 0000000..a9a9ade --- /dev/null +++ b/k8s/backend-seed-deployment.yml @@ -0,0 +1,45 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend-seed +spec: + replicas: 1 + selector: + matchLabels: + app: backend-seed + template: + metadata: + labels: + app: backend-seed + spec: + containers: + - name: backend-seed + image: backend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5050 + env: + - name: SEED_DATA + value: "True" + - name: PUBNUB_USER_ID + value: node-seed-1 + - name: ROOT_HOST + value: backend + envFrom: + - secretRef: + name: backend-secret +--- # Service +apiVersion: v1 +kind: Service +metadata: + name: backend-seed +spec: + selector: + app: backend-seed + ports: + - protocol: TCP + port: 5050 + targetPort: 5050 + type: ClusterIP + + \ No newline at end of file diff --git a/k8s/frontend-deployment.yml b/k8s/frontend-deployment.yml new file mode 100644 index 0000000..0a5d753 --- /dev/null +++ b/k8s/frontend-deployment.yml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend + image: frontend:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 80 + +--- # Service +apiVersion: v1 +kind: Service +metadata: + name: frontend +spec: + type: NodePort + selector: + app: frontend + ports: + - protocol: TCP + port: 8080 + targetPort: 80 + nodePort: 31288 \ No newline at end of file From 2cff0386c741277f645619c3554ba7a55a025b65 Mon Sep 17 00:00:00 2001 From: Roman Matsuiev Date: Sun, 8 Feb 2026 08:42:59 +0200 Subject: [PATCH 4/5] add skaffold --- skaffold.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 skaffold.yaml diff --git a/skaffold.yaml b/skaffold.yaml new file mode 100644 index 0000000..e983891 --- /dev/null +++ b/skaffold.yaml @@ -0,0 +1,18 @@ +apiVersion: skaffold/v4beta13 +kind: Config +metadata: + name: python-blockchain-tutorial +build: + artifacts: + - image: backend + context: . + docker: + dockerfile: backend/Dockerfile + + - image: frontend + context: . + docker: + dockerfile: frontend/Dockerfile +manifests: + rawYaml: + - k8s/*.yml From cc17351ec6e93270ccbee35da5b2ffe069c6a7aa Mon Sep 17 00:00:00 2001 From: Roman Matsuiev Date: Sun, 8 Feb 2026 09:53:05 +0200 Subject: [PATCH 5/5] add ingress --- frontend/src/config.js | 3 +-- k8s/hello-pod.yml | 25 +++++++++++++++++++++++++ k8s/ingress.yml | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 k8s/hello-pod.yml create mode 100644 k8s/ingress.yml diff --git a/frontend/src/config.js b/frontend/src/config.js index ec1db5d..8f2c42e 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -1,5 +1,4 @@ -const API_BASE_URL = - process.env.REACT_APP_API_URL || "http://localhost:5050/api"; +const API_BASE_URL = `${window.location.origin}/api`; const NANOSECONDS_PY = 1; const MICROSECONDS_PY = 1000 * NANOSECONDS_PY; const MILLISECONDS_PY = 1000 * MICROSECONDS_PY; diff --git a/k8s/hello-pod.yml b/k8s/hello-pod.yml new file mode 100644 index 0000000..efcc94e --- /dev/null +++ b/k8s/hello-pod.yml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: Pod +metadata: + name: hello-pod + labels: + app: hello +spec: + containers: + - name: hello + image: hashicorp/http-echo + args: + - "-text=Hello from Kubernetes" + ports: + - containerPort: 5678 +--- +apiVersion: v1 +kind: Service +metadata: + name: hello-service +spec: + selector: + app: hello + ports: + - port: 80 + targetPort: 5678 diff --git a/k8s/ingress.yml b/k8s/ingress.yml new file mode 100644 index 0000000..7ae90b7 --- /dev/null +++ b/k8s/ingress.yml @@ -0,0 +1,33 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: frontend-ingress + namespace: default + annotations: + nginx.ingress.kubernetes.io/use-regex: "true" +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: /api(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: backend + port: + number: 5050 + - path: /(.*) + pathType: ImplementationSpecific + backend: + service: + name: frontend + port: + number: 80 + - path: /health + pathType: Prefix + backend: + service: + name: hello-service + port: + number: 80