git / moonbite-network / web_app.py · raw
"""MyCoin Web Dashboard — Flask application for blockchain visualization and interaction.DEPLOYMENT_REBUILD_2026_08_07_22_15_BULLETPROOF_WALLET_CACHE_BUSTThis 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-timeEducational use only — never holds real funds."""from __future__ import annotationsimport hmacimport jsonimport osimport reimport secretsimport sysimport threadingimport timefrom collections import defaultdictfrom functools import wrapsfrom typing import Optionalfrom 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 exchangeimport forumimport forum_emojiimport merchantsimport live_explorerimport price_feedimport storageimport swap_verifierimport wallimport wallet_historyimport worldcupfrom node import Nodefrom store import BlockStorefrom transaction import Transaction, generate_keypair, pubkey_hashfrom 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 Noneif _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"] = Noneapp = 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 immediatelyapp.config['TEMPLATES_AUTO_RELOAD'] = Trueapp.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_envelse: _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_requestdef 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_requestdef 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 miningimport queueimport uuidapp.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 operationsapp.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 operationsERRORS = { # 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_statusdef 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.nodedef _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.0def _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_requestdef 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"), }), 410def _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 Falsedef _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 unitsdef _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, 503def 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.0def _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 heightdef _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_requestdef _assign_csp_nonce(): """A fresh nonce per response. With a nonce in script-src, only the page's own <script> blocks run — an injected one has no nonce and is refused. That is the XSS protection 'unsafe-inline' was giving away. """ g_nonce = secrets.token_urlsafe(16) request.csp_nonce = g_nonce@app.context_processordef inject_csp_nonce(): return {"csp_nonce": getattr(request, "csp_nonce", "")}@app.context_processordef inject_consensus(): return {"consensus": _consensus_dict()}@app.route("/api/consensus", methods=["GET"])def api_consensus(): """The coin's monetary rules as JSON — the frontend's single source.""" data = _consensus_dict() data["status"] = "success" return jsonify(data)_TIP_CACHE: dict = {"data": None, "at": 0.0}_TIP_TTL = 10.0@app.route("/api/tip", methods=["GET"])def api_tip(): """Homepage heartbeat: tip height, last block time, supply, hashrate and recent blocks. Cached for a few seconds — this is polled by every visitor on the front page and must never queue round trips on the seed box.""" now = time.time() if _TIP_CACHE["data"] is not None and (now - _TIP_CACHE["at"]) < _TIP_TTL: return jsonify(_TIP_CACHE["data"]) if not _merchant_use_rpc(): return jsonify({"status": "error", "message": "node unavailable"}), 503 try: payload, code = live_explorer.tip(_get_merchant_rpc()) except Exception: # noqa: BLE001 — a down node degrades, never 500s the page stale = _TIP_CACHE["data"] if stale is not None: return jsonify(stale) return jsonify({"status": "error", "message": "node unavailable"}), 503 if code == 200: _TIP_CACHE["data"] = payload _TIP_CACHE["at"] = now return jsonify(payload), code@app.route("/")def home_page(): """The home page: 'Ten coins drop every two minutes. Nobody owns the tap.' Built to docs/moonbite-guidelines.html: framed wallet hero with the living mark (bite deepens per real block), numbered rules strip, fair-launch bento, live chain board, three steps, FAQ and the standing disclosure. Self-contained template (no base.html); binds to /api/tip and degrades honestly without it.""" return render_template("home_tap.html")@app.route("/home-final")def home_final_page(): """Previous homepage (split-flap countdown, zero-given-away manifesto and the browser guessing game), kept as the fallback.""" return render_template("home_final.html")# Earlier homepage drafts and experiments. They stay in the repo for reference# but are not part of the site: one site, one header, one design. A temporary# redirect, so a draft can be brought back without stale permanent caches._DRAFT_PAGES = frozenset({ "/horizon", "/home", "/hello", "/moon", "/cinema", "/a", "/b", "/break", "/film", "/the-case", "/2026", "/next", "/home-zeldman", "/home-classic", "/logo-sting", "/home-v2", "/home-mobbin", "/classic",})@app.before_requestdef _retire_draft_pages(): if request.path in _DRAFT_PAGES: return redirect("/", code=302) return None@app.route("/horizon")def horizon_page(): """Previous homepage (one continuous descent), kept as the fallback.""" return render_template("horizon.html")@app.route("/home")def home_standard_page(): """Homepage built to docs/moonbite-guidelines.html: the mark as a living hero (bite deepens per real block), a live explorer strip, the glyph composer, caveats in the primary nav, one gold action, the standing disclosure.""" return render_template("home_standard.html")@app.route("/hello")def hello_page(): """A welcome homepage for people who have never touched crypto: feel the magic first (mine a practice coin in the browser), then plain-language explanation, the anti-scam honesty, and a gentle free first step.""" return render_template("hello.html")@app.route("/moon")def home_moon_page(): """Previous homepage (The Last Unowned Thing), kept for comparison.""" return render_template("moon.html")@app.route("/cinema")def cinema_page(): """WebGL cinematic home page candidate.""" return render_template("cinema.html")@app.route("/a")def home_a_page(): """A/B test — build A: strategy-led home page.""" return render_template("home_brief.html")@app.route("/b")def home_b_page(): """A/B test — build B: studio-led home page.""" return render_template("home_studio.html")@app.route("/break")def break_page(): """Standalone shatter experiment: the glyph bars break the screen.""" return render_template("break.html")@app.route("/film")def film_page(): """Scroll-driven cinematic homepage candidate.""" return render_template("film.html")@app.route("/the-case")def the_case_page(): """Long-form Ogilvy copy: the full argument for readers who want it.""" return render_template("the_case.html")@app.route("/2026")def home_ogilvy_page(): """Ogilvy-style editorial homepage candidate.""" return render_template("ogilvy.html")@app.route("/next")def home_flagship_page(): """Candidate flagship homepage (quiet-confidence redesign).""" return render_template("flagship.html")@app.route("/home-zeldman")def home_zeldman_page(): """Previous marketing homepage (Zeldman-style elegant minimalism).""" return render_template("home.html")@app.route("/home-classic")def home_classic_page(): """Render the previous cinematic film-scroll homepage (kept for reference).""" return render_template("home.html")@app.route("/take-a-bite")def take_a_bite_page(): """Render the "You can't buy MoonBite" cinematic mining-demo landing page.""" return render_template("take_a_bite.html")@app.route("/calculator")def calculator_page(): """Render the Early Miner Calculator (shareable earnings-forecast page).""" return render_template("calculator.html")@app.route("/logo-sting")def logo_sting_page(): """Render the MoonBite logo animation (Boot -> Bite -> Lockup sting).""" return render_template("logo_sting.html")@app.route("/home-v2")def home_v2_page(): """Preview the new editorial/terminal MoonBite home design.""" return render_template("home_v2.html")@app.route("/home-mobbin")def home_mobbin_page(): """Candidate homepage built from Mobbin references (Public, Base, Linear, Firecrawl, Attio): quiet serif hero, mono live-stat row, numbered rail, terminal panel, reserve palette. Does not replace the live "/" page.""" return render_template("home_mobbin.html")@app.route("/home-tap")def home_tap_page(): # Promoted to "/"; the candidate link keeps working. return redirect("/", code=302)@app.route("/dashboard")def dashboard_page(): # Folded into the explorer: one page for everything live-chain. return redirect("/explorer", code=302)# --- bitcoin.org-style information architecture (marketing pages) ------------ ## Introduction@app.route("/individuals")def individuals_page(): """MoonBite for individuals.""" return render_template("individuals.html")@app.route("/businesses")def businesses_page(): """MoonBite for businesses.""" return render_template("businesses.html")@app.route("/getting-started")def getting_started_page(): """Step-by-step getting-started walkthrough.""" return render_template("getting_started.html")@app.route("/how-it-works")def how_it_works_page(): """Plain-language explanation of how MoonBite works.""" return render_template("how_it_works.html")@app.route("/you-need-to-know")def you_need_to_know_page(): """Honest caveats before using MoonBite.""" return render_template("you_need_to_know.html")@app.route("/whitepaper")def whitepaper_page(): """Protocol & design overview.""" return render_template("whitepaper.html")# Resources@app.route("/resources")def resources_page(): """Directory of MoonBite tools and docs.""" return render_template("resources.html")@app.route("/exchanges")def exchanges_page(): """Where to get MBITE (listings coming soon).""" return render_template("exchanges.html")def _community_link(env_name: str): """An external community URL from the environment, or None. Only https links are shown, so a typo in the deploy config can't put a javascript: or plain-http link on the page.""" url = os.environ.get(env_name, "").strip() return url if url.startswith("https://") else None@app.route("/community")def community_page(): """Community hub: live chain, where people talk, ways to help, build log. Discord and Bitcointalk links come from MOONBITE_DISCORD_URL and MOONBITE_BITCOINTALK_URL; until they are set the page says they aren't open yet rather than linking nowhere.""" adrs = _adr_list() return render_template( "community.html", discord_url=_community_link("MOONBITE_DISCORD_URL"), bitcointalk_url=_community_link("MOONBITE_BITCOINTALK_URL"), newest_adr=adrs[-1] if adrs else None, )# --- Community forum (SQLite-backed threaded discussions) -------------------- ## Free-text display names, no accounts. Stored text is HTML-escaped by Jinja on# render, so a post can never inject markup. Write endpoints are rate limited.# Posting has no CSRF token: with no login there is no authenticated action to# forge. Voting is tied to a per-browser id, so the vote API only accepts JSON# from this site's own origin (a cross-site page can't send either).app.jinja_env.globals["forum_avatar"] = forum.avatar# Emoji are drawn only on forum pages (the rest of the site has none).app.jinja_env.filters["emojify"] = forum.emojify_textapp.jinja_env.globals["forum_emoji_picker"] = forum_emoji.picker_datadef _forum_voter(create: bool = False) -> str: """Anonymous per-browser voter id, kept in the signed session cookie.""" voter = session.get("forum_voter", "") if not voter and create: voter = secrets.token_hex(16) # Not made permanent: that would also extend the wallet's session keys. # The network hash in _forum_net() carries dedupe across browser restarts. session["forum_voter"] = voter return voterdef _forum_net() -> str: """Salted hash of the client address: lets the vote table recognise a repeat voter on the same connection without storing the IP itself.""" key = (app.secret_key or "").encode() if isinstance(app.secret_key, str) else (app.secret_key or b"") return hmac.new(key, ("forum-vote:" + _client_id()).encode(), "sha256").hexdigest()[:32]def _forum_mod() -> bool: """True when this browser has logged in with MOONBITE_FORUM_MOD_KEY.""" return bool(os.environ.get("MOONBITE_FORUM_MOD_KEY")) and session.get("forum_mod") is Truedef _forum_csrf() -> str: tok = session.get("forum_csrf") if not tok: tok = secrets.token_urlsafe(24) session["forum_csrf"] = tok return tokdef _forum_csrf_ok() -> bool: """Moderator actions are tied to the session, so they need a token from our own page and must come from our own origin.""" origin = request.headers.get("Origin") if origin and origin.rstrip("/") != request.host_url.rstrip("/"): return False sent, want = request.form.get("csrf", ""), session.get("forum_csrf", "") return bool(want) and hmac.compare_digest(sent, want)def _forum_as_official() -> bool: """A moderator ticked "post as MoonBite" on a form carrying a valid token.""" return _forum_mod() and request.form.get("as_official") == "1" and _forum_csrf_ok()@app.context_processordef _forum_mod_context(): if not request.path.startswith("/discussions"): return {} mod = _forum_mod() return {"is_mod": mod, "forum_csrf": _forum_csrf() if mod else ""}@app.route("/discussions")def discussions_page(): """The board: threads ranked hot/new/top/unanswered, by category, searchable.""" try: page = max(1, int(request.args.get("page", 1))) except (TypeError, ValueError): page = 1 category = request.args.get("c", "").strip() if category not in forum.CATEGORY_NAMES: category = "" sort = request.args.get("sort", "hot").strip() if sort not in forum.SORTS: sort = "hot" q = request.args.get("q", "").strip()[:forum.MAX_QUERY] per_page = 20 data = forum.list_threads(limit=per_page, offset=(page - 1) * per_page, category=category or None, sort=sort, q=q or None) total_pages = max(1, (data["total"] + per_page - 1) // per_page) pinned = forum.pinned_threads() if (page == 1 and not q and not category) else [] mine = forum.my_votes(_forum_voter(), _forum_net(), "t", [t["id"] for t in data["threads"] + pinned]) return render_template( "discussions.html", threads=data["threads"], pinned=pinned, total=data["total"], page=page, total_pages=total_pages, category=category, sort=sort, q=q, mine=mine, categories=forum.CATEGORIES, stats=forum.stats(), discord_url=_community_link("MOONBITE_DISCORD_URL"), error=request.args.get("error"), )@app.route("/discussions/<int:thread_id>")def discussion_thread_page(thread_id: int): """One post and its comment tree.""" sort = request.args.get("sort", "best").strip() if sort not in forum.COMMENT_SORTS: sort = "best" thread = forum.get_thread(thread_id, sort=sort, include_removed=_forum_mod()) if thread is None: return render_template("discussion_thread.html", thread=None), 404 voter, net = _forum_voter(), _forum_net() return render_template( "discussion_thread.html", thread=thread, sort=sort, mine_thread=forum.my_votes(voter, net, "t", [thread["id"]]).get(thread["id"], 0), mine=forum.my_votes(voter, net, "r", [r["id"] for r in thread["replies"]]), render_body=forum.render_body, error=request.args.get("error"), )@app.route("/discussions/new", methods=["POST"])@rate_limit(10, 60)def discussions_create(): """Handle the new-thread form (Post/Redirect/Get).""" if request.form.get("website"): # honeypot: hidden from people, filled by bots return redirect(url_for("discussions_page")) try: thread = forum.create_thread( title=request.form.get("title"), author=request.form.get("author"), body=request.form.get("body"), category=request.form.get("category"), official=_forum_as_official(), ) except ValueError as e: return redirect(url_for("discussions_page", error=str(e)) + "#new") return redirect(url_for("discussion_thread_page", thread_id=thread["id"]))@app.route("/discussions/<int:thread_id>/reply", methods=["POST"])@rate_limit(20, 60)def discussions_reply(thread_id: int): """Handle a top-level comment or a reply to a comment (Post/Redirect/Get).""" if request.form.get("website"): # honeypot, as on the new-thread form return redirect(url_for("discussion_thread_page", thread_id=thread_id)) try: parent = request.form.get("parent_id", "").strip() reply = forum.add_reply( thread_id, author=request.form.get("author"), body=request.form.get("body"), parent_id=int(parent) if parent.isdigit() else None, official=_forum_as_official(), ) except ValueError as e: return redirect(url_for("discussion_thread_page", thread_id=thread_id, error=str(e)) + "#reply") return redirect(url_for("discussion_thread_page", thread_id=thread_id) + f"#c-{reply['id']}")@app.route("/api/discussions/vote", methods=["POST"])@rate_limit(60, 60)def discussions_vote(): """Up/down/clear a vote on a thread or comment. JSON only, same origin only.""" origin = request.headers.get("Origin") if origin and origin.rstrip("/") != request.host_url.rstrip("/"): return jsonify({"status": "error", "message": "cross-site vote refused"}), 403 data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({"status": "error", "message": "expected JSON"}), 400 try: result = forum.vote( str(data.get("kind", "")), int(data.get("id", 0)), _forum_voter(create=True), _forum_net(), int(data.get("value", 0)), ) except (TypeError, ValueError) as e: return jsonify({"status": "error", "message": str(e)}), 400 return jsonify({"status": "success", **result})@app.route("/api/discussions/report", methods=["POST"])@rate_limit(10, 60)def discussions_report(): """Flag a post for moderators. JSON only, same origin only.""" origin = request.headers.get("Origin") if origin and origin.rstrip("/") != request.host_url.rstrip("/"): return jsonify({"status": "error", "message": "cross-site report refused"}), 403 data = request.get_json(silent=True) if not isinstance(data, dict): return jsonify({"status": "error", "message": "expected JSON"}), 400 try: forum.report(str(data.get("kind", "")), int(data.get("id", 0)), str(data.get("reason", "")), _forum_net()) except (TypeError, ValueError) as e: return jsonify({"status": "error", "message": str(e)}), 400 return jsonify({"status": "success"})@app.route("/discussions/mod")def discussions_mod_page(): """Moderator desk: open reports, growth numbers, newest posts.""" if not os.environ.get("MOONBITE_FORUM_MOD_KEY"): return render_template("discussions_mod.html", disabled=True), 404 if not _forum_mod(): return render_template("discussions_mod.html", login=True, error=request.args.get("error")) return render_template( "discussions_mod.html", reports=forum.open_reports(), metrics=forum.metrics(), recent=forum.recent_posts(), reasons=forum.REPORT_REASONS, )@app.route("/discussions/mod/login", methods=["POST"])@rate_limit(5, 300)def discussions_mod_login(): key = os.environ.get("MOONBITE_FORUM_MOD_KEY", "") sent = request.form.get("key", "") if not key or not hmac.compare_digest(sent.encode(), key.encode()): return redirect(url_for("discussions_mod_page", error="That key isn't right.")) session["forum_mod"] = True session["forum_csrf"] = secrets.token_urlsafe(24) return redirect(url_for("discussions_mod_page"))@app.route("/discussions/mod/logout", methods=["POST"])def discussions_mod_logout(): if _forum_csrf_ok(): session.pop("forum_mod", None) session.pop("forum_csrf", None) return redirect(url_for("discussions_page"))_MOD_ACTIONS = { "pin": ("pinned", True), "unpin": ("pinned", False), "lock": ("locked", True), "unlock": ("locked", False), "remove": ("removed", True), "restore": ("removed", False),}@app.route("/discussions/mod/action", methods=["POST"])@rate_limit(120, 60)def discussions_mod_action(): if not _forum_mod() or not _forum_csrf_ok(): return redirect(url_for("discussions_mod_page", error="Log in again to moderate.")) kind, action = request.form.get("kind", ""), request.form.get("action", "") nxt = request.form.get("next", "") if not nxt.startswith("/discussions") or "//" in nxt or "\\" in nxt: nxt = url_for("discussions_mod_page") try: target = int(request.form.get("id", "0")) if action == "dismiss": forum.resolve_reports(kind, target) elif action in _MOD_ACTIONS: flag, on = _MOD_ACTIONS[action] forum.set_flag(kind, target, flag, on) else: raise ValueError("unknown action") except ValueError as e: return redirect(url_for("discussions_mod_page", error=str(e))) return redirect(nxt)@app.route("/vocabulary")def vocabulary_page(): """Glossary of MoonBite / crypto terms.""" return render_template("vocabulary.html")@app.route("/events")def events_page(): """MoonBite events and milestones.""" return render_template("events.html")@app.route("/moonbite-core")def moonbite_core_page(): """The MoonBite Core reference node software.""" return render_template("moonbite_core.html")# Participate@app.route("/support")def support_page(): """Support the MoonBite network.""" return render_template("support.html")@app.route("/buy")def buy_page(): """Getting MBITE (buying — coming soon).""" return render_template("buy.html")@app.route("/sell")def sell_page(): """Selling MBITE (coming soon).""" return render_template("sell.html")@app.route("/full-node")def full_node_page(): # Folded into /developers, which carries the run-a-node guide. return redirect("/developers#run-a-node", code=302)@app.route("/development")def development_page(): """Building on and contributing to MoonBite.""" return render_template("development.html")# Other@app.route("/scams")def scams_page(): """How to avoid MoonBite-related scams.""" return render_template("scams.html")@app.route("/legal")def legal_page(): """Plain-language legal disclaimer.""" return render_template("legal.html")@app.route("/privacy")def privacy_page(): """Plain-language privacy policy.""" return render_template("privacy.html")@app.route("/bounty")def bounty_page(): """Bug-bounty hackathon page (21 Sep - 5 Oct 2026).""" return render_template("bounty.html")@app.route("/press")def press_page(): """Press and brand information.""" return render_template("press.html")@app.route("/blog")def blog_page(): """MoonBite build log.""" return render_template("blog.html")@app.route("/get-wallet")def get_wallet_page(): """Render the wallet landing page.""" return render_template("get_wallet.html")@app.route("/mine")def mine_page(): """Render the mining landing page.""" # Report which OS bundles are actually staged so the download buttons reflect # reality (live vs build-from-source) instead of a hardcoded guess. avail = {os_name: _bundle_available(os_name) for os_name in ("linux", "macos-arm64", "macos-x86_64", "windows")} return render_template("mine.html", downloads=avail)@app.route("/markets")def markets_page(): """Render the markets / listings landing page.""" return render_template("markets.html")@app.route("/developers")def developers_page(): """Render the developers landing page.""" return render_template("developers.html")@app.route("/learn")def learn_page(): """Render the learn / FAQ landing page.""" return render_template("learn.html")@app.route("/about")def about_page(): """Render the about landing page.""" return render_template("about.html")@app.route("/why")def why_page(): """Render the 'why MoonBite' page.""" return render_template("why.html")# --------------------------------------------------------------------------- ## Governance / ADRs — publish the decision records (docs/ADR-*.md) as pages.# Minimal Markdown -> HTML for the subset the ADRs use (no extra dependency).# --------------------------------------------------------------------------- #import glob as _globimport html as _html_DOCS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "docs")def _md_inline(t): t = _html.escape(t) t = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", t) t = re.sub(r"`([^`]+)`", r"<code>\1</code>", t) t = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r'<a href="\2" rel="noopener">\1</a>', t) return tdef _md_to_html(text): lines = text.split("\n") out, i, n = [], 0, len(lines) while i < n: ln = lines[i] if not ln.strip(): i += 1 continue if ln.startswith("### "): out.append("<h3>" + _md_inline(ln[4:]) + "</h3>"); i += 1; continue if ln.startswith("## "): out.append("<h2>" + _md_inline(ln[3:]) + "</h2>"); i += 1; continue if ln.startswith("# "): out.append("<h1>" + _md_inline(ln[2:]) + "</h1>"); i += 1; continue if re.match(r"^---+\s*$", ln): out.append("<hr>"); i += 1; continue # GFM table if ln.lstrip().startswith("|") and i + 1 < n and re.match(r"^\s*\|[\s:|-]+\|\s*$", lines[i + 1]): def cells(r): return [c.strip() for c in r.strip().strip("|").split("|")] head = cells(ln); i += 2 rows = [] while i < n and lines[i].lstrip().startswith("|"): rows.append(cells(lines[i])); i += 1 t = ["<table><thead><tr>"] + ["<th>" + _md_inline(c) + "</th>" for c in head] + ["</tr></thead><tbody>"] for r in rows: t.append("<tr>" + "".join("<td>" + _md_inline(c) + "</td>" for c in r) + "</tr>") t.append("</tbody></table>") out.append("".join(t)); continue # list if re.match(r"^\s*[-*] ", ln): items = [] while i < n and re.match(r"^\s*[-*] ", lines[i]): items.append("<li>" + _md_inline(re.sub(r"^\s*[-*] ", "", lines[i])) + "</li>"); i += 1 out.append("<ul>" + "".join(items) + "</ul>"); continue # paragraph (join until blank) para = [ln]; i += 1 while i < n and lines[i].strip() and not re.match(r"^(#|\||\s*[-*] |---)", lines[i]): para.append(lines[i]); i += 1 out.append("<p>" + _md_inline(" ".join(para)) + "</p>") return "\n".join(out)def _adr_list(): items = [] for path in sorted(_glob.glob(os.path.join(_DOCS_DIR, "ADR-*.md"))): slug = os.path.basename(path)[:-3] title = slug try: with open(path, encoding="utf-8") as fh: for line in fh: if line.startswith("# "): title = line[2:].strip(); break except OSError: continue items.append({"slug": slug, "title": title}) return items@app.route("/governance")def governance_page(): return render_template("governance.html", adrs=_adr_list())@app.route("/governance/<slug>")def governance_adr(slug): if not re.match(r"^ADR-[0-9A-Za-z-]+$", slug): return render_template("governance.html", adrs=_adr_list()), 404 path = os.path.join(_DOCS_DIR, slug + ".md") if not os.path.isfile(path): # Short form (/governance/ADR-006) -> the one ADR with that number. if re.match(r"^ADR-[0-9]+$", slug): hits = _glob.glob(os.path.join(_DOCS_DIR, slug + "-*.md")) if len(hits) == 1: return redirect("/governance/" + os.path.basename(hits[0])[:-3], code=301) return render_template("governance.html", adrs=_adr_list()), 404 with open(path, encoding="utf-8") as fh: body = _md_to_html(fh.read()) return render_template("adr.html", body=body, adrs=_adr_list())@app.route("/wallet")def wallet_page(): """The MoonBite self-custody wallet (flagship UI). Brand-aligned, mobile-first; reuses the same audited client-side crypto modules as before (BIP39 generation, PBKDF2+AES-GCM at rest, on-device signing) and the same /api/chain/* endpoints, so it is functionally the production wallet with a new front end. Served as a static file; it is CSP-safe (external module scripts, no inline handlers). """ return send_from_directory("static", "wallet-flagship.html")@app.route("/wallet-classic")def wallet_classic_page(): """The previous wallet UI, kept reachable for anyone who prefers it or has it installed as a PWA. Same keys and endpoints as /wallet.""" return render_template("wallet-pwa-app.html")# Superseded wallet builds. Kept as permanent redirects rather than deleted# routes so existing links, bookmarks and installed PWA shortcuts survive.@app.route("/wallet-app")@app.route("/wallet-full")@app.route("/wallet-moonbite")@app.route("/wallet-complete")def wallet_legacy_redirect(): return redirect(url_for("wallet_page"), code=301)@app.route("/wallet-manifest.json")def wallet_manifest(): """Serve PWA manifest.""" import os manifest_path = os.path.join(os.path.dirname(__file__), "wallet-manifest.json") if os.path.exists(manifest_path): return send_file(manifest_path, mimetype="application/manifest+json") return jsonify({"error": "Manifest not found"}), 404@app.route("/wallet-sw.js")def wallet_service_worker(): """Serve the service worker for offline support. Served from the site root, not /static, because a worker's scope is capped by the path it is served from — at /static/ it could not intercept /wallet. This looked in the repo root and 404'd, so no new worker could ever install and browsers kept running whatever version they had cached. Any stale worker's caches are purged by the activate handler in wallet-sw.js once a current copy finally installs. """ sw_path = os.path.join(os.path.dirname(__file__), "static", "wallet-sw.js") if os.path.exists(sw_path): response = send_file(sw_path, mimetype="application/javascript") # Never let a proxy or the browser pin an old worker: this is the one # file that must always be revalidated, or an install can never be # superseded. response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" response.headers["Service-Worker-Allowed"] = "/" return response return jsonify({"error": "Service worker not found"}), 404@app.route("/mining")def mining_page(): """Render the mining page.""" # August 4, 2026 - Testing deployment return render_template("mining.html")@app.route("/leaderboard")def leaderboard_page(): """Render the mining leaderboard page.""" return render_template("leaderboard.html")def _blocks_mined_by(address: str) -> int: """Count coinbase payouts to `address` on the active chain. This is what makes the Wall a record rather than a guestbook: a claim is only accepted if the chain itself already paid that address. """ try: node = get_node() chain = node.chain count = 0 for block_hash in chain.active_chain(): block = chain.blocks[block_hash] if not block.transactions or not block.transactions[0].is_coinbase(): continue outputs = block.transactions[0].outputs if not outputs: continue try: if address_from_pubkey_hash(outputs[0].pubkey_hash) == address: count += 1 except Exception: continue return count except Exception: return 0# The archive index. Grouped rather than alphabetical so a visitor can tell at# a glance which pages are current tools and which are superseded builds._ARCHIVE = [ ("Tools", [ ("/explorer", "Block explorer", ("live", "live")), ("/mining", "Mining console (advanced)", ("live", "live")), ("/dashboard", "Network dashboard", ("live", "live")), ("/leaderboard", "Mining leaderboard", ("live", "live")), ("/merchants", "Merchant payments", None), ]), ("Reference", [ ("/whitepaper", "Whitepaper", ("live", "live")), ("/how-it-works", "How it works", None), ("/moonbite-core", "MoonBite Core", None), ("/vocabulary", "Vocabulary", None), ("/learn", "Learn", None), ("/you-need-to-know", "What you need to know", None), ("/full-node", "Run a full node", None), ("/developers", "Developers", None), ("/development", "Development", None), ]), ("Getting started", [ ("/getting-started", "Getting started", None), ("/mine", "Start mining", None), ("/get-wallet", "Get a wallet", None), ("/individuals", "For individuals", None), ("/businesses", "For businesses", None), ]), ("Markets & honesty", [ ("/markets", "Markets", None), ("/exchanges", "Exchanges", None), ("/buy", "Buying MBITE", None), ("/sell", "Selling MBITE", None), ("/scams", "Scams & impersonation", ("live", "live")), ("/legal", "Legal", None), ("/privacy", "Privacy", ("live", "live")), ]), ("Community", [ ("/community", "Community", None), ("/discussions", "Discussions", ("live", "live")), ("/events", "Events", None), ("/blog", "Blog", None), ("/press", "Press kit", None), ("/support", "Support", None), ("/resources", "Resources", None), ("/why", "Why MoonBite", None), ("/about", "About", None), ]), ("Superseded builds", [ ("/home-final", "Homepage — split-flap countdown", ("older", "moved")), ("/home-classic", "Homepage — classic", ("older", "moved")), ("/home-v2", "Homepage — v2", ("older", "moved")), ("/home-zeldman", "Homepage — Zeldman", ("older", "moved")), ("/take-a-bite", "Take a Bite", ("older", "moved")), ("/logo-sting", "Logo sting", ("older", "moved")), ("/wallet-app", "Wallet builds → /wallet", ("redirects", "moved")), ]),]@app.route("/classic")def classic_page(): """Render the archive index of every page, including superseded builds.""" return render_template("classic.html", archive=_ARCHIVE)@app.route("/free")def free_page(): """Render the Un-Airdrop: there is nothing to claim, and that is the point.""" return render_template("free.html")@app.route("/wall")def wall_page(): """Render the public, chain-verified wall of first blocks.""" return render_template("wall.html")@app.route("/api/wall", methods=["GET"])@rate_limit(60, 60)def api_wall_list(): """Newest certificates, plus this visitor's own entry when an address is given.""" try: limit = int(request.args.get("limit", 60)) offset = int(request.args.get("offset", 0)) except (TypeError, ValueError): return jsonify({"status": "error", "message": "limit and offset must be integers"}), 400 data = wall.recent(limit, offset) data["you"] = wall.lookup(request.args.get("address")) data["status"] = "success" return jsonify(data)@app.route("/api/wall", methods=["POST"])@rate_limit(6, 60)def api_wall_add(): """Place one certificate, refusing any address the chain has not paid.""" p = request.get_json(silent=True) or {} try: cert = wall.add( address=p.get("address"), handle=p.get("handle"), country=p.get("country"), height=p.get("height", 0), reward=p.get("reward", 0), verify_blocks=_blocks_mined_by, ) except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 data = wall.recent(60, 0) data["you"] = cert data["status"] = "success" return jsonify(data)@app.route("/halving")def halving_page(): """Render the permanent halving countdown and emission staircase.""" return render_template("halving.html")# Where prepared miner bundles live if built. Kept off the deploy tree so the# 200MB+ binaries never enter git; a bundle is dropped here at release time._MINER_DOWNLOAD_DIR = storage.data_path("downloads", "MOONBITE_DOWNLOAD_DIR")_MINER_BUNDLES = { "linux": "moonbite-miner-linux-x86_64.tar.gz", # macOS ships per-architecture - the release CI builds both natively # (macos-14 arm64, macos-13 x86_64). The bare "macos" key defaults to Apple # Silicon, which covers every Mac since 2020; Intel Macs use macos-x86_64. # Filenames match the release workflow's artifacts exactly, so the moment a # bundle is staged in the download dir the button below goes live. "macos": "moonbite-miner-macos-arm64.tar.gz", "macos-arm64": "moonbite-miner-macos-arm64.tar.gz", "macos-x86_64": "moonbite-miner-macos-x86_64.tar.gz", "windows": "moonbite-miner-windows-x86_64.zip", # The desktop wallet app is a separate download (kept out of the miner # bundle so the miner stays small and quick to fetch). "wallet": "moonbite-wallet.exe",}def _bundle_available(os_name: str) -> bool: """True when the bundle for `os_name` is staged and downloadable now, so the mine page can show a live button rather than a build-from-source ghost.""" fname = _MINER_BUNDLES.get(os_name) return bool(fname) and os.path.isfile(os.path.join(_MINER_DOWNLOAD_DIR, fname))@app.route("/download/<os_name>")def download_miner(os_name): """Serve a prepared miner bundle, or say plainly it is not built yet. Never a dead link: an OS whose bundle has not been produced returns an honest 'not ready, build from source' page rather than a 404 or a broken download. """ fname = _MINER_BUNDLES.get(os_name) if fname is None: # Unknown platform key. Rendering mine.html here without the context # /mine builds raised in Jinja and turned a typo'd URL into a 500. return _not_published_page( f"There is no MoonBite download called '{os_name}'. " "Pick a platform from the mining guide." ), 404 path = os.path.join(_MINER_DOWNLOAD_DIR, fname) if os.path.isfile(path): # Hand the large file to nginx (sendfile) instead of streaming it # through the single gunicorn worker, which is slow and blocks the site # for the whole download. nginx serves it from an internal location. if os.environ.get("MOONBITE_XACCEL"): mt = ("application/zip" if fname.endswith(".zip") else "application/octet-stream" if fname.endswith(".exe") else "application/gzip") resp = app.response_class() resp.headers["X-Accel-Redirect"] = "/dl-internal/" + fname resp.headers["Content-Type"] = mt resp.headers["Content-Disposition"] = f'attachment; filename="{fname}"' return resp return send_from_directory(_MINER_DOWNLOAD_DIR, fname, as_attachment=True) # Bundle not staged for this OS yet (the wallet .exe, for one, is no # longer tracked in git and only appears once a signed release is staged) # — tell the truth rather than 404 a button the page advertised. what = "desktop wallet" if os_name == "wallet" else f"{os_name} miner bundle" return _not_published_page( f"The {what} is not published yet. " "You can build it from source at https://moonbite.org/git, " "or use another platform's bundle for now." ), 503def _not_published_page(msg: str) -> str: """Minimal self-contained page for a download that cannot be served. Deliberately template-free so it can never fail on missing context; the message is composed from fixed strings and the route's own key, never user text that could inject markup. """ from markupsafe import escape return ( "<!doctype html><meta charset=utf-8>" "<title>Not ready — MoonBite</title>" "<body style='background:#0B0D12;color:#F5F2EA;font-family:system-ui;" "max-width:560px;margin:16vh auto;padding:0 20px;line-height:1.6'>" f"<h1 style='color:#D9A441'>Not published yet</h1><p>{escape(msg)}</p>" "<p><a style='color:#D9A441' href='/mine'>← Back to mining guide</a></p>" )@app.route("/start")def start_page(): """Render the one-screen path from visitor to first mined block.""" return render_template("start.html")@app.route("/world-cup")def world_cup_page(): """Render the Mining World Cup country scoreboard.""" return render_template("worldcup.html")@app.route("/api/worldcup", methods=["GET"])@rate_limit(60, 60)def api_worldcup_standings(): """Country standings, plus this visitor's own entry when a token is given.""" data = worldcup.standings() data["you"] = worldcup.lookup(request.args.get("token")) data["status"] = "success" return jsonify(data)@app.route("/api/worldcup/enlist", methods=["POST"])@rate_limit(10, 60)def api_worldcup_enlist(): """Declare a country for this miner and return their permanent ordinal.""" payload = request.get_json(silent=True) or {} try: me = worldcup.enlist( token=payload.get("token"), code=payload.get("code"), blocks=payload.get("blocks", 0), ) except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 data = worldcup.standings() data["you"] = me data["status"] = "success" return jsonify(data)@app.route("/merchants")def merchants_page(): """Render the merchant payment platform page.""" return render_template("merchant.html")@app.route("/explorer")def explorer_page(): """Render the block explorer page.""" return render_template("explorer.html")@app.route("/balance")def balance_page(): """Paste a mined address, see its live balance. Closes the loop the mine flow leaves open: the node pays you, and this is where you watch it arrive. Reads /api/chain/address/<addr> (scantxoutset); shares no key material.""" return render_template("balance.html")@app.route("/downloads/<path:filename>")def downloads(filename: str): """Serve real release artifacts from website/downloads. SHA256SUMS.txt is the exception: it lists the live miner bundles, so it is served from the same directory the bundles are staged in (never from the repo, which cannot know the hashes of the published files).""" if filename == "SHA256SUMS.txt": resp = send_from_directory(_MINER_DOWNLOAD_DIR, "SHA256SUMS.txt", mimetype="text/plain") resp.headers["Cache-Control"] = "no-cache" return resp return send_from_directory("website/downloads", filename)@app.route("/.well-known/security.txt")def security_txt(): """RFC 9116 contact for vulnerability reports.""" body = ( "Contact: mailto:security@moonbite.org\n" "Expires: 2027-10-17T00:00:00.000Z\n" "Preferred-Languages: en\n" "Canonical: https://moonbite.org/.well-known/security.txt\n" "Policy: https://moonbite.org/bounty\n" ) return app.response_class(body, mimetype="text/plain")@app.route("/api/notify", methods=["POST"])@rate_limit(10, 60)def api_notify(): """Capture an email for the exchange-listing announcement.""" data = request.get_json(silent=True) or {} email = str(data.get("email", "")).strip() source = str(data.get("source", "unknown")).strip()[:64] # Basic server-side email validation (no third parties, no SMTP) if not EMAIL_RE.match(email) or len(email) > 254: return jsonify({"status": "error", "message": "Invalid email address"}), 400 record = {"email": email, "source": source, "ts": int(time.time())} try: # Written into the data directory, not the working directory. Beside # the code it sat inside the deploy tree, which a cutover replaces # wholesale — and it was committed to a public repo, so every deploy # also overwrote whatever had been collected with the copy from git. with open(storage.data_path("notify_signups.jsonl"), "a", encoding="utf-8") as fh: fh.write(json.dumps(record) + "\n") except OSError: # Ephemeral FS (e.g. Railway) — client keeps a localStorage fallback. pass return jsonify({"status": "success", "message": "You're on the list"})# ============================================================================= ## API Routes — Internal Exchange (non-custodial order book)## The server matchmakes order *intents* only. It never holds coins, keys, or# balances; settlement is a wallet-to-wallet atomic swap off this server.# ============================================================================= #@app.route("/api/exchange/pairs", methods=["GET"])def api_exchange_pairs(): """List the trading pairs the order book supports.""" return jsonify({"status": "success", "pairs": exchange.SUPPORTED_PAIRS}), 200@app.route("/api/exchange/orders", methods=["GET"])def api_exchange_orders(): """Return the order book (bids/asks) for a pair, or all open orders.""" pair = request.args.get("pair") status = request.args.get("status", "open") try: book = exchange.list_orders(pair=pair, status=status) return jsonify({"status": "success", **book}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/exchange/order", methods=["POST"])@rate_limit(30, 60)def api_exchange_create_order(): """Post a new public order intent to the book.""" data = request.get_json(silent=True) or {} try: order = exchange.create_order( side=str(data.get("side", "")).strip(), pair=str(data.get("pair", "")).strip(), price=data.get("price"), amount=data.get("amount"), mbite_address=data.get("mbite_address", ""), quote_address=data.get("quote_address", ""), ) return jsonify({"status": "success", "order": order}), 201 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/exchange/order/<order_id>", methods=["GET"])def api_exchange_get_order(order_id: str): """Fetch a single order by id.""" order = exchange.get_order(order_id) if order is None: return jsonify({"status": "error", "message": "order not found"}), 404 return jsonify({"status": "success", "order": order}), 200@app.route("/api/exchange/order/<order_id>/cancel", methods=["POST"])def api_exchange_cancel_order(order_id: str): """Cancel an open order — requires the secret cancel_token from creation.""" data = request.get_json(silent=True) or {} try: order = exchange.cancel_order(order_id, str(data.get("cancel_token", ""))) return jsonify({"status": "success", "order": order}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/exchange/order/<order_id>/settle", methods=["GET"])def api_exchange_settle_hint(order_id: str): """Return the atomic-swap hand-off instructions for a matched order.""" try: return jsonify({"status": "success", **exchange.settle_hint(order_id)}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 404# --- Phase 2a: atomic-swap settlement coordination (non-custodial) ---------- ## Records the HTLC hand-off and self-reported funding for a matched pair. The# server verifies nothing on-chain yet (Phase 2b) and moves no funds; a swap# progresses no further than 'both_locked' and no trade is marked settled here.@app.route("/api/exchange/order/<order_id>/swap", methods=["GET"])def api_exchange_get_swap(order_id: str): """Return the settlement-coordination swap for a matched order, if any.""" swap = exchange.get_swap(order_id) if swap is None: return jsonify({"status": "error", "message": "no swap for this order"}), 404 return jsonify({"status": "success", "swap": swap}), 200@app.route("/api/exchange/order/<order_id>/swap/init", methods=["POST"])@rate_limit(30, 60)def api_exchange_swap_init(order_id: str): """Register the HTLC hand-off for a matched order pair. Auth: cancel_token.""" data = request.get_json(silent=True) or {} try: swap = exchange.init_swap( order_id=order_id, cancel_token=str(data.get("cancel_token", "")), hashlock=data.get("hashlock", ""), base_recipient_pk=data.get("base_recipient_pubkey", ""), base_refund_pk=data.get("base_refund_pubkey", ""), quote_recipient_pk=data.get("quote_recipient_pubkey", ""), quote_refund_pk=data.get("quote_refund_pubkey", ""), base_locktime=data.get("base_locktime"), quote_locktime=data.get("quote_locktime"), ) return jsonify({"status": "success", "swap": swap}), 201 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/exchange/order/<order_id>/swap/funded", methods=["POST"])@rate_limit(30, 60)def api_exchange_swap_funded(order_id: str): """Report an HTLC funding txid for one leg (base|quote). Auth: cancel_token. Phase 2a records the report and advances the state machine; it does NOT verify the tx on-chain (Phase 2b) and never treats it as settlement. """ data = request.get_json(silent=True) or {} try: swap = exchange.report_funding( order_id=order_id, cancel_token=str(data.get("cancel_token", "")), leg=str(data.get("leg", "")).strip(), txid=data.get("txid", ""), ) return jsonify({"status": "success", "swap": swap}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/exchange/verify", methods=["POST"])def api_exchange_verify(): """Run one Phase 2b on-chain verification pass over pending swaps. Read-only against the operator's node: confirms HTLC fundings/redemptions and advances genuinely-settled trades (the only path that moves last_price). This is an OPERATOR trigger, not a public endpoint — intended to be called on a timer (cron/systemd). It is: * disabled unless VERIFIER_ENABLED is set (a swap never settles otherwise); * gated by a shared secret VERIFIER_TRIGGER_TOKEN (X-Verifier-Token header) so the public cannot make the box hammer the node; * safe when a chain is unreachable/unconfigured — it simply settles nothing (the quote leg uses a NullAdapter until the Phase 2c quote adapter lands). """ from explorer import config as ex_config if not ex_config.VERIFIER_ENABLED: return jsonify({"status": "error", "message": "verifier disabled"}), 403 expected = os.environ.get("VERIFIER_TRIGGER_TOKEN", "") provided = request.headers.get("X-Verifier-Token", "") if not expected or not hmac.compare_digest(expected, provided): return jsonify({"status": "error", "message": "unauthorized"}), 403 # Only now touch the RPC client. explorer/rpc.py uses a bare ``import config`` # (it is designed to run with explorer/ on the path), so make that resolve. _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 base_adapter = swap_verifier.MoonNodeAdapter(RpcClient()) quote_adapter = swap_verifier.NullAdapter() # Phase 2c wires a real quote leg try: applied = swap_verifier.run_verification_pass( exchange, base_adapter, quote_adapter, min_confs_base=ex_config.VERIFIER_MIN_CONFS_BASE, min_confs_quote=ex_config.VERIFIER_MIN_CONFS_QUOTE, ) except Exception as e: # noqa: BLE001 - node unreachable etc. => 503, never 500 return jsonify({"status": "error", "message": f"node unavailable: {e}"}), 503 return jsonify({ "status": "success", "verified": len(applied), "results": [{"swap_id": sid, "status": u.get("status")} for sid, u in applied], }), 200# ============================================================================= ## API Routes — Merchant adoption (non-custodial "Accept MBITE")## A directory of businesses that voluntarily accept MBITE, plus invoices they# raise. The server never holds funds — a payment is *observed* on-chain at the# merchant's own address; settlement is wallet-to-wallet.# ============================================================================= #@app.route("/api/merchants", methods=["GET"])def api_merchants_list(): """List merchants in the directory, optionally filtered by category.""" category = request.args.get("category") try: rows = merchants.list_merchants(category=category) return jsonify( {"status": "success", "merchants": rows, "categories": list(merchants.CATEGORIES)} ), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/merchants", methods=["POST"])@rate_limit(10, 60)def api_merchants_add(): """Register a merchant that accepts MBITE (opt-in).""" data = request.get_json(silent=True) or {} try: row = merchants.add_merchant( name=data.get("name"), category=str(data.get("category", "")).strip(), mbite_address=data.get("mbite_address", ""), url=data.get("url", ""), blurb=data.get("blurb", ""), address_validator=_valid_receiving_address, ) return jsonify({"status": "success", "merchant": row}), 201 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/merchant/invoice", methods=["POST"])@rate_limit(30, 60)def api_merchant_invoice_create(): """Raise a non-custodial payment request against a merchant's own address. Authenticated (audit H1): requires merchant_id plus that merchant's manage token (X-Merchant-Token header, or manage_token in the JSON body). The address, if given, must be the merchant's registered address; omit it to use that address. Anonymous invoices are refused (403). """ data = request.get_json(silent=True) or {} token = request.headers.get("X-Merchant-Token") or data.get("manage_token") try: inv = merchants.create_invoice( address=data.get("address"), amount=data.get("amount"), received_lookup=merchant_received_lookup, merchant_id=data.get("merchant_id"), manage_token=token, memo=data.get("memo", ""), address_validator=_valid_receiving_address, ) return jsonify({"status": "success", "invoice": inv}), 201 except merchants.MerchantAuthError as e: return jsonify({"status": "error", "message": str(e)}), 403 except ScanBudgetExhausted as e: return _scan_budget_response(e) except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/merchant/invoice/<invoice_id>", methods=["GET"])@rate_limit(30, 60) # audit H2: each poll may cost a full UTXO scandef api_merchant_invoice_status(invoice_id: str): """Poll an invoice — re-checks the chain for payment each call.""" try: inv = merchants.invoice_status(invoice_id, merchant_received_lookup) return jsonify({"status": "success", "invoice": inv}), 200 except ScanBudgetExhausted as e: return _scan_budget_response(e) except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 404# ============================================================================= ## API Routes — Wallet Transaction History# ============================================================================= #def _get_session_id() -> str: """Extract user session ID from the session cookie for wallet isolation.""" if "session_id" not in session: session["session_id"] = secrets.token_hex(16) return session["session_id"]@app.route("/api/wallet/transaction/send", methods=["POST"])@rate_limit(30, 60)def api_wallet_transaction_send(): """Create a new send transaction record. The wallet app creates this after sending coins on-chain. The server records the transaction for history/audit purposes (never custodial). """ data = request.get_json(silent=True) or {} session_id = _get_session_id() try: txid = str(data.get("txid", "")).strip() if not txid: return json_error( "VALIDATION_MISSING_FIELD", user_message="Transaction ID is required", ) amount_units = int(data.get("amount_units", 0)) if amount_units <= 0: return json_error( "VALIDATION_INVALID_AMOUNT", user_message="Amount must be greater than zero", suggested_action="Please check the amount and try again", ) from_address = str(data.get("from_address", "")).strip() to_address = str(data.get("to_address", "")).strip() if not from_address or not to_address: return json_error( "VALIDATION_MISSING_FIELD", user_message="Both sender and recipient addresses are required", ) # Validate addresses if not _valid_receiving_address(from_address): return json_error( "VALIDATION_INVALID_ADDRESS", user_message="Sender address is invalid", suggested_action="Please check the sender address", ) if not _valid_receiving_address(to_address): return json_error( "VALIDATION_INVALID_ADDRESS", user_message="Recipient address is invalid", suggested_action="Please check the recipient address format", ) fee_units = int(data.get("fee_units", 0)) status = str(data.get("status", "pending")).strip() memo = str(data.get("memo", "")).strip()[:500] account_id = data.get("account_id") # Optional: link to specific account tx = wallet_history.add_transaction( session_id=session_id, txid=txid, direction="send", amount_units=amount_units, from_address=from_address, to_address=to_address, fee_units=fee_units, status=status, memo=memo, ) # Update transaction with account_id if provided if account_id: try: import sqlite3 conn = wallet_history.get_connection() conn.execute( "UPDATE transactions SET account_id = ? WHERE user_session_id = ? AND txid = ?", (account_id, session_id, txid) ) conn.commit() conn.close() except Exception: pass # Account_id is optional, don't fail the transaction return jsonify({"status": "success", "transaction": tx}), 201 except ValueError as e: return json_error( "VALIDATION_INVALID_AMOUNT", debug_message=str(e), suggested_action="Please check all fields and try again", ) except Exception as e: print(f"[api_wallet_transaction_send] Unexpected error: {e}", flush=True) return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again or contact support if the problem persists", )@app.route("/api/wallet/transactions", methods=["GET"])def api_wallet_transactions_list(): """List user's transactions with optional pagination and filtering. Query params: limit: Max records per page (1-100, default 20) offset: Pagination offset (default 0) status: Filter by 'pending', 'confirmed', or 'failed' sort: 'asc' or 'desc' (default desc = newest first) """ session_id = _get_session_id() try: limit = int(request.args.get("limit", 20)) offset = int(request.args.get("offset", 0)) status = request.args.get("status") sort = request.args.get("sort", "desc") result = wallet_history.get_transactions( session_id=session_id, limit=limit, offset=offset, status=status, sort=sort, ) return jsonify({"status": "success", "data": result}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_wallet_transactions_list] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/wallet/transactions/<txid>", methods=["GET"])def api_wallet_transaction_detail(txid: str): """Fetch a single transaction by txid.""" session_id = _get_session_id() try: tx = wallet_history.get_transaction(session_id=session_id, txid=txid) if not tx: return jsonify({"status": "error", "message": "transaction not found"}), 404 return jsonify({"status": "success", "data": tx}), 200 except Exception as e: print(f"[api_wallet_transaction_detail] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/wallet/transactions/<txid>", methods=["PATCH"])@rate_limit(60, 60)def api_wallet_transaction_update_memo(txid: str): """Update the memo of a transaction.""" data = request.get_json(silent=True) or {} session_id = _get_session_id() try: memo = str(data.get("memo", "")).strip() tx = wallet_history.update_transaction_memo( session_id=session_id, txid=txid, memo=memo, ) if not tx: return jsonify({"status": "error", "message": "transaction not found"}), 404 return jsonify({"status": "success", "data": tx}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_wallet_transaction_update_memo] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/wallet/transactions/search", methods=["GET"])def api_wallet_transactions_search(): """Search transactions with full-text and filter options. Query params: q: Search query (matches txid, addresses, memo) amount_min: Minimum amount in base units (optional) amount_max: Maximum amount in base units (optional) date_from: Start timestamp (optional) date_to: End timestamp (optional) status: Filter by 'pending', 'confirmed', or 'failed' direction: Filter by 'send' or 'receive' limit: Max records per page (1-100, default 20) offset: Pagination offset (default 0) """ session_id = _get_session_id() try: query = request.args.get("q", "") amount_min = request.args.get("amount_min") amount_max = request.args.get("amount_max") date_from = request.args.get("date_from") date_to = request.args.get("date_to") status = request.args.get("status") direction = request.args.get("direction") limit = int(request.args.get("limit", 20)) offset = int(request.args.get("offset", 0)) result = wallet_history.search_transactions( session_id=session_id, query=query, amount_min=amount_min, amount_max=amount_max, date_from=date_from, date_to=date_to, status=status, direction=direction, limit=limit, offset=offset, ) return jsonify({"status": "success", "data": result}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_wallet_transactions_search] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/wallet/transactions/export", methods=["GET"])def api_wallet_transactions_export(): """Export transactions as CSV file. Query params: date_from: Start timestamp (optional) date_to: End timestamp (optional) format: 'csv' (default) or 'json' include_fees: 'true' (default) or 'false' include_memo: 'true' (default) or 'false' """ session_id = _get_session_id() try: date_from = request.args.get("date_from") date_to = request.args.get("date_to") format_type = request.args.get("format", "csv").lower() include_fees = request.args.get("include_fees", "true").lower() == "true" include_memo = request.args.get("include_memo", "true").lower() == "true" date_from = int(date_from) if date_from else None date_to = int(date_to) if date_to else None if format_type == "csv": csv_data = wallet_history.export_transactions_csv( session_id=session_id, date_from=date_from, date_to=date_to, include_fees=include_fees, include_memo=include_memo, ) response = make_response(csv_data) response.headers["Content-Type"] = "text/csv; charset=utf-8" response.headers["Content-Disposition"] = "attachment; filename=transactions.csv" return response, 200 elif format_type == "json": result = wallet_history.get_transactions( session_id=session_id, limit=10000, offset=0, ) response = make_response(jsonify({"status": "success", "data": result})) response.headers["Content-Disposition"] = "attachment; filename=transactions.json" return response, 200 else: return jsonify({"status": "error", "message": "format must be 'csv' or 'json'"}), 400 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_wallet_transactions_export] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500# ============================================================================= ## API Routes — Price Ticker# ============================================================================= #@app.route("/api/price/mbite", methods=["GET"])def api_price_mbite(): """Get current MBITE price. Returns: dict with price_usd, change_24h, high_24h, low_24h, market_cap, volume_24h, timestamp """ try: price_data = price_feed.get_price() return jsonify({"status": "success", "data": price_data}), 200 except Exception as e: print(f"[api_price_mbite] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/price/mbite/history", methods=["GET"])def api_price_mbite_history(): """Get MBITE price history. Query params: hours: Number of hours to retrieve (default 24, max 720) """ try: hours = int(request.args.get("hours", 24)) hours = max(1, min(hours, 720)) # Clamp to 1-720 hours history_data = price_feed.get_price_history(hours=hours) return jsonify({"status": "success", "data": history_data}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_price_mbite_history] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500# ============================================================================= ## API Routes — Address Book# ============================================================================= #@app.route("/api/address-book/add", methods=["POST"])@rate_limit(30, 60)def api_address_book_add(): """Add a labeled contact to the address book.""" data = request.get_json(silent=True) or {} session_id = _get_session_id() try: label = str(data.get("label", "")).strip() address = str(data.get("address", "")).strip() category = str(data.get("category", "general")).strip() notes = str(data.get("notes", "")).strip() contact = wallet_history.add_contact( session_id=session_id, label=label, address=address, category=category, notes=notes, ) return jsonify({"status": "success", "data": contact}), 201 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_address_book_add] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/address-book", methods=["GET"])def api_address_book_list(): """List all contacts in the address book. Query params: category: Filter by category (optional) sort: 'created', 'updated', 'label', 'times_sent' (default 'created') """ session_id = _get_session_id() try: category = request.args.get("category") sort = request.args.get("sort", "created") contacts = wallet_history.get_contacts( session_id=session_id, category=category, sort=sort, ) return jsonify({"status": "success", "data": contacts}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_address_book_list] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/address-book/<int:contact_id>", methods=["GET"])def api_address_book_detail(contact_id: int): """Fetch a single contact by ID.""" session_id = _get_session_id() try: contact = wallet_history.get_contact(session_id=session_id, contact_id=contact_id) if not contact: return jsonify({"status": "error", "message": "contact not found"}), 404 return jsonify({"status": "success", "data": contact}), 200 except Exception as e: print(f"[api_address_book_detail] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/address-book/<int:contact_id>", methods=["PATCH"])@rate_limit(60, 60)def api_address_book_update(contact_id: int): """Update a contact's fields (label, address, category, notes, is_favorite).""" data = request.get_json(silent=True) or {} session_id = _get_session_id() try: contact = wallet_history.update_contact( session_id=session_id, contact_id=contact_id, updates=data, ) if not contact: return jsonify({"status": "error", "message": "contact not found"}), 404 return jsonify({"status": "success", "data": contact}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_address_book_update] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/address-book/<int:contact_id>", methods=["DELETE"])@rate_limit(30, 60)def api_address_book_delete(contact_id: int): """Delete a contact from the address book.""" session_id = _get_session_id() try: deleted = wallet_history.delete_contact( session_id=session_id, contact_id=contact_id, ) if not deleted: return jsonify({"status": "error", "message": "contact not found"}), 404 return jsonify({"status": "success", "message": "contact deleted"}), 200 except Exception as e: print(f"[api_address_book_delete] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/address-book/export", methods=["GET"])@rate_limit(10, 60)def api_address_book_export(): """Export address book as CSV file.""" session_id = _get_session_id() try: csv_data = wallet_history.export_address_book_csv(session_id=session_id) return app.response_class( csv_data, mimetype="text/csv", headers={"Content-Disposition": "attachment;filename=address-book.csv"}, ) except Exception as e: print(f"[api_address_book_export] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500@app.route("/api/address-book/import", methods=["POST"])@rate_limit(10, 60)def api_address_book_import(): """Bulk import contacts from CSV. Expects CSV with headers: label, address, category, notes Skips rows with missing label/address or duplicate labels. """ session_id = _get_session_id() try: # Get CSV data from multipart form or raw body csv_data = None if "file" in request.files: file = request.files["file"] csv_data = file.read().decode("utf-8") elif request.data: csv_data = request.data.decode("utf-8") if not csv_data: raise ValueError("CSV data is required") result = wallet_history.import_address_book_csv( session_id=session_id, csv_data=csv_data, ) return jsonify({"status": "success", "data": result}), 200 except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400 except Exception as e: print(f"[api_address_book_import] Unexpected error: {e}", flush=True) return jsonify({"status": "error", "message": "internal server error"}), 500def _qr_svg(payload: str) -> Optional[str]: """Render `payload` as an SVG QR code, or None if the encoder is unavailable. Pure-Python (qrcode's SVG factory needs no Pillow). Kept optional so the app still runs — the customer can always copy the payment URI — if the dependency is not installed. """ try: import qrcode import qrcode.image.svg as svg except Exception: # noqa: BLE001 — optional dependency return None import io qr = qrcode.QRCode(box_size=8, border=2) qr.add_data(payload) qr.make(fit=True) buf = io.BytesIO() qr.make_image(image_factory=svg.SvgPathImage).save(buf) return buf.getvalue().decode("utf-8")@app.route("/api/merchant/invoices", methods=["GET"])@rate_limit(10, 60)def api_merchant_invoices_list(): """List ONE merchant's invoices, each freshly checked on-chain. A shop dashboard read, now authenticated: requires ?merchant_id=… AND that merchant's manage token in the X-Merchant-Token header ONLY — a query-string token would land in access logs and browser history (audit L1). The old unscoped call that returned every merchant's ledger is gone. Listing also advances any invoice that has been paid or expired since last seen. """ merchant_id = request.args.get("merchant_id") token = request.headers.get("X-Merchant-Token") try: rows = merchants.list_invoices( merchant_received_lookup, merchant_id=merchant_id, manage_token=token ) return jsonify({"status": "success", "invoices": rows}), 200 except merchants.MerchantAuthError as e: return jsonify({"status": "error", "message": str(e)}), 403 except ScanBudgetExhausted as e: return _scan_budget_response(e) except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/merchant/invoices/poll", methods=["POST"])@rate_limit(10, 60)def api_merchant_invoices_poll(): """Sweep ONE merchant's pending invoices, auto-marking paid/expired ones. The automation a shop (or a timer) uses so it need not poll each invoice by hand. Authenticated: requires merchant_id + that merchant's manage token (JSON body or X-Merchant-Token header), which bounds the on-chain scan to that merchant's own addresses. Non-custodial: only observes and records. """ data = request.get_json(silent=True) or {} merchant_id = data.get("merchant_id") token = request.headers.get("X-Merchant-Token") or data.get("manage_token") try: summary = merchants.poll_pending_invoices( merchant_received_lookup, merchant_id=merchant_id, manage_token=token ) return jsonify({"status": "success", **summary}), 200 except merchants.MerchantAuthError as e: return jsonify({"status": "error", "message": str(e)}), 403 except ScanBudgetExhausted as e: return _scan_budget_response(e) except ValueError as e: return jsonify({"status": "error", "message": str(e)}), 400@app.route("/api/merchant/invoice/<invoice_id>/qr.svg", methods=["GET"])@rate_limit(30, 60) # audit H2: status check behind the QR may scan the UTXO setdef api_merchant_invoice_qr(invoice_id: str): """Serve a scannable QR of the invoice's BIP21-style payment URI. Non-custodial: the QR just encodes moonbite:<address>?amount=… so the payer's own wallet prefills the send. The server never touches funds. """ try: inv = merchants.invoice_status(invoice_id, merchant_received_lookup) except ScanBudgetExhausted as e: return _scan_budget_response(e) except ValueError: return jsonify({"status": "error", "message": "invoice not found"}), 404 svg_doc = _qr_svg(inv["pay_uri"]) if svg_doc is None: return jsonify({"status": "error", "message": "QR encoder unavailable"}), 501 return app.response_class(svg_doc, mimetype="image/svg+xml")# ============================================================================= ## API Routes — Wallet# ============================================================================= #@app.route("/api/wallet/new", methods=["GET"])@rate_limit(30, 60)def api_wallet_new(): """Generate a new keypair and return address + pubkey_hash.""" try: sk, pubkey_hex = generate_keypair() pkh = pubkey_hash(pubkey_hex) address = address_from_pubkey_hash(pkh) # Scope generated addresses to THIS visitor's signed session cookie — not # a process-global dict — so one user's /balance never aggregates another # user's addresses, and the server holds no unbounded in-memory state. # Persist only the pubkey_hash (all /balance needs); keep it well under # the ~4KB cookie limit and bounded so it cannot grow without limit. pkhs = [h for h in session.get("wallet_pkhs", []) if h != pkh] pkhs.append(pkh) session["wallet_pkhs"] = pkhs[-_MAX_SESSION_ADDRESSES:] return jsonify( { "status": "success", "address": address, "pubkey_hash": pkh, "pubkey": pubkey_hex, } ), 200 except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again or reload the page", )@app.route("/api/wallet/balance", methods=["GET"])def api_wallet_balance(): """Get balance for all generated addresses in this session.""" try: node = get_node() total_balance = 0 utxo_count = 0 # Only this visitor's own session-scoped addresses (see /api/wallet/new). for pkh in session.get("wallet_pkhs", []): # Iterate through all UTXOs and find those matching this pubkey_hash for _txid, _idx, out in node.chain.utxo.items(): if out.pubkey_hash == pkh: total_balance += out.amount utxo_count += 1 # MoonBite's smallest unit is a "cent": 1 MBITE = 100,000,000 cents # (same 8-decimal precision as Bitcoin). Report the real coin value. from params import CENTS_PER_COIN balance_coins = total_balance / CENTS_PER_COIN return jsonify( { "status": "success", "balance_coins": balance_coins, "balance_units": total_balance, "balance_display": f"{balance_coins:.8f}".rstrip("0").rstrip(".") or "0", "utxo_count": utxo_count, } ), 200 except Exception as e: return json_error( "NETWORK_CONNECTION_ERROR", debug_message=str(e), suggested_action="Please wait and try again", )@app.route("/api/address/<address>/balance", methods=["GET"])@rate_limit(60, 60)def api_address_balance(address: str): """Public balance lookup for any address on the chain. /api/wallet/balance only reports addresses held in the caller's Flask session, which is useless to the wallet PWA: it derives its address client-side from the user's own seed phrase, so the server has never seen that address and would always answer zero. Balances on a public chain are public — every block explorer exposes this and the UTXO set is broadcast to every node — so there is nothing to protect here by requiring ownership proof. Spending still requires the private key; this endpoint is read-only. """ try: if not _valid_receiving_address(address): return json_error( "VALIDATION_INVALID_ADDRESS", suggested_action="Check the address and try again", ) pkh = pubkey_hash_from_address(address) node = get_node() total = 0 utxos = [] for txid, idx, out in node.chain.utxo.items(): if out.pubkey_hash == pkh: total += out.amount utxos.append({"txid": txid, "vout": idx, "amount_units": out.amount}) from params import CENTS_PER_COIN balance_coins = total / CENTS_PER_COIN return jsonify( { "status": "success", "address": address, "balance_coins": balance_coins, "balance_units": total, "balance_display": f"{balance_coins:.8f}".rstrip("0").rstrip(".") or "0", "utxo_count": len(utxos), "utxos": utxos, "height": node.chain.height, } ), 200 except Exception as e: # noqa: BLE001 return json_error( "NETWORK_CONNECTION_ERROR", debug_message=str(e), suggested_action="Please wait and try again", )# ============================================================================= ## API Routes — HD Wallet (BIP39/BIP32)# ============================================================================= ## These routes generated a BIP39 seed on the SERVER and parked the mnemonic in# the Flask session cookie — which is signed but not encrypted, so the seed was# recoverable in plaintext by anyone who could read the cookie, and the server# itself saw and held every seed. That is disqualifying for a self-custody# wallet, where the seed must be born and stay in the browser and never cross# the wire. Key generation now lives entirely client-side (moonbite-hd.js /# moonbite-bip39.js); these endpoints are retired. They are answered with 410# Gone rather than deleted so any lingering caller gets a clear, permanent# signal instead of a silent 404._HD_GONE = { "status": "error", "error": "endpoint_removed", "message": ( "Server-side seed handling has been removed. MoonBite is self-custody: " "your recovery phrase is generated and kept in your own browser and is " "never sent to the server. Use the wallet at /wallet." ),}@app.route("/api/wallet/hd/new", methods=["GET"])@app.route("/api/wallet/hd/seed", methods=["GET"])@app.route("/api/wallet/hd/import", methods=["POST"])@app.route("/api/wallet/hd/address", methods=["GET"])def api_wallet_hd_removed(): """Retired server-side seed endpoints — see comment above.""" return jsonify(_HD_GONE), 410# ============================================================================= ## API Routes — Multi-Account Management# ============================================================================= #@app.route("/api/wallet/accounts", methods=["GET"])@rate_limit(30, 60)def api_wallet_accounts_list(): """List all accounts for the current user session with their balances.""" try: session_id = _get_session_id() accounts = wallet_history.list_accounts(session_id) result = [] for account in accounts: # Get addresses for this account addresses = wallet_history.get_account_addresses(account["id"]) account_dict = dict(account) # Calculate balance by summing UTXOs from all active addresses total_balance = 0 utxo_count = 0 node = get_node() for addr_record in addresses: pkh = addr_record["pubkey_hash"] if pkh: for _txid, _idx, out in node.chain.utxo.items(): if out.pubkey_hash == pkh: total_balance += out.amount utxo_count += 1 # Update cached balance wallet_history.update_account_balance(session_id, account["id"], total_balance) account_dict["balance_units"] = total_balance account_dict["balance_coins"] = total_balance / 100000000 # CENTS_PER_COIN account_dict["utxo_count"] = utxo_count account_dict["address_count"] = len(addresses) result.append(account_dict) return jsonify( { "status": "success", "accounts": result, "total_count": len(result), } ), 200 except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/wallet/accounts/<account_id>", methods=["GET"])@rate_limit(30, 60)def api_wallet_account_detail(account_id: str): """Get details for a specific account including addresses and balance.""" try: session_id = _get_session_id() account = wallet_history.get_account(session_id, account_id) if not account: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account not found", status_code=404, ) account_dict = dict(account) # Get all addresses for this account addresses = wallet_history.get_account_addresses(account_id) address_list = [dict(addr) for addr in addresses] # Calculate balance total_balance = 0 utxo_count = 0 node = get_node() for addr_record in addresses: pkh = addr_record["pubkey_hash"] if pkh: for _txid, _idx, out in node.chain.utxo.items(): if out.pubkey_hash == pkh: total_balance += out.amount utxo_count += 1 wallet_history.update_account_balance(session_id, account_id, total_balance) account_dict["addresses"] = address_list account_dict["balance_units"] = total_balance account_dict["balance_coins"] = total_balance / 100000000 account_dict["utxo_count"] = utxo_count return jsonify( { "status": "success", "account": account_dict, } ), 200 except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )def _register_public_account(session_id, name, address, color, is_default, derivation_path): """Store an account from a client-derived address. No key material. Self-custody means the seed is generated and kept in the browser (static/moonbite-bip39.js); the server only ever records the public address and label. mnemonic=None so the DB stores no seed-derived hash, and the session holds non-secret fields only. """ account = wallet_history.create_account( session_id, name, mnemonic=None, color=color or None, is_default=is_default ) # The pubkey_hash is a legacy field only the retired demo-balance path read # (it summed the in-process UTXO set by hash). The educational decoder cannot # parse the real segwit/base58 addresses the live node issues, and on the # live node balance comes from the address itself (scantxoutset), so derive # it best-effort and store None when it is not decodable rather than 500. try: pkh = pubkey_hash_from_address(address) except Exception: # noqa: BLE001 — real addresses the educational helper can't decode pkh = None wallet_history.add_account_address( account["id"], address=address, derivation_path=derivation_path or "m/44'/0'/0'/0/0", pubkey_hash=pkh, ) session[f"account_{account['id']}"] = { "id": account["id"], "name": account["name"], "hd_index": 1, } return account, address@app.route("/api/wallet/accounts/create", methods=["POST"])@rate_limit(10, 60)def api_wallet_accounts_create(): """Create a new account with a generated HD wallet.""" try: session_id = _get_session_id() data = request.get_json() or {} name = (data.get("name") or "").strip() if not name: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account name is required", suggested_action="Please provide a name like 'Main', 'Savings', etc.", ) # Self-custody: the seed is generated in the browser and never sent # here. The client posts only the public address it derived; the # server stores the label + address and never sees key material. address = (data.get("address") or "").strip() if not address: return json_error( "VALIDATION_MISSING_FIELD", user_message="A derived wallet address is required", suggested_action=( "Generate the seed in your browser and send only the " "derived address; the server never receives the seed." ), ) if not _valid_receiving_address(address): return json_error( "VALIDATION_INVALID_ADDRESS", user_message="That is not a valid MoonBite address for this network", suggested_action="Send a mainnet moon1 address derived in your browser", ) color = (data.get("color") or "").strip() is_default = bool(data.get("is_default", False)) derivation_path = (data.get("derivation_path") or "").strip() account, first_address = _register_public_account( session_id, name, address, color, is_default, derivation_path ) return jsonify( { "status": "success", "account": { "id": account["id"], "name": account["name"], "color": account["color"], "is_default": bool(account["is_default"]), "created_at": account["created_at"], }, "first_address": first_address, "message": "Account added. Your seed stays in your browser - back it up there.", } ), 201 except ValueError as e: return json_error( "VALIDATION_MISSING_FIELD", user_message=str(e), suggested_action="Please try again with a different name", ) except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/wallet/accounts/import", methods=["POST"])@rate_limit(5, 60)def api_wallet_accounts_import(): """Import an account from an existing mnemonic.""" try: session_id = _get_session_id() data = request.get_json() or {} name = (data.get("name") or "").strip() if not name: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account name is required", suggested_action="Please provide a name", ) # Self-custody import: the user's existing seed is validated and its # address derived IN THE BROWSER (static/moonbite-address.js). The # server receives only that derived address - never the seed or # passphrase - so a restored wallet exposes no key material server-side. address = (data.get("address") or "").strip() if not address: return json_error( "VALIDATION_MISSING_FIELD", user_message="A derived wallet address is required", suggested_action=( "Derive the address from your seed in your browser and send " "only that address; the server never receives the seed." ), ) if not _valid_receiving_address(address): return json_error( "VALIDATION_INVALID_ADDRESS", user_message="That is not a valid MoonBite address for this network", suggested_action="Send a mainnet moon1 address derived in your browser", ) color = (data.get("color") or "").strip() is_default = bool(data.get("is_default", False)) derivation_path = (data.get("derivation_path") or "").strip() account, first_address = _register_public_account( session_id, name, address, color, is_default, derivation_path ) return jsonify( { "status": "success", "account": { "id": account["id"], "name": account["name"], "color": account["color"], "is_default": bool(account["is_default"]), "created_at": account["created_at"], }, "first_address": first_address, "message": "Account imported. Your seed stays in your browser.", } ), 201 except ValueError as e: return json_error( "VALIDATION_MISSING_FIELD", user_message=str(e), suggested_action="Please try again with different parameters", ) except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/wallet/accounts/<account_id>", methods=["PATCH"])@rate_limit(20, 60)def api_wallet_accounts_update(account_id: str): """Update account name and/or color.""" try: session_id = _get_session_id() data = request.get_json() or {} name = data.get("name") color = data.get("color") if name is None and color is None: return json_error( "VALIDATION_MISSING_FIELD", user_message="Please provide name and/or color to update", ) account = wallet_history.update_account(session_id, account_id, name, color) if not account: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account not found", status_code=404, ) return jsonify( { "status": "success", "account": dict(account), } ), 200 except ValueError as e: return json_error( "VALIDATION_MISSING_FIELD", user_message=str(e), suggested_action="Please try again with a different name", ) except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/wallet/accounts/<account_id>/switch", methods=["POST"])@rate_limit(20, 60)def api_wallet_accounts_switch(account_id: str): """Switch to an account (set as current in session).""" try: session_id = _get_session_id() account = wallet_history.get_account(session_id, account_id) if not account: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account not found", status_code=404, ) # Store current account in session session["current_account_id"] = account_id return jsonify( { "status": "success", "current_account_id": account_id, "account_name": account["name"], } ), 200 except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/wallet/accounts/<account_id>/set-default", methods=["POST"])@rate_limit(20, 60)def api_wallet_accounts_set_default(account_id: str): """Set an account as the default.""" try: session_id = _get_session_id() account = wallet_history.set_default_account(session_id, account_id) if not account: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account not found", status_code=404, ) return jsonify( { "status": "success", "account": dict(account), } ), 200 except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/wallet/accounts/<account_id>", methods=["DELETE"])@rate_limit(10, 60)def api_wallet_accounts_delete(account_id: str): """Delete (soft-delete) an account.""" try: session_id = _get_session_id() deleted = wallet_history.delete_account(session_id, account_id) if not deleted: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account not found", status_code=404, ) # Clear from session if it was the current account if session.get("current_account_id") == account_id: session.pop("current_account_id", None) return jsonify( { "status": "success", "message": "Account deleted", } ), 200 except Exception as e: return json_error( "INTERNAL_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/wallet/accounts/<account_id>/balance", methods=["GET"])@rate_limit(30, 60)def api_wallet_accounts_balance(account_id: str): """Get balance for a specific account.""" try: session_id = _get_session_id() account = wallet_history.get_account(session_id, account_id) if not account: return json_error( "VALIDATION_MISSING_FIELD", user_message="Account not found", status_code=404, ) # Get addresses for this account addresses = wallet_history.get_account_addresses(account_id) # Calculate balance by summing UTXOs total_balance = 0 utxo_count = 0 node = get_node() for addr_record in addresses: pkh = addr_record["pubkey_hash"] if pkh: for _txid, _idx, out in node.chain.utxo.items(): if out.pubkey_hash == pkh: total_balance += out.amount utxo_count += 1 wallet_history.update_account_balance(session_id, account_id, total_balance) from params import CENTS_PER_COIN return jsonify( { "status": "success", "account_id": account_id, "balance_coins": total_balance / CENTS_PER_COIN, "balance_units": total_balance, "balance_display": f"{total_balance / CENTS_PER_COIN:.8f}".rstrip("0").rstrip(".") or "0", "utxo_count": utxo_count, } ), 200 except Exception as e: return json_error( "NETWORK_CONNECTION_ERROR", debug_message=str(e), suggested_action="Please wait and try again", )# ============================================================================= ## API Routes — Wallet Preferences# ============================================================================= #@app.route("/api/wallet/preferences", methods=["GET"])@rate_limit(30, 60)def api_wallet_preferences_get(): """Get all user preferences with defaults filled in.""" try: session_id = _get_session_id() prefs = wallet_history.get_preferences(session_id) return jsonify( { "status": "success", "preferences": prefs, } ), 200 except Exception as e: return json_error( "INTERNAL_SERVER_ERROR", debug_message=str(e), status_code=500, )@app.route("/api/wallet/preferences", methods=["PATCH"])@rate_limit(20, 60)def api_wallet_preferences_update(): """Update user preferences. Returns all current preferences after update.""" try: session_id = _get_session_id() data = request.get_json() or {} if not isinstance(data, dict): return json_error( "VALIDATION_INVALID_TYPE", user_message="Request body must be a JSON object", status_code=400, ) # Update preferences (validates all keys/values internally) updated_prefs = wallet_history.update_preferences(session_id, data) return jsonify( { "status": "success", "preferences": updated_prefs, } ), 200 except ValueError as e: return json_error( "VALIDATION_INVALID_VALUE", user_message=str(e), status_code=400, ) except Exception as e: return json_error( "INTERNAL_SERVER_ERROR", debug_message=str(e), status_code=500, )@app.route("/api/wallet/preferences/defaults", methods=["GET"])@rate_limit(30, 60)def api_wallet_preferences_defaults(): """Get default preference values.""" try: defaults = wallet_history.get_preference_defaults() return jsonify( { "status": "success", "defaults": defaults, } ), 200 except Exception as e: return json_error( "INTERNAL_SERVER_ERROR", debug_message=str(e), status_code=500, )@app.route("/api/wallet/preferences/reset", methods=["POST"])@rate_limit(10, 60)def api_wallet_preferences_reset(): """Reset all user preferences to defaults.""" try: session_id = _get_session_id() reset_prefs = wallet_history.reset_preferences(session_id) return jsonify( { "status": "success", "message": "Preferences reset to defaults", "preferences": reset_prefs, } ), 200 except Exception as e: return json_error( "INTERNAL_SERVER_ERROR", debug_message=str(e), status_code=500, )# ============================================================================= ## API Routes — Blockchain Info# ============================================================================= #_chain_addr_cache: dict = {}_CHAIN_ADDR_TTL = 5.0# Blocks a mining reward must wait before it can be spent (MoonBite Core# consensus, inherited unchanged from Bitcoin/Litecoin).COINBASE_MATURITY = 100@app.route("/api/chain/address/<address>", methods=["GET"])@rate_limit(15, 60)def api_chain_address(address): """Real balance + spendable UTXOs for an address, straight from the node (scantxoutset). Powers the self-custody wallet. Amounts in base units (1e8). """ if not _merchant_use_rpc(): return jsonify({"status": "error", "message": "node not configured"}), 503 # scantxoutset walks the whole UTXO set (seconds on the seed box) and the # address lands inside a descriptor string, so junk input is both a DoS # vector and an injection risk: validate first, and cache the full reply # briefly so a polling wallet doesn't pin the node. if not _valid_receiving_address(address): return jsonify({"status": "error", "message": "invalid address"}), 400 hit = _chain_addr_cache.get(address) if hit is not None and time.time() - hit[0] < _CHAIN_ADDR_TTL: return jsonify(hit[1]), 200 try: rpc = _get_merchant_rpc() try: res = _budgeted_scantxoutset(rpc, address) except ScanBudgetExhausted as e: # Global budget spent (audit H2): a stale cached reply beats a 503. if hit is not None: return jsonify(hit[1]), 200 return _scan_budget_response(e) if not isinstance(res, dict) or not res.get("success", False): return jsonify({"status": "error", "message": "scan failed"}), 502 from decimal import Decimal # Mining rewards (coinbase outputs) can't be spent until they have # COINBASE_MATURITY confirmations; a wallet that picks one gets its # transaction rejected. Only coins from the last COINBASE_MATURITY # blocks can still be immature, and this node's scantxoutset carries # no coinbase flag, so ask gettxout about just those. height = int(res.get("height") or 0) utxos = [] immature = [] spendable_total = 0 immature_total = 0 for u in res.get("unspents", []): units = int((Decimal(str(u["amount"])) * 100000000).to_integral_value()) entry = {"txid": u["txid"], "vout": int(u["vout"]), "value": units} coin_height = int(u.get("height") or 0) maturing = False if coin_height and (height + 1) - coin_height < COINBASE_MATURITY: try: txout = rpc.gettxout(u["txid"], int(u["vout"]), False) maturing = bool(txout and txout.get("coinbase")) except Exception: # noqa: BLE001 maturing = True # unsure: never offer it for spending if maturing: entry["matures_at_height"] = coin_height + COINBASE_MATURITY - 1 immature.append(entry) immature_total += units else: utxos.append(entry) spendable_total += units payload = { "status": "success", "address": address, # Spendable only, so every client that compares an amount against # balance_units or spends from utxos never touches maturing coins. "balance_units": spendable_total, "immature_units": immature_total, "total_units": spendable_total + immature_total, "utxos": utxos, "immature_utxos": immature, "height": height, } _chain_addr_cache[address] = (time.time(), payload) return jsonify(payload), 200 except Exception: # noqa: BLE001 app.logger.exception("chain address scan failed") return jsonify({"status": "error", "message": "lookup failed"}), 502@app.route("/api/chain/broadcast", methods=["POST"])@rate_limit(30, 60)def api_chain_broadcast(): """Broadcast a raw, already-signed transaction to the network.""" if not _merchant_use_rpc(): return jsonify({"status": "error", "message": "node not configured"}), 503 data = request.get_json(silent=True) or {} rawtx = str(data.get("rawtx") or data.get("transaction") or "").strip() if not rawtx or any(c not in "0123456789abcdefABCDEF" for c in rawtx): return jsonify({"status": "error", "message": "no raw transaction"}), 400 try: txid = _get_merchant_rpc().sendrawtransaction(rawtx) return jsonify({"status": "success", "txid": txid}), 200 except Exception as e: # noqa: BLE001 return jsonify({"status": "error", "message": str(e)[:200]}), 400@app.route("/api/blockchain/info", methods=["GET"])@rate_limit(60, 60) # Public info endpointdef api_blockchain_info(): """Get blockchain state: height, tip hash, total money, tx count.""" if _merchant_use_rpc(): payload, code = live_explorer.blockchain_info(_get_merchant_rpc()) return jsonify(payload), code try: node = get_node() chain = node.chain # Count total transactions in the active chain tx_count = sum( len(block.transactions) for block_hash in chain.active_chain() for block in [chain.blocks[block_hash]] ) # Calculate total money (sum of ONLY coinbase outputs, which create new coins) # Non-coinbase transactions just move existing coins, not creating new value. # In a real system, this would be tracked more efficiently. total_money_satoshis = sum( block.transactions[0].outputs[0].amount # coinbase is always tx[0], output[0] for block_hash in chain.active_chain() for block in [chain.blocks[block_hash]] if block.transactions # skip empty blocks (none should exist, but be safe) ) total_money_coins = total_money_satoshis / 100_000_000 tip_bits = chain.blocks[chain.tip].header.bits if chain.tip else 0 return jsonify( { "status": "success", "height": chain.height, "tip_hash": chain.tip, "total_money_satoshis": total_money_satoshis, "total_money_coins": total_money_coins, "tx_count": tx_count, "mempool_size": len(chain.mempool), "bits": tip_bits, "difficulty": (1 << tip_bits) if tip_bits else 0, } ), 200 except Exception as e: return json_error( "NETWORK_CONNECTION_ERROR", debug_message=str(e), suggested_action="Please try again", )@app.route("/api/blockchain/status", methods=["GET"])@rate_limit(60, 60) # Public status endpointdef api_blockchain_status(): """Get blockchain sync status for offline mode indicator. Returns: { is_synced: bool - true if blockchain is fully synced current_height: int - current block height peers_connected: int - number of connected peers blocks_behind: int - estimated blocks behind (0 if synced) sync_percentage: float - sync progress 0-100 last_block_time: float - unix timestamp of last block blockchain_healthy: bool - true if sync is progressing normally estimated_sync_seconds: int - estimated seconds until synced (-1 if unknown) } """ try: node = get_node() chain = node.chain # Get current blockchain state current_height = chain.height last_block = chain.blocks.get(chain.tip) last_block_time = last_block.header.timestamp if last_block else time.time() # If node has no blocks (height 0), return demo/healthy status # This happens when local node is not running (MoonBite pre-launch) if current_height == 0: return jsonify( { "status": "success", "is_synced": True, "current_height": 0, "peers_connected": 0, "blocks_behind": 0, "sync_percentage": 100.0, "last_block_time": time.time(), "blockchain_healthy": True, "estimated_sync_seconds": 0, "timestamp": time.time(), "mode": "demo" # Indicates wallet in external RPC mode } ), 200 # For demo/educational network, assume we're synced if we have blocks # In production, this would check against known peer heights is_synced = current_height > 1 or current_height == 1 # Track if blockchain is healthy (has recent blocks) time_since_last_block = time.time() - last_block_time blockchain_healthy = time_since_last_block < 600 # Healthy if block within 10 min return jsonify( { "status": "success", "is_synced": is_synced, "current_height": current_height, "peers_connected": 0, # Demo network, no peer tracking "blocks_behind": 0 if is_synced else 1, "sync_percentage": 100.0 if is_synced else 50.0, "last_block_time": last_block_time, "blockchain_healthy": blockchain_healthy, "estimated_sync_seconds": 0 if is_synced else 30, "timestamp": time.time(), } ), 200 except Exception as e: # Fallback: Return demo/healthy status when local node unavailable # (MoonBite pre-launch - wallet functions for key management and local operations) # When external RPC is configured via BIGCOIN_RPC_URL, this will use that instead return jsonify( { "status": "success", "is_synced": True, "current_height": 0, "peers_connected": 0, "blocks_behind": 0, "sync_percentage": 100.0, "last_block_time": time.time(), "blockchain_healthy": True, "estimated_sync_seconds": 0, "timestamp": time.time(), "mode": "demo" # Indicates wallet is in demo mode without live blockchain } ), 200# ============================================================================= ## API Routes — Mining# ============================================================================= #@app.route("/api/mining/start", methods=["POST"])@rate_limit(6, 60)def api_mining_start(): """Start mining blocks. Expects JSON: {"blocks": N, "address": "..."} Now supports concurrent mining from multiple devices/clients. Each request gets a unique job_id and runs in parallel. """ print(f"[MINING API] New concurrent mining endpoint called", flush=True) try: # Refuse new work when the box is already saturated, before doing # any parsing. Each job spawns a thread doing proof-of-work and this # endpoint was uncapped: a caller could loop it and pin every core on a # 1-vCPU host. The rate limit slows arrivals; this bounds concurrency, # and checking first means a flood costs us almost nothing. with app.mining_lock: running = sum( 1 for j in app.mining_state["active_jobs"].values() if j.get("is_mining") ) if running >= _MAX_CONCURRENT_MINING_JOBS: return jsonify( { "status": "error", "message": ( "The node is already mining at capacity " f"({_MAX_CONCURRENT_MINING_JOBS} jobs). Try again shortly." ), } ), 429 data = request.get_json() blocks_to_mine = data.get("blocks", 1) miner_address = data.get("address") # Reject a non-integer or out-of-range block count (bool is an int # subclass, so exclude it explicitly). Capping at _MAX_MINE_BLOCKS # stops a single request from pinning a worker on unbounded PoW. if isinstance(blocks_to_mine, bool) or not isinstance(blocks_to_mine, int): return jsonify( { "status": "error", "message": "'blocks' must be an integer", } ), 400 if not miner_address or blocks_to_mine <= 0 or blocks_to_mine > _MAX_MINE_BLOCKS: return jsonify( { "status": "error", "message": f"Invalid blocks (1-{_MAX_MINE_BLOCKS}) or address", } ), 400 # Validate and convert address to pubkey_hash try: from wallet import pubkey_hash_from_address miner_pubkey_hash = pubkey_hash_from_address(miner_address) except Exception as e: return jsonify( { "status": "error", "message": f"Invalid address format: {str(e)}", } ), 400 # Generate unique job ID for this mining request job_id = str(uuid.uuid4()) # Register the job before the thread starts. # # The worker used to be the first thing to record it, so a caller that # polled /api/mining/status right after this call could be told "idle" # and conclude mining had finished before it began. Registering here # means the status is accurate the moment this endpoint returns. with app.mining_lock: app.mining_state["active_jobs"][job_id] = { "job_id": job_id, "is_mining": True, "blocks_to_mine": blocks_to_mine, "blocks_mined": 0, "hashes_tried": 0, "hashrate": 0.0, "started_at": time.time(), "mining_address": miner_pubkey_hash, "last_error": None, } # Start mining in a background thread thread = threading.Thread( target=mining_worker, args=(job_id, blocks_to_mine, miner_pubkey_hash), daemon=True ) thread.start() return jsonify( { "status": "mining", "job_id": job_id, "blocks_to_mine": blocks_to_mine, } ), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/mining/status", methods=["GET"])def api_mining_status(): """Get current mining status (supports multiple concurrent jobs). Optional query params: - include=leaderboard : Add top miners leaderboard - include=merchants : Add merchant payment status """ try: from block import block_subsidy node = get_node() chain = node.chain next_bits = chain.next_bits() difficulty = 1 << next_bits next_height = chain.height + 1 block_reward = block_subsidy(next_height) now = time.time() with app.mining_lock: jobs = app.mining_state["active_jobs"] # Finished jobs stay briefly so a poller can read the final counts, # then go. Keeping them forever both pinned the status at "mining" # and grew this dict without bound, one entry per job for the life # of the process. for job_id in [ jid for jid, j in jobs.items() if not j.get("is_mining") and now - j.get("finished_at", now) > _FINISHED_JOB_RETENTION_SEC ]: del jobs[job_id] active_jobs = jobs.copy() running = {jid: j for jid, j in active_jobs.items() if j.get("is_mining")} # Only a job that is actually running means we are mining. This counted # every retained job, so the endpoint reported "mining" forever after # the first one and no caller could ever see it finish. is_mining = len(running) > 0 # Counters cover finished jobs too: a caller that polls after the last # block lands should still see how many were mined, not a sudden zero. total_blocks_mined = sum(j.get("blocks_mined", 0) for j in active_jobs.values()) total_blocks_target = sum(j.get("blocks_to_mine", 0) for j in active_jobs.values()) total_hashes_tried = sum(j.get("hashes_tried", 0) for j in active_jobs.values()) # Hashrate is an instantaneous rate, so only running jobs contribute. combined_hashrate = sum(j.get("hashrate", 0.0) for j in running.values()) # Estimated seconds to the next block at combined hashrate eta_seconds = (difficulty / combined_hashrate) if combined_hashrate > 0 else None response = { "status": "mining" if is_mining else "idle", # Explicit boolean so a caller does not have to string-compare # "mining"/"idle"/"error" to answer "is it still going?". "mining": is_mining, "active_jobs": len(running), "retained_jobs": len(active_jobs), "blocks_mined": total_blocks_mined, "total_blocks_target": total_blocks_target, # mining.html reads total_blocks, so its progress bar sat at 0%. # Kept as an alias rather than renamed, so existing callers of # total_blocks_target keep working. "total_blocks": total_blocks_target, "current_height": chain.height, "tip_hash": chain.tip, "bits": next_bits, "difficulty": difficulty, "total_hashes_tried": total_hashes_tried, "combined_hashrate": round(combined_hashrate, 2), "eta_next_block_seconds": (round(eta_seconds, 2) if eta_seconds is not None else None), "next_block_reward_coins": block_reward / 100_000_000, } # Add leaderboard if requested if request.args.get("include") == "leaderboard": address_blocks = {} for block_hash in chain.active_chain(): block = chain.blocks[block_hash] if block.transactions and block.transactions[0].is_coinbase(): coinbase = block.transactions[0] if coinbase.outputs: output = coinbase.outputs[0] try: addr = address_from_pubkey_hash(output.pubkey_hash) address_blocks[addr] = address_blocks.get(addr, 0) + 1 except Exception: pass leaderboard = sorted( [{"address": addr, "blocks": count} for addr, count in address_blocks.items()], key=lambda x: x["blocks"], reverse=True )[:50] response["leaderboard"] = leaderboard return jsonify(response), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/mining/stop", methods=["GET"])def api_mining_stop(): """Stop mining (all jobs or specific job_id).""" try: job_id = request.args.get("job_id") with app.mining_lock: if job_id: # Stop a specific job if job_id in app.mining_state["active_jobs"]: app.mining_state["active_jobs"][job_id]["is_mining"] = False return jsonify({"status": "stopped", "job_id": job_id}), 200 else: return jsonify({"status": "error", "message": f"Job {job_id} not found"}), 404 else: # Stop all jobs stopped_count = 0 for job in app.mining_state["active_jobs"].values(): if job.get("is_mining"): job["is_mining"] = False stopped_count += 1 return jsonify({"status": "stopped", "jobs_stopped": stopped_count}), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/mining/globalstats", methods=["GET"])@rate_limit(30, 60) # Allow 30 requests per minute for stats updatesdef api_mining_global_stats(): """Get global mining statistics for viral display.""" try: with app.mining_lock: active_jobs = app.mining_state["active_jobs"].copy() # Count active miners (only those actively mining right now) active_miners = sum(1 for job in active_jobs.values() if job.get("is_mining")) total_blocks_mined = sum(job.get("blocks_mined", 0) for job in active_jobs.values()) total_blocks_target = sum(job.get("blocks_to_mine", 0) for job in active_jobs.values()) combined_hashrate = sum(job.get("hashrate", 0.0) for job in active_jobs.values()) node = get_node() return jsonify( { "status": "success", "active_miners": active_miners, "active_jobs": len(active_jobs), "blocks_mined_globally": total_blocks_mined, "blocks_pending": total_blocks_target - total_blocks_mined, "combined_hashrate": round(combined_hashrate, 2), "current_height": node.chain.height, "network_difficulty": 1 << node.chain.next_bits(), } ), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/merchants/invoice/<invoice_id>", methods=["GET"])@rate_limit(30, 60) # Check invoice statusdef api_merchants_invoice_status(invoice_id): """Get invoice status and payment progress.""" try: # In production: look up invoice in database # For MVP: check if payment address received MBITE # Mock response (would check actual payment in production) return jsonify({ "status": "success", "invoice_id": invoice_id, "payment_status": "pending", # or "paid", "expired" "amount_expected": 10, "amount_received": 0, "confirmations": 0, "expires_at": int(time.time()) + 3600, }), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/merchants/create-invoice", methods=["POST"])@rate_limit(30, 60) # Merchants can create invoicesdef api_merchants_create_invoice(): """Create a payment invoice for a digital product. Request: { "merchant_address": "moon1xxx", "product_name": "My eBook", "amount_mbite": 10, "product_id": "ebook-001", "customer_email": "buyer@example.com" } Response: { "invoice_id": "inv_abc123", "status": "pending", "payment_address": "moon1yyy", # Unique address for this payment "amount_mbite": 10, "fee_mbite": 0.2, # 2% fee "expires_at": 3600 # seconds } """ try: data = request.get_json() merchant_address = data.get("merchant_address") product_name = data.get("product_name", "Digital Product") amount_mbite = float(data.get("amount_mbite", 0)) product_id = data.get("product_id", "") if not merchant_address or amount_mbite <= 0: return jsonify({ "status": "error", "message": "Invalid merchant_address or amount" }), 400 # Calculate 2% fee (1% to miners, 0.5% merchants, 0.5% development) fee_mbite = amount_mbite * 0.02 net_to_merchant = amount_mbite - fee_mbite # Generate unique payment address for this invoice # (In production: would create HD wallet sub-address) import uuid invoice_id = f"inv_{uuid.uuid4().hex[:12]}" payment_address = f"moon1{uuid.uuid4().hex[:59]}" # Mock address return jsonify({ "status": "success", "invoice_id": invoice_id, "payment_address": payment_address, "amount_mbite": amount_mbite, "fee_mbite": round(fee_mbite, 8), "net_to_merchant": round(net_to_merchant, 8), "product_name": product_name, "product_id": product_id, "expires_seconds": 3600, "created_at": int(time.time()), }), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/mining/leaderboard", methods=["GET"])@rate_limit(30, 60) # Leaderboard is public & frequently accesseddef api_mining_leaderboard(): """Get mining leaderboard - top miners by blocks mined (this week/all-time).""" try: period = request.args.get("period", "week") # week or all limit = min(int(request.args.get("limit", "50")), 100) # Max 100 node = get_node() chain = node.chain # Build address -> blocks_mined map from chain address_blocks = {} for block_hash in chain.active_chain(): block = chain.blocks[block_hash] # Coinbase tx has miner address if block.transactions and block.transactions[0].is_coinbase(): coinbase = block.transactions[0] if coinbase.outputs: # Get first output's recipient (miner address) output = coinbase.outputs[0] try: addr = address_from_pubkey_hash(output.pubkey_hash) address_blocks[addr] = address_blocks.get(addr, 0) + 1 except Exception: pass # Sort by blocks mined leaderboard = sorted( [{"address": addr, "blocks": count} for addr, count in address_blocks.items()], key=lambda x: x["blocks"], reverse=True )[:limit] return jsonify( { "status": "success", "period": period, "leaderboard": leaderboard, "total_miners": len(address_blocks), "current_height": chain.height, } ), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/mining/receipt/<job_id>", methods=["GET"])@rate_limit(30, 60) # Allow receipt generation requestsdef api_mining_share(job_id): """Generate shareable receipt data for a completed mining job. Returns OG tags and share text for Twitter, TikTok, etc. """ try: with app.mining_lock: job = app.mining_state["active_jobs"].get(job_id) if not job: return jsonify({"status": "error", "message": "Job not found"}), 404 blocks_mined = job.get("blocks_mined", 0) hashrate = job.get("hashrate", 0) if blocks_mined == 0: return jsonify({"status": "error", "message": "No blocks mined yet"}), 400 # MBITE earned at the CURRENT subsidy — never a hardcoded figure, # so the share text stays truthful across halvings. from block import block_subsidy from params import CENTS_PER_COIN subsidy_coins = block_subsidy(get_node().chain.height) / CENTS_PER_COIN mbite_earned = blocks_mined * subsidy_coins # Generate share text for different platforms s = 's' if blocks_mined > 1 else '' share_text = (f"I just mined {blocks_mined} MoonBite block{s}! " f"Earned {mbite_earned:,.0f} MBITE 🌙⛏️") twitter_text = f"{share_text} Join me: https://moonbite.org/mining" tiktok_text = f"Mining crypto with {share_text} #MoonBite #Mining" return jsonify( { "status": "success", "job_id": job_id, "blocks_mined": blocks_mined, "mbite_earned": mbite_earned, "hashrate": round(hashrate, 2), "share_text": share_text, "twitter": { "text": twitter_text, "url": f"https://twitter.com/intent/tweet?text={share_text.replace(' ', '%20')}&url=https://moonbite.org", }, "tiktok": { "text": tiktok_text, }, "og_tags": { "title": f"I mined {mbite_earned:,.0f} MBITE! 🚀", "description": (f"Mined {blocks_mined} block{s} " "using the MoonBite browser miner"), "image": "https://moonbite.org/favicon.svg", "url": f"https://moonbite.org/mining?receipt={job_id}", }, } ), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500# ============================================================================= ## API Routes — Transactions# ============================================================================= #@app.route("/api/transactions", methods=["GET"])def api_transactions(): """Get recent transactions from mempool and recent blocks.""" try: node = get_node() transactions = [] # Get mempool transactions (pending) for txid, tx in list(node.chain.mempool.items())[:10]: transactions.append( { "txid": txid, "status": "pending", "inputs": len(tx.inputs), "outputs": len(tx.outputs), } ) # Get transactions from the last 5 blocks chain = node.chain for block_hash in chain.active_chain()[-5:]: block = chain.blocks[block_hash] for tx in block.transactions: transactions.append( { "txid": tx.txid, "status": "confirmed", "inputs": len(tx.inputs), "outputs": len(tx.outputs), } ) return jsonify( { "status": "success", "transactions": transactions[:20], # Limit to 20 most recent } ), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/mempool", methods=["GET"])def api_mempool(): """Return only the pending (unconfirmed) transactions in the mempool. Shape matches the wallet page's mempool panel: each entry carries the txid, input/output counts, and the total output value in cents. """ if _merchant_use_rpc(): payload, code = live_explorer.mempool(_get_merchant_rpc()) return jsonify(payload), code try: node = get_node() transactions = [] for txid, tx in node.chain.mempool.items(): total_out_cents = sum(out.amount for out in tx.outputs) transactions.append( { "txid": txid, "inputs": len(tx.inputs), "outputs": len(tx.outputs), "total_out_cents": total_out_cents, } ) return jsonify({"status": "success", "transactions": transactions}), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500# ============================================================================= ## API Routes — Block Explorer# ============================================================================= #def _block_summary(chain, block_hash: str) -> dict: """Build a compact summary dict for a block.""" block = chain.blocks[block_hash] header = block.header height = chain.heights[block_hash] confirmations = chain.height - height + 1 return { "height": height, "hash": block_hash, "confirmations": confirmations, "timestamp": header.timestamp, "tx_count": len(block.transactions), "size": block.serialized_size(), "nonce": header.nonce, "bits": header.bits, "prev_hash": header.prev_hash, "merkle_root": header.merkle_root, }def _tx_summary(tx) -> dict: """Build a detailed summary dict for a transaction.""" outputs = [] total_out = 0 for out in tx.outputs: total_out += out.amount try: address = address_from_pubkey_hash(out.pubkey_hash) except Exception: address = None outputs.append( { "amount": out.amount, "pubkey_hash": out.pubkey_hash, "address": address, } ) inputs = [] for inp in tx.inputs: inputs.append( { "prev_txid": inp.prev_txid, "output_index": inp.output_index, } ) return { "txid": tx.txid, "is_coinbase": tx.is_coinbase(), "input_count": len(tx.inputs), "output_count": len(tx.outputs), "total_out": total_out, "inputs": inputs, "outputs": outputs, }@app.route("/api/explorer/blocks", methods=["GET"])def api_explorer_blocks(): """Return a paginated list of blocks, newest first.""" try: try: limit = int(request.args.get("limit", 15)) except (TypeError, ValueError): limit = 15 try: offset = int(request.args.get("offset", 0)) except (TypeError, ValueError): offset = 0 limit = max(1, min(limit, 50)) offset = max(0, offset) if _merchant_use_rpc(): payload, code = live_explorer.blocks(_get_merchant_rpc(), limit, offset) return jsonify(payload), code node = get_node() chain = node.chain active = chain.active_chain() # genesis -> tip newest_first = list(reversed(active)) page = newest_first[offset : offset + limit] blocks = [_block_summary(chain, h) for h in page] return jsonify( { "status": "success", "blocks": blocks, "total": len(active), "offset": offset, "limit": limit, } ), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/explorer/block/<identifier>", methods=["GET"])@rate_limit(30, 60)def api_explorer_block(identifier: str): """Return a block by height or hash, including its transactions.""" if _merchant_use_rpc(): payload, code = live_explorer.block(_get_merchant_rpc(), identifier) return jsonify(payload), code try: node = get_node() chain = node.chain block_hash = None # Numeric identifier -> treat as height if identifier.isdigit(): target_height = int(identifier) for h in chain.active_chain(): if chain.heights[h] == target_height: block_hash = h break elif identifier in chain.blocks: block_hash = identifier if block_hash is None: return jsonify( {"status": "error", "message": "Block not found"} ), 404 summary = _block_summary(chain, block_hash) block = chain.blocks[block_hash] summary["transactions"] = [_tx_summary(tx) for tx in block.transactions] return jsonify({"status": "success", "block": summary}), 200 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/explorer/tx/<txid>", methods=["GET"])def api_explorer_tx(txid: str): """Return a transaction by txid from the active chain or mempool.""" if _merchant_use_rpc(): payload, code = live_explorer.tx(_get_merchant_rpc(), txid) return jsonify(payload), code try: node = get_node() chain = node.chain # Search the active chain (newest first) for block_hash in reversed(chain.active_chain()): block = chain.blocks[block_hash] for tx in block.transactions: if tx.txid == txid: summary = _tx_summary(tx) summary["status"] = "confirmed" summary["block_hash"] = block_hash summary["block_height"] = chain.heights[block_hash] summary["confirmations"] = ( chain.height - chain.heights[block_hash] + 1 ) return jsonify({"status": "success", "transaction": summary}), 200 # Search the mempool if txid in chain.mempool: summary = _tx_summary(chain.mempool[txid]) summary["status"] = "pending" summary["confirmations"] = 0 return jsonify({"status": "success", "transaction": summary}), 200 return jsonify({"status": "error", "message": "Transaction not found"}), 404 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500@app.route("/api/explorer/search", methods=["GET"])def api_explorer_search(): """Resolve a query to a block (by height/hash) or a transaction (by txid).""" if _merchant_use_rpc(): payload, code = live_explorer.search(_get_merchant_rpc(), request.args.get("q") or "") return jsonify(payload), code try: node = get_node() chain = node.chain query = (request.args.get("q") or "").strip() if not query: return jsonify( {"status": "error", "message": "Empty search query"} ), 400 # Height if query.isdigit(): target_height = int(query) for h in chain.active_chain(): if chain.heights[h] == target_height: return jsonify( {"status": "success", "kind": "block", "id": str(target_height)} ), 200 # Block hash if query in chain.blocks: return jsonify({"status": "success", "kind": "block", "id": query}), 200 # Transaction (chain or mempool) for block_hash in reversed(chain.active_chain()): for tx in chain.blocks[block_hash].transactions: if tx.txid == query: return jsonify( {"status": "success", "kind": "tx", "id": query} ), 200 if query in chain.mempool: return jsonify({"status": "success", "kind": "tx", "id": query}), 200 return jsonify( {"status": "error", "message": "No block or transaction matches that query"} ), 404 except Exception as e: return jsonify({"status": "error", "message": str(e)}), 500# ============================================================================= ## Error Handlers# ============================================================================= #@app.errorhandler(404)def not_found(error): """Handle 404 errors.""" return jsonify({"status": "error", "message": "Not found"}), 404@app.errorhandler(413)def payload_too_large(error): """Reject over-sized request bodies (see MAX_CONTENT_LENGTH) with clean JSON instead of buffering the payload.""" return jsonify({"status": "error", "message": "Request body too large"}), 413@app.errorhandler(500)def server_error(error): """Handle 500 errors.""" return jsonify({"status": "error", "message": "Internal server error"}), 500# ============================================================================= ## CORS Headers — allow-listed origins only (no wildcard)## Same-origin requests (the dashboard calling its own API) never need these# headers. This only scopes *cross-origin* access. Because the write APIs# consume application/json, browsers send a CORS preflight; refusing unknown# origins here blocks cross-site reads and cross-origin forged writes.# ============================================================================= #_DEFAULT_ALLOWED_ORIGINS = { "https://moonbite.org", "https://www.moonbite.org", "http://localhost:5000", "http://127.0.0.1:5000",}# Override/extend via ALLOWED_ORIGINS="https://a.example,https://b.example".ALLOWED_ORIGINS = { o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") if o.strip()} or _DEFAULT_ALLOWED_ORIGINS@app.after_requestdef add_cors_headers(response): """Reflect CORS headers only for allow-listed origins — never a wildcard.""" origin = request.headers.get("Origin") if origin in ALLOWED_ORIGINS: response.headers["Access-Control-Allow-Origin"] = origin response.headers["Vary"] = "Origin" response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type" return response# CSP is intentionally conservative: the site serves all scripts/styles from its# own origin but uses a few inline <script>/<style> blocks (theme + reveal), so# ============================================================================= ## New Wallet APIs - Phase 1-4 Features# ============================================================================= ## ---------------------------------------------------------------------------# Retired: unauthenticated cloud seed-backup store.# These stored/returned a client-supplied encrypted seed blob keyed only by a# 64-bit id, with NO per-user authentication: anyone who learned a backupId got# that blob, whose secrecy then collapsed to the wallet's 6-digit PIN. The server# also kept every uploaded seed indefinitely with no ownership or deletion. No# wallet UI ever called them. Removed in favour of self-custody: the recovery# phrase is generated and kept in the user's own browser and is never uploaded.# Answered with 410 Gone (not 404) so any lingering caller gets a clear, permanent# signal instead of silently writing seeds to disk again.# ---------------------------------------------------------------------------_BACKUP_GONE = { "status": "error", "error": "endpoint_removed", "message": ( "Cloud seed backup has been removed. MoonBite is self-custody: your " "recovery phrase is generated and kept in your own browser and is never " "uploaded. Write down your 12 words to back up." ),}@app.route("/api/wallet/backup/create", methods=["POST"])@app.route("/api/wallet/backup/restore", methods=["POST"])@app.route("/api/wallet/backup/status", methods=["GET"])def api_wallet_backup_removed(): """Retired unauthenticated seed-backup endpoints — see comment above.""" return jsonify(_BACKUP_GONE), 410@app.route("/api/tx/broadcast", methods=["POST"])@rate_limit(30, 60)def api_tx_broadcast(): """Accept a transaction signed in the browser and put it on the chain. The wallet holds the only copy of the key, so it builds and signs the transaction itself and this endpoint never sees a private key or a seed. What arrives is already authorized; the node re-verifies it against the UTXO set exactly as it would a transaction from any peer, and rejects it otherwise. A malformed or unauthorized submission can only waste its sender's time. """ data = request.get_json(silent=True) or {} try: raw = data.get("transaction") if not isinstance(raw, dict): return json_error( "VALIDATION_MISSING_FIELD", user_message="A signed transaction is required", ) try: tx = Transaction.from_dict(raw) except (KeyError, TypeError, ValueError) as e: return json_error( "VALIDATION_MISSING_FIELD", user_message="Transaction is malformed", debug_message=str(e), ) node = get_node() # submit_transaction validates signatures, ownership, maturity and # value conservation before it will touch the mempool. if not node.submit_transaction(tx): return json_error( "VALIDATION_INSUFFICIENT_BALANCE", user_message=( "The network rejected this transaction. Its inputs may " "already be spent, or the balance may have changed." ), suggested_action="Refresh your balance and try again", ) return jsonify( { "status": "success", "txid": tx.txid, "accepted": True, "mempool_size": len(node.chain.mempool), } ), 200 except Exception as e: # noqa: BLE001 return json_error( "NETWORK_CONNECTION_ERROR", debug_message=str(e), suggested_action="Please wait and try again", )@app.route("/api/wallet/send", methods=["POST"])def wallet_send(): """Refuse to fake a send. This used to mint a random hex string, call it a txid and report success, so a user could believe coins had moved when nothing had touched the chain. Spending now happens through /api/tx/broadcast with a transaction the wallet signed itself; there is no server-side path that can send on a user's behalf, because the server has no key to sign with. """ return json_error( "VALIDATION_MISSING_FIELD", user_message=( "This endpoint no longer sends coins. Sign the transaction in the " "wallet and submit it to /api/tx/broadcast." ), status_code=410, )@app.route("/api/achievements", methods=["GET"])def get_achievements(): """Get user achievements and gamification data""" try: achievements = [ {"id": "first_tx", "name": "First Send", "icon": "📤", "unlocked": False}, {"id": "hodler", "name": "HODLER", "icon": "💎", "unlocked": False}, {"id": "miner", "name": "Miner", "icon": "⛏️", "unlocked": False}, {"id": "whale", "name": "Whale", "icon": "🐋", "unlocked": False}, {"id": "social", "name": "Social", "icon": "🌍", "unlocked": False}, {"id": "collector", "name": "Collector", "icon": "🎁", "unlocked": False} ] return jsonify({ "achievements": achievements, "score": 0, "streaks": {"mining": 0}, "milestones": ["100 MBITE balance", "1000 MBITE balance"] }) except Exception as e: return jsonify({"error": str(e)}), 500@app.route("/api/achievements/unlock", methods=["POST"])def unlock_achievement(): """Unlock an achievement""" try: data = request.get_json() or {} achievement_id = data.get("id") return jsonify({ "success": True, "achievement": achievement_id, "points": 10, "message": f"Achievement unlocked: {achievement_id}" }) except Exception as e: return jsonify({"error": str(e)}), 500# NOTE: a second GET handler for /api/wallet/transactions lived here. Flask# accepted it (the endpoint names differed) but Werkzeug matches the first# registered rule, so it was unreachable code shadowed by# api_wallet_transactions_list above. Removed rather than left to rot as a# second, divergent implementation of a live endpoint.@app.route("/api/wallet/price", methods=["GET"])def get_wallet_price(): """MBITE market price — reports that there is none. This endpoint previously returned a hardcoded $0.0234 with a $327m market cap, while /api/price/mbite simultaneously returned $45.67 with a $9.13bn cap. Two live endpoints inventing different prices, three orders of magnitude apart, for a coin that is not listed anywhere. Both now defer to price_feed, which answers honestly. """ data = price_feed.get_price() return jsonify({ "listed": data["listed"], "price": data["price_usd"], # None while unlisted "currency": request.args.get("currency", "usd").lower(), "change24h": data["change_24h"], "marketCap": data["market_cap"], "volume24h": data["volume_24h"], "message": data.get("message"), "timestamp": data["timestamp"], })@app.route("/api/wallet/price-history", methods=["GET"])def get_wallet_price_history(): """Historical prices — empty, because none exist. The previous implementation synthesized a series from hash(i), producing a chart indistinguishable from real market data for an asset that has never traded. """ days = request.args.get("days", 30, type=int) data = price_feed.get_price_history(hours=max(1, min(days, 365)) * 24) return jsonify({ "listed": data["listed"], "history": data["points"], "currency": "usd", "days": days, "message": data["message"], })# ---------------------------------------------------------------------------# Hardware-wallet integration is NOT implemented. These routes previously# returned a SIMULATED address (moon1 + an all-zero pubkey-hash) and FAKE# signatures. If ever surfaced in the UI, receiving to that address would BURN# funds (no private key exists for it) and "signed" transactions would be# invalid. Return 501 Not Implemented unconditionally so nothing can act on# fabricated fund-relevant data, in any mode. (In live mode these are also caught# earlier by retire_demo_chain_routes via the /api/hardware-wallet/ prefix.)# ---------------------------------------------------------------------------_HARDWARE_WALLET_NOT_IMPLEMENTED = { "status": "error", "error": "not_implemented", "message": ("Hardware-wallet support is not implemented. Use the self-custody " "software wallet; do not send funds to any address this endpoint " "would return."),}@app.route("/api/hardware-wallet/detect", methods=["GET"])@app.route("/api/hardware-wallet/address", methods=["POST"])@app.route("/api/hardware-wallet/sign", methods=["POST"])def api_hardware_wallet_not_implemented(): """Retired hardware-wallet stubs that returned fake addresses/signatures.""" return jsonify(_HARDWARE_WALLET_NOT_IMPLEMENTED), 501@app.route("/api/wallet/settings", methods=["GET"])def get_wallet_settings(): """Get wallet settings""" return jsonify({ "theme": "dark", "currency": "usd", "network": "mainnet", "rpcEndpoint": "http://localhost:9444", "sessionTimeout": 300, "biometricEnabled": False, "notificationsEnabled": True, "priceAlerts": [], "language": "en" })@app.route("/api/wallet/settings", methods=["PATCH"])def update_wallet_settings(): """Update wallet settings""" try: data = request.get_json() or {} return jsonify({ "success": True, "settings": data, "message": "Settings updated successfully" }) except Exception as e: return jsonify({"error": str(e)}), 500@app.route("/api/mining/stats", methods=["GET"])def get_mining_stats(): """Get mining statistics""" try: stats = { "blocksMined": 0, "totalRewards": 0.0, "hashRate": 0, "difficulty": 1, "nextBlockEstimate": 120, "miningStreak": 0, "today": 0.0, "thisWeek": 0.0, "thisMonth": 0.0 } return jsonify(stats) except Exception as e: return jsonify({"error": str(e)}), 500@app.route("/api/mining/alerts", methods=["GET"])def get_mining_alerts(): """Mining alerts. No fabricated data: the server has no sensor access, so the honest answer is an empty list.""" return jsonify({"alerts": []})# script/style allow 'unsafe-inline'. Everything else is locked to 'self', no# framing, no plugins. Tightening to nonces is future work (see AUDIT_REPORT.md).# Strict policy for converted pages: inline scripts must carry the nonce, and# there is no 'unsafe-inline' fallback, so injected script is blocked outright._CSP_STRICT = ( "default-src 'self'; " "script-src 'self' 'nonce-{nonce}'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "img-src 'self' data:; " "font-src 'self' https://fonts.gstatic.com; " "connect-src 'self'; " "base-uri 'self'; " "form-action 'self'; " "frame-ancestors 'none'")_CSP = ( "default-src 'self'; " "script-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " "img-src 'self' data:; " "font-src 'self' https://fonts.gstatic.com; " "connect-src 'self'; " "base-uri 'self'; " "form-action 'self'; " "frame-ancestors 'none'")@app.after_requestdef add_security_headers(response): """Baseline hardening headers on every response (see AUDIT_REPORT.md #4). HSTS defends against SSL-strip downgrade; nosniff blocks MIME confusion; frame-ancestors/X-Frame-Options stop clickjacking; Referrer-Policy limits leakage. Safe to also set at the nginx edge — duplicates are harmless.""" response.headers.setdefault( "Strict-Transport-Security", "max-age=63072000; includeSubDomains" ) response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "DENY") response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") # Lock down powerful browser features the site never uses (audit L6). # publickey-credentials-get is deliberately NOT listed: the wallet's PIN # flow uses WebAuthn and must keep it. response.headers.setdefault( "Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()", ) # Pages rendered with a nonce get the strict policy: no 'unsafe-inline', # so an injected <script> cannot execute. Templates still carrying inline # handlers keep the legacy policy until they are converted too. # Strict by default: every served template now carries nonced script tags # and no inline handlers, so 'unsafe-inline' is no longer needed anywhere. # A view can set request.csp_legacy = True if it ever needs the old policy. nonce = getattr(request, "csp_nonce", None) if nonce and not getattr(request, "csp_legacy", False): response.headers["Content-Security-Policy"] = _CSP_STRICT.format(nonce=nonce) else: response.headers.setdefault("Content-Security-Policy", _CSP) return response# ============================================================================= ## SEO: robots.txt + sitemap.xml# ============================================================================= ## Launch set: the pages we're actively promoting and stand behind today.# Everything else (design experiments, retired demo-chain pages, drafts) is# real code left in place for later, but has no business being indexed or# found by a crawler until it's finished and reviewed._LAUNCH_PATHS = [ "/", "/mine", "/get-wallet", "/wallet", "/explorer", "/whitepaper", "/developers", "/bounty", "/about",]@app.route("/robots.txt")def robots_txt(): lines = ["User-agent: *"] + [f"Allow: {p}" for p in _LAUNCH_PATHS] + [ "Disallow: /", "", "Sitemap: https://moonbite.org/sitemap.xml", ] return Response("\n".join(lines), mimetype="text/plain")@app.route("/sitemap.xml")def sitemap_xml(): urls = "".join( f"<url><loc>https://moonbite.org{p}</loc></url>" for p in _LAUNCH_PATHS ) xml = f'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{urls}</urlset>' return Response(xml, mimetype="application/xml")# ============================================================================= ## App Initialization# ============================================================================= #if __name__ == "__main__": # Initialize the node on startup get_node() # Initialize wallet history database wallet_history.create_schema() # Production deploys run this under gunicorn (web_app:app) and never reach # this block. When launched directly, honor the environment so the same # file works locally (defaults) and on a server/PaaS (PORT/HOST/FLASK_DEBUG). host = os.environ.get("HOST", "localhost") port = int(os.environ.get("PORT", "5000")) # Default OFF: the Werkzeug debugger is a remote code execution # console, so it must never be the default on a server. Opt in # explicitly with FLASK_DEBUG=1 for local work. debug = os.environ.get("FLASK_DEBUG", "0") == "1" print(f"MoonBite Dashboard starting on http://{host}:{port}") app.run(debug=debug, host=host, port=port)