"""MyCoin Web Dashboard — Flask application for blockchain visualization and interaction. DEPLOYMENT_REBUILD_2026_08_07_22_15_BULLETPROOF_WALLET_CACHE_BUST This module provides a RESTful API and web interface for MyCoin, allowing users to: - Generate new wallet addresses - Check wallet balances - View blockchain information - Mine blocks with configurable parameters - Monitor mining progress in real-time Educational use only — never holds real funds. """ from __future__ import annotations import hmac import json import os import re import secrets import sys import threading import time from collections import defaultdict from functools import wraps from typing import Optional from flask import ( Flask, Response, jsonify, make_response, redirect, render_template, request, send_file, send_from_directory, session, url_for, ) from werkzeug.middleware.proxy_fix import ProxyFix # These were dropped when the modules were missing from the tree, which # left ~31 references to them raising NameError at runtime — swallowed by # fail-safe excepts, so merchant lookups silently returned zero and the # swap verifier 500'd. The merge restored the modules; restore the imports. import exchange import forum import forum_emoji import merchants import live_explorer import price_feed import storage import swap_verifier import wall import wallet_history import worldcup from node import Node from store import BlockStore from transaction import Transaction, generate_keypair, pubkey_hash from wallet import (address_from_pubkey_hash, is_valid_address, pubkey_hash_from_address) # Pragmatic email validation for the listing-notify capture. EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") # Durable block store. The node persists every mined block and replays them on # startup, so the chain survives a restart or redeploy. Persistence never # changes consensus: reload replays each block through full validation. # # On a host with a volume this defaults on, because the alternative is a coin # whose entire history is deleted by its next deploy — every block, every # balance, back to height 0. Without a volume it stays off, so local runs and # tests still get a clean in-memory chain unless MOONBITE_CHAIN_DB says # otherwise. _CHAIN_DB = os.environ.get("MOONBITE_CHAIN_DB", "").strip() or None if _CHAIN_DB is None and storage.is_persistent(): _CHAIN_DB = storage.data_path("chain.db") elif _CHAIN_DB is not None: # Honour an explicit path, but make sure its directory exists first. _CHAIN_DB = storage.data_path(_CHAIN_DB, "MOONBITE_CHAIN_DB") _chain_store: Optional["BlockStore"] = None app = Flask(__name__, template_folder="templates", static_folder="static") # Wallet code must not be frozen in caches; serve static assets fresh. app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # Disable template caching to ensure updates are served immediately app.config['TEMPLATES_AUTO_RELOAD'] = True app.jinja_env.auto_reload = True # Signs the session cookie that scopes per-visitor wallet state. Set SECRET_KEY # in production so sessions survive restarts and validate across gunicorn workers. # A random per-process key is a safe DEV default, but in production it silently # breaks: cookies signed by one worker fail on another, and every restart resets # all sessions. So fail fast if this looks like production and SECRET_KEY is unset. _secret_key_env = os.environ.get("SECRET_KEY") if _secret_key_env: app.secret_key = _secret_key_env else: _looks_production = bool( os.environ.get("FLASK_ENV") == "production" or os.environ.get("MOONBITE_ENV") == "production" or os.environ.get("BIGCOIN_RPC_URL") # a live node is configured or os.environ.get("BIGCOIN_RPC_PASSWORD") ) if _looks_production and os.environ.get("MOONBITE_ALLOW_EPHEMERAL_SECRET") != "1": raise RuntimeError( "SECRET_KEY is not set but this looks like a production deploy (a live " "node RPC is configured). An ephemeral key breaks sessions across " "gunicorn workers and on every restart. Set SECRET_KEY to a persistent " "random value (e.g. `python -c \"import secrets;print(secrets.token_hex(32))\"`), " "or set MOONBITE_ALLOW_EPHEMERAL_SECRET=1 to override for a one-off run." ) app.secret_key = secrets.token_hex(32) app.logger.warning( "SECRET_KEY not set; using an ephemeral per-process key. Sessions will not " "persist across restarts or gunicorn workers. Set SECRET_KEY in production." ) # The session cookie only scopes per-visitor UI state; it must never carry a # secret. Even so, harden it: Secure keeps it off any plaintext hop, HttpOnly # keeps it out of reach of page script, and SameSite=Lax blunts CSRF on the # state-changing POST routes. HTTPS-only in production; allow plain HTTP when # a developer runs the app locally without TLS. app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=os.environ.get("MOONBITE_INSECURE_COOKIES") != "1", ) # Trust exactly TRUSTED_PROXY_COUNT reverse-proxy hop(s) in front (nginx = 1). # ProxyFix rewrites request.remote_addr to the client IP that our OWN proxy # observed (the rightmost X-Forwarded-For hop it appended), so a client cannot # forge its identity by sending extra XFF hops. Set to 0 if no proxy is present. _TRUSTED_PROXY_COUNT = int(os.environ.get("TRUSTED_PROXY_COUNT", "1")) if _TRUSTED_PROXY_COUNT > 0: app.wsgi_app = ProxyFix( app.wsgi_app, x_for=_TRUSTED_PROXY_COUNT, x_proto=1, x_host=1 ) # Force HTTPS (both moonbite.org and www.moonbite.org work equally) @app.before_request def force_https(): """Force HTTPS in production. Both moonbite.org and www.moonbite.org work equally.""" # Force HTTPS in production if os.environ.get("RAILWAY_ENVIRONMENT") == "production": if request.headers.get("X-Forwarded-Proto", "http") != "https": url = request.url.replace("http://", "https://", 1) return redirect(url, code=301) # Initialize databases on first request (for production gunicorn deploys) _schemas_initialized = False @app.before_request def init_schemas(): global _schemas_initialized if not _schemas_initialized: try: wallet_history.create_schema() _schemas_initialized = True except Exception as e: print(f"[init_schemas] Warning: {e}", flush=True) # Hard cap on request body size. Flask/Werkzeug default to unlimited, so without # this a single POST with a multi-hundred-MB body forces the worker to buffer + # JSON-parse it — ~2.8x amplification measured, i.e. one 400 MB request spikes # the process past 1 GB RAM and OOM-kills a small container. Every legitimate # API body here (notify, orders, invoices, tx broadcast) is well under 256 KB, # so Werkzeug rejects anything larger with 413 BEFORE reading the payload. _MAX_CONTENT_LENGTH = int(os.environ.get("MOONBITE_MAX_BODY_BYTES", str(256 * 1024))) app.config["MAX_CONTENT_LENGTH"] = _MAX_CONTENT_LENGTH # Global state for mining operations - queue-based for concurrent mining import queue import uuid app.mining_state = { "active_jobs": {}, # job_id -> {is_mining, blocks_to_mine, blocks_mined, ...} "job_queue": queue.Queue(), # Queue of pending mining jobs "total_blocks_mined": 0, # Total blocks mined across all jobs } # How long a finished mining job stays readable in /api/mining/status before it # is dropped. Long enough that a UI polling every few seconds still sees the # final block count, short enough that the job dict cannot grow without bound. _FINISHED_JOB_RETENTION_SEC = 60 # Global node instance (initialized once per app instance) app.node: Optional[Node] = None # Per-visitor wallet addresses are stored in the signed session cookie (see # /api/wallet/new), capped so the cookie cannot grow without bound. _MAX_SESSION_ADDRESSES = 25 # Upper bound on blocks a single /api/mining/start request may enqueue. Without # a cap, one request could ask for billions of blocks and tie up a worker's CPU # indefinitely (a self-inflicted DoS). 100 is plenty for the demo reactor. _MAX_MINE_BLOCKS = 100 # Ceiling on simultaneously running mining jobs. Proof-of-work is CPU-bound # and this host is small, so unbounded concurrency is a denial-of-service. _MAX_CONCURRENT_MINING_JOBS = int(os.environ.get("MOONBITE_MAX_MINING_JOBS", "2")) # Lock for thread-safe mining and blockchain operations app.mining_lock = threading.Lock() app.blockchain_lock = threading.Lock() # Protect blockchain.add_block() # --------------------------------------------------------------------------- # # Lightweight in-process rate limiting (no external store, no third parties). # # A fixed-window counter per (client, endpoint) protects the write endpoints # (wallet minting, mining, notify, order/merchant creation) from casual abuse. # Trusted integrations can be issued an API key (MOONBITE_API_KEYS, comma-sep) # and pass it as X-API-Key to bypass the limits. This is deliberately simple: # it is anti-abuse for a single-node demo, not a distributed quota system. # --------------------------------------------------------------------------- # _API_KEYS = { k.strip() for k in os.environ.get("MOONBITE_API_KEYS", "").split(",") if k.strip() } # Off under pytest (many calls share one client IP) and whenever explicitly # disabled; real deployments keep it on. A dedicated test flips it back on to # assert the limiter actually fires. _RATE_DISABLED = ( os.environ.get("MOONBITE_DISABLE_RATELIMIT", "") == "1" or "PYTEST_CURRENT_TEST" in os.environ or "pytest" in sys.modules ) _rl_lock = threading.Lock() _rl_hits: "defaultdict[tuple, list]" = defaultdict(list) def _client_id() -> str: """Caller identity for rate limiting. ProxyFix has already resolved request.remote_addr to the client IP our trusted proxy observed, so we do NOT parse X-Forwarded-For here — a client cannot spoof this by adding XFF hops.""" return request.remote_addr or "unknown" # Restored after being replaced by a pass-through stub in July, labelled # "TEMPORARILY DISABLED FOR BITCOIN ALGORITHM TESTING". It shipped that way, # so every rate-limited route on the live site was uncapped for a month: # the email capture, wallet creation, mining, the wall. Three security # regression tests had been failing the whole time. def rate_limit(max_calls: int, window_seconds: int = 60): """Cap a route at `max_calls` per rolling `window_seconds` per client. A valid X-API-Key (configured via MOONBITE_API_KEYS) bypasses the cap. """ def decorator(fn): @wraps(fn) def wrapper(*args, **kwargs): has_valid_key = bool(_API_KEYS) and request.headers.get("X-API-Key", "") in _API_KEYS if _RATE_DISABLED or has_valid_key: return fn(*args, **kwargs) key = (_client_id(), fn.__name__) now = time.time() with _rl_lock: hits = _rl_hits[key] cutoff = now - window_seconds hits[:] = [t for t in hits if t > cutoff] if len(hits) >= max_calls: retry = int(window_seconds - (now - hits[0])) + 1 resp = jsonify({ "status": "error", "message": f"rate limit exceeded ({max_calls}/{window_seconds}s)", "retry_after": retry, }) resp.headers["Retry-After"] = str(retry) return resp, 429 hits.append(now) return fn(*args, **kwargs) return wrapper return decorator # --------------------------------------------------------------------------- # # Error Standardization — Consistent API error responses # --------------------------------------------------------------------------- # # Standard error code definitions for wallet and blockchain operations ERRORS = { # Validation errors "VALIDATION_INVALID_ADDRESS": { "user_message": "Invalid address format", "http_status": 400, }, "VALIDATION_INSUFFICIENT_BALANCE": { "user_message": "Insufficient balance for this transaction", "http_status": 400, }, "VALIDATION_INVALID_AMOUNT": { "user_message": "Invalid or negative amount specified", "http_status": 400, }, "VALIDATION_INVALID_MNEMONIC": { "user_message": "Invalid seed phrase (must be valid BIP39 mnemonic)", "http_status": 400, }, "VALIDATION_MISSING_FIELD": { "user_message": "Required field is missing from request", "http_status": 400, }, # Network/sync errors "NETWORK_NOT_SYNCED": { "user_message": "Blockchain is still syncing, please wait", "http_status": 503, }, "NETWORK_TX_REJECTED": { "user_message": "Transaction was rejected by the network", "http_status": 400, }, "NETWORK_OFFLINE": { "user_message": "Unable to reach the blockchain (offline mode)", "http_status": 503, }, "NETWORK_CONNECTION_ERROR": { "user_message": "Connection error, retrying...", "http_status": 503, }, # Security errors "SECURITY_SESSION_EXPIRED": { "user_message": "Session has expired, please reload", "http_status": 401, }, "SECURITY_INVALID_PASSWORD": { "user_message": "Incorrect password", "http_status": 401, }, "SECURITY_RATE_LIMITED": { "user_message": "Too many requests, please wait before trying again", "http_status": 429, }, # Storage/persistence errors "STORAGE_QUOTA_EXCEEDED": { "user_message": "Local storage quota exceeded", "http_status": 507, }, "STORAGE_CORRUPTED": { "user_message": "Local data is corrupted, unable to proceed", "http_status": 500, }, # General errors "INTERNAL_ERROR": { "user_message": "An unexpected error occurred", "http_status": 500, }, } def json_error( error_code: str, user_message: Optional[str] = None, debug_message: Optional[str] = None, suggested_action: Optional[str] = None, status_code: Optional[int] = None, ) -> tuple: """Create a standardized error response. Args: error_code: Unique error identifier (e.g., "VALIDATION_INVALID_ADDRESS") user_message: User-friendly message (overrides default if provided) debug_message: Developer-focused debug info (only if debug=true in request) suggested_action: What the user should do to recover status_code: HTTP status code (overrides error definition if provided) Returns: Tuple of (response_dict, http_status_code) for Flask to return """ error_def = ERRORS.get(error_code, ERRORS["INTERNAL_ERROR"]) http_status = status_code or error_def.get("http_status", 500) message = user_message or error_def.get("user_message", "An error occurred") response = { "status": "error", "error_code": error_code, "message": message, "timestamp": time.time(), } if suggested_action: response["action"] = suggested_action # Include debug info only if requested and in non-production or explicitly enabled if debug_message and os.environ.get("FLASK_DEBUG") == "1": response["debug"] = debug_message return jsonify(response), http_status def get_node() -> Node: """Get or create the global node instance. When a durable store is configured (MOONBITE_CHAIN_DB), stored blocks are replayed into the fresh node on first creation so the chain persists across restarts. Each block is re-validated by `add_block`, so a tampered store is rejected rather than trusted. """ global _chain_store if app.node is None: app.node = Node("web-app", coinbase_maturity=0) if _CHAIN_DB is not None: _chain_store = BlockStore(_CHAIN_DB) genesis_hash = app.node.chain.tip replayed = 0 for block in _chain_store.load_blocks_in_height_order(): if block.hash == genesis_hash: continue try: if app.node.chain.add_block(block): replayed += 1 except Exception as e: # noqa: BLE001 — skip any invalid stored block print(f"Skipping unloadable block {block.hash[:12]}: {e}") if replayed: print(f"Replayed {replayed} persisted block(s); height={app.node.chain.height}") return app.node def _persist_block(block) -> None: """Save a freshly mined block to the durable store, if one is configured.""" if _chain_store is not None and block is not None: try: _chain_store.save_block(block, get_node().chain.heights[block.hash]) except Exception as e: # noqa: BLE001 — persistence must never break mining print(f"Persist error for block {block.hash[:12]}: {e}") def received_at_address(address: str) -> int: """Total base units ever paid to `address` across the active chain. Watch-only style, monotonic (never drops when the merchant later spends), so it is a stable baseline for detecting a specific inbound invoice payment. Non-custodial: we only *observe* the chain; we never move or hold funds. """ try: pkh = pubkey_hash_from_address(address) except Exception: return 0 node = get_node() chain = node.chain total = 0 for block_hash in chain.active_chain(): block = chain.blocks[block_hash] for tx in block.transactions: for out in tx.outputs: if out.pubkey_hash == pkh: total += out.amount return total # --------------------------------------------------------------------------- # # Merchant payment observation — production node (JSON-RPC) backend # # The merchant layer needs a received_lookup(address) -> base units. In the demo # it reads the in-process educational chain (received_at_address above). Against # the real moonbited node we ask it directly. A stock node has no address index, # so we use `scantxoutset`, which reports the address's *current unspent* balance. # That is enough to detect a fresh invoice payment (a new inbound total over the # baseline snapshotted when the invoice was raised), provided the merchant does # not spend from the receive address mid-invoice — so give each invoice/merchant # a dedicated receive address. # --------------------------------------------------------------------------- # _merchant_rpc_client = None _merchant_rpc_lock = threading.Lock() _merchant_recv_cache: dict = {} # scantxoutset walks the whole UTXO set (seconds, single-scan-at-a-time), so a # short cache keeps rapid invoice-status polls from hammering the node. _MERCHANT_RECV_TTL = float(os.environ.get("MOONBITE_MERCHANT_SCAN_TTL", "5")) # --------------------------------------------------------------------------- # # Global scantxoutset budget (audit H2). # # Every public route that ends in a UTXO scan (invoice status/QR, the wallet's # /api/chain/address, merchant list/poll) shares ONE process-wide budget so N # attacker-generated addresses cannot pin the node with back-to-back full # scans. Two layers: a semaphore caps in-flight scans, and a token bucket caps # the scan *rate* across all callers. When the budget is exhausted the caller # gets the last cached value for that address if there is one, otherwise a # ScanBudgetExhausted the route turns into 503 + Retry-After. Per-process: # with gunicorn --workers 2 the effective site-wide cap is twice these numbers. # --------------------------------------------------------------------------- # _SCAN_MAX_CONCURRENT = int(os.environ.get("MOONBITE_SCAN_MAX_CONCURRENT", "2")) _SCAN_BUCKET_CAPACITY = int(os.environ.get("MOONBITE_SCAN_BUCKET_CAPACITY", "6")) _SCAN_BUCKET_WINDOW = float(os.environ.get("MOONBITE_SCAN_BUCKET_WINDOW", "10")) _scan_semaphore = threading.BoundedSemaphore(max(1, _SCAN_MAX_CONCURRENT)) _scan_bucket_lock = threading.Lock() _scan_bucket_tokens = float(_SCAN_BUCKET_CAPACITY) _scan_bucket_last = time.time() class ScanBudgetExhausted(Exception): """The process-wide scantxoutset budget is spent; retry after .retry_after s.""" def __init__(self, retry_after: int = 5): super().__init__("chain scan budget exhausted") self.retry_after = max(1, int(retry_after)) def _scan_bucket_take() -> None: """Take one scan token or raise ScanBudgetExhausted (never blocks).""" global _scan_bucket_tokens, _scan_bucket_last if _SCAN_BUCKET_CAPACITY <= 0: return # budget disabled by operator refill_rate = _SCAN_BUCKET_CAPACITY / max(0.001, _SCAN_BUCKET_WINDOW) with _scan_bucket_lock: now = time.time() _scan_bucket_tokens = min( float(_SCAN_BUCKET_CAPACITY), _scan_bucket_tokens + (now - _scan_bucket_last) * refill_rate, ) _scan_bucket_last = now if _scan_bucket_tokens < 1.0: wait = (1.0 - _scan_bucket_tokens) / refill_rate raise ScanBudgetExhausted(retry_after=int(wait) + 1) _scan_bucket_tokens -= 1.0 def _scan_budget_reset() -> None: """Test hook: refill the bucket.""" global _scan_bucket_tokens, _scan_bucket_last with _scan_bucket_lock: _scan_bucket_tokens = float(_SCAN_BUCKET_CAPACITY) _scan_bucket_last = time.time() def _budgeted_scantxoutset(rpc, address: str): """Run ONE scantxoutset for `address` under the global budget. Raises ScanBudgetExhausted when either the rate bucket is empty or no concurrency slot frees up within a short wait; propagates RPC errors.""" _scan_bucket_take() if not _scan_semaphore.acquire(timeout=2.0): raise ScanBudgetExhausted(retry_after=3) try: return rpc.scantxoutset("start", [{"desc": f"addr({address})"}]) finally: _scan_semaphore.release() def _merchant_use_rpc() -> bool: """True when the operator has pointed us at a real node (RPC creds/URL set) and has not forced demo mode. Unset in dev/tests -> stay on the demo chain. NOTE: production should also set DEMO_MODE=0 so the RPC client keeps retrying the node on a transient outage instead of latching into demo sample data. """ if os.environ.get("DEMO_MODE", "").strip().lower() in ("1", "true", "yes", "on"): return False return bool( os.environ.get("BIGCOIN_RPC_URL") or os.environ.get("BIGCOIN_RPC_USER") or os.environ.get("BIGCOIN_RPC_PASSWORD") ) # --------------------------------------------------------------------------- # # Retire the pre-launch educational (toy) chain routes once the site is backed # by the real MoonBite Core node. These routes mined fake coins and reported # toy-chain balances/leaderboards as if real; when _merchant_use_rpc() is true # they are retired so a visitor can never be misled. The honest live set # (/api/blockchain/info, /api/explorer/*, /api/mempool, /api/consensus, the # explorer/halving/worldcup pages, downloads, static) is untouched. # --------------------------------------------------------------------------- # _RETIRED_PAGE_REDIRECTS = {"/start": "/mine", "/mining": "/mine", "/leaderboard": "/mine"} _RETIRED_API_PREFIXES = ( "/api/mining/", "/api/mine", "/api/wallet/new", "/api/wallet/balance", "/api/wallet/backup", "/api/wallet/transaction", "/api/transactions", "/api/blockchain/status", "/api/tx/broadcast", # Mock stubs that fabricate fund-relevant data on the live site: the plural # /api/merchants/* invoice mock returns an unspendable payment address, and # /api/hardware-wallet/* returns fake signatures. Retire them in live mode # so nothing is misled; the REAL merchant API is singular /api/merchant/* # (merchants.py) and does not match these prefixes. "/api/merchants/", "/api/hardware-wallet/", ) def _is_retired_api(path: str, method: str = "GET") -> bool: if path == "/api/mine" or any(path.startswith(pfx) for pfx in _RETIRED_API_PREFIXES): return True if path.startswith("/api/address/") and path.endswith("/balance"): return True # The account endpoints that report a balance sum the demo chain's UTXO # set, so on the live node they answer 0 for an address holding real # coins — and the balance route writes that 0 back into the account DB. # Their sibling /api/wallet/balance was retired; these were missed. Only # the reads go: create/import/update/switch/delete touch the local # account DB alone and are unaffected by which chain is live. # HEAD counts as a read: Flask adds it to every GET route and runs the # full view (discarding only the body), so a HEAD that slipped past this # guard would still execute the balance route's DB write. if method in ("GET", "HEAD"): if path == "/api/wallet/accounts": return True if path.startswith("/api/wallet/accounts/"): rest = path[len("/api/wallet/accounts/"):] if rest.endswith("/balance") or "/" not in rest: return True return False @app.before_request def retire_demo_chain_routes(): # Only when the site is live on the real node; dev/demo keeps the toy chain. if not _merchant_use_rpc(): return path = request.path p = path[:-1] if (len(path) > 1 and path.endswith("/")) else path if p in _RETIRED_PAGE_REDIRECTS: return redirect(_RETIRED_PAGE_REDIRECTS[p], code=302) if _is_retired_api(p, request.method): return jsonify({ "status": "error", "message": ("This endpoint ran on the pre-launch demo chain and has " "been retired. MoonBite is live - mine with the real node: " "https://moonbite.org/mine"), }), 410 def _get_merchant_rpc(): """Lazily build the explorer RPC client. explorer/rpc.py uses a bare ``import config``, so the explorer dir must be on sys.path (mirrors the settlement-verifier route).""" global _merchant_rpc_client if _merchant_rpc_client is None: _exp_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "explorer") if _exp_dir not in sys.path: sys.path.insert(0, _exp_dir) from explorer.rpc import RpcClient _merchant_rpc_client = RpcClient() return _merchant_rpc_client # Real MoonBite addresses come in several forms - segwit bech32 (moon1...), MWEB # (moonmweb1...) and base58 (M... / 3...). The educational in-process wallet's # is_valid_address recognises only its own bech32 scheme and REJECTS the moon1q... # addresses the production node actually issues, so any user-facing validation on # the live node must defer to the node, not that helper. _ADDR_HRP = os.environ.get("MOONBITE_ADDRESS_HRP", "moon").strip().lower() def _looks_like_moonbite_address(address: str) -> bool: """Cheap format gate for this network, used ONLY as a fallback when the node cannot be reached. The node is the real authority.""" a = (address or "").strip() low = a.lower() if low.startswith("tmoon") or low.startswith("rmoon"): # wrong networks return False if low.startswith(_ADDR_HRP + "1") or low.startswith("moonmweb1"): return bool(re.fullmatch(r"[a-z0-9]{14,90}", low)) # bech32-ish body if a[:1] in ("M", "3"): # base58 P2PKH / P2SH return bool(re.fullmatch(r"[1-9A-HJ-NP-Za-km-z]{25,40}", a)) return False def _valid_receiving_address(address: str) -> bool: """True if `address` is valid on the network this site serves. Live: the node's validateaddress RPC is the authority - it accepts the real segwit and base58 addresses users hold and rejects wrong-network / malformed ones, which the educational is_valid_address cannot. Demo/dev: the in-process chain uses is_valid_address. If the node is unreachable, fall back to a format gate so a transient blip neither blocks a valid address nor waves through junk. """ address = (address or "").strip() if not address: return False if _merchant_use_rpc(): try: info = _get_merchant_rpc().call("validateaddress", address) return bool(isinstance(info, dict) and info.get("isvalid")) except Exception: # noqa: BLE001 — a node blip must not hard-fail a valid address return _looks_like_moonbite_address(address) return is_valid_address(address) def received_at_address_rpc(address: str) -> int: """Current unspent balance at `address` from the production node, in base units (merchants.UNITS_PER_COIN). Fail-safe: ANY node error returns 0 so an invoice is never falsely marked paid — it just stays pending and is retried on the next poll. Budgeted (audit H2): the scan goes through the global scantxoutset budget. When that is exhausted we serve the last cached value for the address even if stale; with no cache entry we raise ScanBudgetExhausted rather than return 0, because a fabricated 0 baseline at invoice creation would let pre-existing funds satisfy the new invoice.""" from decimal import Decimal now = time.time() with _merchant_rpc_lock: hit = _merchant_recv_cache.get(address) if hit is not None and now - hit[0] < _MERCHANT_RECV_TTL: return hit[1] try: client = _get_merchant_rpc() result = _budgeted_scantxoutset(client, address) except ScanBudgetExhausted: if hit is not None: return hit[1] # stale but honest: the node's last answer raise except Exception: # noqa: BLE001 — node down/auth/demo => observe nothing return 0 if not isinstance(result, dict) or not result.get("success", True): return 0 try: units = int( (Decimal(str(result.get("total_amount", 0))) * merchants.UNITS_PER_COIN) .to_integral_value() ) except Exception: # noqa: BLE001 — malformed amount return 0 with _merchant_rpc_lock: _merchant_recv_cache[address] = (time.time(), units) return units def _scan_budget_response(exc): """503 + Retry-After for a route whose chain scan hit the global budget.""" resp = jsonify({ "status": "error", "message": "chain scan budget exhausted, retry shortly", "retry_after": exc.retry_after, }) resp.headers["Retry-After"] = str(exc.retry_after) return resp, 503 def merchant_received_lookup(address: str) -> int: """The received_lookup handed to the merchant layer: production node when configured, else the in-process educational chain.""" if _merchant_use_rpc(): return received_at_address_rpc(address) return received_at_address(address) def mining_worker(job_id: str, blocks_to_mine: int, miner_address: str) -> None: """Background worker thread for mining blocks (concurrent-safe).""" print(f"[MINING] Job {job_id}: Starting mining {blocks_to_mine} blocks for {miner_address}", flush=True) node = get_node() # Job-specific state job_state = { "job_id": job_id, "is_mining": True, "blocks_to_mine": blocks_to_mine, "blocks_mined": 0, "current_block_height": node.chain.height, "hashes_tried": 0, "hashrate": 0.0, "started_at": time.time(), "mining_address": miner_address, "last_error": None, } with app.mining_lock: app.mining_state["active_jobs"][job_id] = job_state for i in range(blocks_to_mine): with app.mining_lock: if not app.mining_state["active_jobs"].get(job_id, {}).get("is_mining", False): print(f"[MINING] Job {job_id}: Mining cancelled", flush=True) break try: print(f"[MINING] Job {job_id}: Mining block {i+1}/{blocks_to_mine}", flush=True) # Lock blockchain access - mine_block() calls chain.add_block() which needs serialization with app.blockchain_lock: block = node.mine_block(miner_address) if block is not None: print(f"[MINING] Job {job_id}: Block mined! Hash: {block.hash}, Nonce: {block.header.nonce}, Txs: {len(block.transactions)}", flush=True) _persist_block(block) else: print(f"[MINING] Job {job_id}: mine_block() returned None - mining failed or block rejected", flush=True) if block is not None: # Update job state (outside blockchain lock to avoid holding lock too long) job_state["hashes_tried"] += block.header.nonce + 1 elapsed = max(1e-6, time.time() - job_state["started_at"]) job_state["hashrate"] = job_state["hashes_tried"] / elapsed job_state["blocks_mined"] = i + 1 job_state["current_block_height"] = node.chain.height job_state["last_error"] = None with app.mining_lock: app.mining_state["total_blocks_mined"] += 1 app.mining_state["active_jobs"][job_id] = job_state print(f"[MINING] Job {job_id}: Progress {i+1}/{blocks_to_mine} blocks mined, Hashrate: {job_state['hashrate']:.2f} H/s", flush=True) else: job_state["last_error"] = "Mining failed or block rejected" print(f"[MINING] Job {job_id}: Stopping mining - block was None", flush=True) with app.mining_lock: app.mining_state["active_jobs"][job_id] = job_state break except Exception as e: import traceback error_msg = f"{str(e)}\n{traceback.format_exc()}" print(f"[MINING] Job {job_id}: ERROR - {error_msg}", flush=True) job_state["last_error"] = str(e) with app.mining_lock: app.mining_state["active_jobs"][job_id] = job_state break job_state["is_mining"] = False # Stamped so /api/mining/status can retire the job once a poller has had a # chance to read its final counts. job_state["finished_at"] = time.time() job_state["hashrate"] = 0.0 with app.mining_lock: app.mining_state["active_jobs"][job_id] = job_state print(f"[MINING] Job {job_id}: Mining finished. Blocks mined: {job_state['blocks_mined']}", flush=True) # ============================================================================= # # Routes # ============================================================================= # _LIVE_HEIGHT_CACHE: dict = {"height": None, "at": 0.0} _LIVE_HEIGHT_TTL = 10.0 def _live_chain_height(): """Tip height from the real node, cached for a few seconds. _consensus_dict() runs on every template render, so an uncached call here would put a node round trip in front of every page on the site. Returns None when the node cannot be reached and nothing is cached yet: callers must report that as unknown rather than substituting a number. """ now = time.time() cached = _LIVE_HEIGHT_CACHE["height"] if cached is not None and (now - _LIVE_HEIGHT_CACHE["at"]) < _LIVE_HEIGHT_TTL: return cached try: height = int(_get_merchant_rpc().getblockcount()) except Exception: # noqa: BLE001 — a down node must not break page renders return cached # last known, or None _LIVE_HEIGHT_CACHE["height"] = height _LIVE_HEIGHT_CACHE["at"] = now return height def _consensus_dict() -> dict: """One truthful snapshot of the coin's monetary rules + live reward. Injected into every template and served at /api/consensus so no page ever copy-pastes a consensus number again. """ from block import block_subsidy from params import (CENTS_PER_COIN, HALVING_INTERVAL, INITIAL_SUBSIDY, MAX_SUPPLY, TARGET_BLOCK_TIME) if _merchant_use_rpc(): # The real chain is the only honest source once the site is live. The # demo chain sits at height 0 forever, and reporting that next to a # masthead reading "Block 6,636" is exactly the kind of contradiction # a visitor checks us on. None here means "not known right now". height = _live_chain_height() else: try: height = get_node().chain.height except Exception: height = 0 # Schedule maths needs a number; the reported height keeps its None. h = height or 0 halving_years = HALVING_INTERVAL * TARGET_BLOCK_TIME / 31_557_600 # Year the subsidy decays to zero (33 halvings for a 50-coin start). eras = 0 s = INITIAL_SUBSIDY while s > 0: s >>= 1 eras += 1 return { "initial_subsidy_coins": INITIAL_SUBSIDY // CENTS_PER_COIN, "current_reward_coins": block_subsidy(h + 1) / CENTS_PER_COIN, "halving_interval": HALVING_INTERVAL, "next_halving_height": ((h // HALVING_INTERVAL) + 1) * HALVING_INTERVAL, "block_time_sec": TARGET_BLOCK_TIME, "block_time_min": TARGET_BLOCK_TIME // 60, "blocks_per_day": 86_400 // TARGET_BLOCK_TIME, # Current subsidy, not the genesis one: after the first halving the # genesis figure would overstate daily emission by 2x. "daily_emission_coins": (86_400 // TARGET_BLOCK_TIME) * (block_subsidy(h + 1) / CENTS_PER_COIN), "max_supply_coins": MAX_SUPPLY / CENTS_PER_COIN, "max_supply_label": "~33,000,000", "halving_years": round(halving_years, 2), "launch_year": 2026, "final_block_year": 2026 + round(eras * halving_years), "height": height, } @app.before_request def _assign_csp_nonce(): """A fresh nonce per response. With a nonce in script-src, only the page's own