diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..ad9e427 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + branches: [ "main", "feature/*" ] + pull_request: + branches: [ "main", "feature/*" ] + +jobs: + backend-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-mock + - name: Run tests + run: | + pytest tests/ + + frontend-tests: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + - name: Install dependencies + run: npm ci + - name: Run tests + run: npm run test diff --git a/.gitignore b/.gitignore index 7944dd2..7d4e30b 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,18 @@ db.sqlite3-journal # Flask stuff: instance/ .webassets-cache +backend/rpc_config.ini +backend/fee_analysis.db + +# Logs +*.log +nohup.out + +# Next.js +frontend/.next/ +frontend/node_modules/ +frontend/frontend.log +frontend/frontend.pd # Scrapy stuff: .scrapy @@ -217,3 +229,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +fee_analysis.db + +.DS_Store diff --git a/Readme.md b/Readme.md index 2ffa0c0..a706ca1 100644 --- a/Readme.md +++ b/Readme.md @@ -1,4 +1,72 @@ -Bitcoin Core Feerate API +### Bitcoin Core Fee Rate Estimator -ASAP: https://bitcoincorefeerate.com/fees/2/economical/2 +- A full-stack application for monitoring and validating Bitcoin Core transaction fee estimates against actual block data. +- Built on top of Bitcoin Core PR #34075 +### Overview + +This project tracks `estimatesmartfee` from a Bitcoin Core node and compares those estimates with the feerate percentiles of subsequent blocks. It provides a visual interface to verify the accuracy of the node's fee predictions. + +#### Key Features +- **Fee Estimate Tracking**: A background service polls Bitcoin Core every 7 seconds for smart fee estimates. +- **Historical Accuracy**: Visualizes the accuracy of estimates (within range, overpaid, or underpaid) compared to real block data. +- **Mempool Diagram**: Real-time visualization of the mempool fee/weight accumulation curve. +- **Block Statistics**: Direct insights into feerate percentiles for recent blocks. + +#### Architecture + +- **Backend (Python/Flask)**: Communicates with Bitcoin Core via RPC. Collects estimates into SQLite and serves data via a REST API. +- **Frontend (Next.js/TypeScript)**: Modern UI using Recharts and D3. Communicates with the backend via a secure API proxy route. + +#### Project Structure + +```text +. +├── backend/ # Flask API, data collector, and SQLite database +│ ├── src/ # Core logic and RPC services +│ └── tests/ # Pytest suite for backend validation +├── frontend/ # Next.js web application +│ ├── src/app/ # App router and pages +│ └── src/components/ # D3 and Recharts visualization components +└── .github/workflows/ # Automated testing workflow +``` + +#### How to Use + +#### Prerequisites +- **Bitcoin Core Node**: Access to a node with RPC enabled (`getblockstats` support required). +- **Python**: 3.12+ +- **Node.js**: 22+ + +#### 1. Configuration +- **Backend**: Copy `backend/rpc_config.ini.example` to `backend/rpc_config.ini` and provide RPC credentials. + +#### 2. Manual Startup +**Backend:** +```bash +cd backend +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python src/app.py +``` + +**Frontend:** +```bash +cd frontend +npm install +npm run dev +``` + +#### 3. Automated Startup +Use the provided `restart.sh` script to launch both services in the background: +```bash +chmod +x restart.sh +./restart.sh +``` + +### Credits +- **Abubakar Sadiq Ismail**: Bitcoin Core contributor and architecture. +- **b-l-u-e**: Backend logic and service implementation. +- **mercie-ux**: Frontend design and visual components. +- **Gemini & Claude**: AI-assisted development and test automation. diff --git a/app.py b/app.py deleted file mode 100644 index f8a6892..0000000 --- a/app.py +++ /dev/null @@ -1,15 +0,0 @@ -from flask import Flask -from werkzeug.middleware.proxy_fix import ProxyFix - -from bitcoin_core_rpc import estimatesmartfee - -app = Flask(__name__) -app.wsgi_app = ProxyFix(app.wsgi_app) - -@app.route("/fees///", methods=['GET']) -def fees(target, mode, level): - return estimatesmartfee(conf_target=target, mode=mode, verbosity_level=level) - -@app.errorhandler(404) -def page_not_found(error): - return "Hello Crawler :)" diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..3e91190 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,8 @@ +# test files +test_mock.py +test_secure_connection.py +test_rpc_ports.py +test_getbestblockhash.py + +rpc_config.ini + diff --git a/backend/doc.md b/backend/doc.md new file mode 100644 index 0000000..38ebb2c --- /dev/null +++ b/backend/doc.md @@ -0,0 +1,50 @@ +# Backend - Bitcoin Core Fees API + +This Flask-based REST API interacts with Bitcoin Core RPC and a local SQLite database to provide fee analytics and block statistics. + +## Running the Application + +### 1. Prerequisites +Ensure you have a virtual environment set up and dependencies installed: +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Ensure `rpc_config.ini` is configured with your Bitcoin Core RPC credentials. + +### 2. Start the App (Background) +To start the application in the background: + +```bash +nohup env PYTHONPATH=src .venv/bin/gunicorn --workers 4 --bind 127.0.0.1:5001 app:app > debug.log 2>&1 & +``` + +### 3. Monitoring Logs +To see the logs in real-time: +```bash +tail -f debug.log +``` + +### 4. Stopping the App +To stop the process: +```bash +pkill -f "gunicorn" +``` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/blockcount` | GET | Current block height from node. | +| `/fees///` | GET | `estimatesmartfee` results converted to sat/vB. | +| `/mempool-diagram` | GET | Analyzed feerate diagram for mempool accumulation. | +| `/performance-data//` | GET | Block feerate percentiles vs. recorded estimates. | +| `/fees-sum//` | GET | Aggregated accuracy metrics (within, over, under). | + +### Parameters: +- `target`: Confirmation target (e.g., 2, 7, 144). +- `mode`: Fee estimation mode (`economical`, `conservative`, `unset`). +- `level`: Verbosity level for fee estimation. +- `start_block`: Block height to start range analysis from. diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..64fde51 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.1.3 +Flask-CORS==6.0.2 +Flask-Limiter==4.1.1 +requests==2.32.5 +configparser==6.0.0 +gunicorn==25.1.0 +pytest==9.0.2 +pytest-cov==7.0.0 diff --git a/rpc_config.ini.example b/backend/rpc_config.ini.example similarity index 100% rename from rpc_config.ini.example rename to backend/rpc_config.ini.example diff --git a/backend/src/app.py b/backend/src/app.py new file mode 100644 index 0000000..416a233 --- /dev/null +++ b/backend/src/app.py @@ -0,0 +1,128 @@ +import logging +import os +from flask import Flask, jsonify, request +from flask_cors import CORS +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from werkzeug.middleware.proxy_fix import ProxyFix +import services.rpc_service as rpc_service +import services.collector_service as collector_service +import services.database_service as db_service + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +def create_app(): + app = Flask(__name__) + # NOTE: Configure x_for=1 to match your actual proxy depth. + # Without this, X-Forwarded-For spoofing can defeat IP-based limiting. + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + CORS(app) + + # --------------------------------------------------------------------------- + # Rate limiting + # --------------------------------------------------------------------------- + # Uses the real client IP (respects ProxyFix above). + # Default: 200 requests/day, 60/hour applied to every endpoint unless + # overridden with a per-route @limiter.limit() decorator below. + # --------------------------------------------------------------------------- + limiter = Limiter( + key_func=get_remote_address, + app=app, + default_limits=["10000 per day", "1000 per hour"], + # Store state in memory by default. For multi-worker/multi-process + # deployments swap this for a Redis URI: + # storage_uri="redis://localhost:6379" + storage_uri="memory://", + # Return 429 JSON instead of HTML when limit is hit + headers_enabled=True, # adds X-RateLimit-* headers to responses + ) + + db_service.init_db() + collector_service.start_background_collector() + + # --------------------------------------------------------------------------- + # Routes + # --------------------------------------------------------------------------- + + @app.route("/fees///", methods=['GET']) + @limiter.limit("50 per minute") # estimatesmartfee is a node RPC call — keep it tight + def fees(target, mode, level): + VALID_MODES = {"economical", "conservative", "unset"} + if mode not in VALID_MODES: + return jsonify({"error": f"Invalid mode '{mode}'. Must be one of: {', '.join(VALID_MODES)}"}), 400 + try: + result = rpc_service.estimate_smart_fee(conf_target=target, mode=mode, verbosity_level=level) + return jsonify(result) + except Exception as e: + logger.error(f"/fees RPC failed: {e}", exc_info=True) + return jsonify({"error": "Internal server error"}), 500 + + @app.route("/mempool-diagram", methods=['GET']) + @limiter.limit("50 per minute") # expensive computation — strict cap + def mempool_diagram(): + try: + result = rpc_service.get_mempool_feerate_diagram_analysis() + return jsonify(result) + except Exception as e: + logger.error(f"Mempool diagram RPC failed: {e}", exc_info=True) + return jsonify({"error": "Internal server error"}), 500 + + @app.route("/performance-data//", methods=['GET']) + @limiter.limit("50 per minute") # hits DB + RPC + def get_performance_data(start_block): + target = request.args.get('target', default=2, type=int) + try: + data = rpc_service.get_performance_data(start_height=start_block, count=100, target=target) + return jsonify(data) + except Exception as e: + logger.error(f"/performance-data RPC failed: {e}", exc_info=True) + return jsonify({"error": "Internal server error"}), 500 + + @app.route("/fees-sum//", methods=['GET']) + @limiter.limit("50 per minute") + def get_local_fees_sum(start_block): + target = request.args.get('target', default=2, type=int) + try: + data = rpc_service.calculate_local_summary(target=target) + return jsonify(data) + except Exception as e: + logger.error(f"/fees-sum failed: {e}", exc_info=True) + return jsonify({"error": "Internal server error"}), 500 + + @app.route("/blockcount", methods=['GET']) + @limiter.limit("100 per minute") # cheap call, slightly more relaxed + def block_count(): + try: + result = rpc_service.get_block_count() + return jsonify({"blockcount": result}) + except Exception as e: + logger.error(f"/blockcount RPC failed: {e}", exc_info=True) + return jsonify({"error": "Internal server error"}), 500 + + # --------------------------------------------------------------------------- + # Error handlers + # --------------------------------------------------------------------------- + + @app.errorhandler(404) + def page_not_found(error): + return jsonify({"error": "Endpoint not found"}), 404 + + @app.errorhandler(429) + def rate_limit_exceeded(error): + # error.description is the limit string e.g. "30 per 1 minute" + logger.warning(f"Rate limit exceeded from {get_remote_address()}: {error.description}") + return jsonify({ + "error": "Too many requests", + "message": f"Rate limit exceeded: {error.description}. Please slow down." + }), 429 + + return app + +app = create_app() +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5001)) + app.run(debug=False, host='0.0.0.0', port=port) diff --git a/backend/src/services/collector_service.py b/backend/src/services/collector_service.py new file mode 100644 index 0000000..4f4cd36 --- /dev/null +++ b/backend/src/services/collector_service.py @@ -0,0 +1,48 @@ +import time +import threading +import logging +import services.rpc_service as rpc_service +import services.database_service as db_service + +logger = logging.getLogger("collector") +_collector_started = False + +def run_collector(): + logger.info("Starting high-resolution fee estimate collector (7s interval)...") + # 1 and 2 are the same, so we only poll 2 + targets = [2, 7, 144] + + while True: + start_time = time.time() + try: + current_height = rpc_service.get_block_count() + + for t in targets: + try: + res = rpc_service.estimate_smart_fee(t, "unset", 1) + if "feerate_sat_per_vb" in res: + rate = res["feerate_sat_per_vb"] + db_service.save_estimate(current_height, t, rate) + # Log as collected for the target + logger.info(f"[Collector] SAVED: target={t} height={current_height} rate={rate:.2f} sat/vB") + except Exception as e: + logger.error(f"[Collector] Failed to collect for target {t}: {e}") + + except Exception as e: + logger.error(f"[Collector] Loop error: {e}") + + elapsed = time.time() - start_time + # Interval between request should be 7 seconds. + # (https://bitcoin.stackexchange.com/questions/125776/how-long-does-it-take-for-a-transaction-to-propagate-through-the-network) + sleep_time = max(0, 7 - elapsed) + time.sleep(sleep_time) + +def start_background_collector(): + global _collector_started + if _collector_started: + logger.warning("Collector already running, skipping.") + return + _collector_started = True + thread = threading.Thread(target=run_collector, daemon=True) + thread.start() + return thread diff --git a/backend/src/services/database_service.py b/backend/src/services/database_service.py new file mode 100644 index 0000000..8fd94d6 --- /dev/null +++ b/backend/src/services/database_service.py @@ -0,0 +1,104 @@ +import sqlite3 +import os +import logging + +logger = logging.getLogger(__name__) + +DB_PATH = os.environ.get( + "DB_PATH", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "fee_analysis.db") +) + +MAX_RANGE_BLOCKS = 10_000 # safety cap on get_estimates_in_range + +def init_db(): + try: + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS fee_estimates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + poll_height INTEGER, + target INTEGER, + estimate_feerate REAL, + expected_height INTEGER, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP -- UTC + ) + ''') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_poll_height ON fee_estimates(poll_height)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_target ON fee_estimates(target)') + # Composite index for the most common query pattern (poll_height + target together) + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_poll_height_target + ON fee_estimates(poll_height, target) + ''') + conn.commit() + logger.info(f"Database initialised at {DB_PATH}") + except sqlite3.Error as e: + logger.error(f"Failed to initialise database: {e}", exc_info=True) + raise + + +def save_estimate(poll_height, target, feerate): + expected_height = poll_height + target + try: + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO fee_estimates (poll_height, target, estimate_feerate, expected_height) + VALUES (?, ?, ?, ?) + ''', (poll_height, target, feerate, expected_height)) + conn.commit() + logger.debug(f"Saved estimate: poll_height={poll_height}, target={target}, feerate={feerate}") + except sqlite3.Error as e: + logger.error(f"Failed to save estimate (poll_height={poll_height}, target={target}): {e}", exc_info=True) + raise + + +def get_estimates_in_range(start_height, end_height, target=2): + # Enforce a max block range to prevent runaway queries + if end_height - start_height > MAX_RANGE_BLOCKS: + logger.warning( + f"Requested range [{start_height}, {end_height}] exceeds MAX_RANGE_BLOCKS={MAX_RANGE_BLOCKS}. Clamping." + ) + end_height = start_height + MAX_RANGE_BLOCKS + + try: + with sqlite3.connect(DB_PATH) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(''' + SELECT DISTINCT poll_height, target, estimate_feerate, expected_height + FROM fee_estimates + WHERE poll_height >= ? AND poll_height <= ? AND target = ? + ORDER BY poll_height ASC, timestamp ASC + ''', (start_height, end_height, target)) + rows = cursor.fetchall() + + if not rows: + logger.debug(f"No estimates found in range [{start_height}, {end_height}] for target={target}") + + return rows + except sqlite3.Error as e: + logger.error(f"Failed to query estimates in range: {e}", exc_info=True) + raise + + +def get_db_height_range(target=2): + try: + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute( + 'SELECT MIN(poll_height), MAX(poll_height) FROM fee_estimates WHERE target = ?', + (target,) + ) + row = cursor.fetchone() + + if row and row[0] is None: + logger.debug(f"No data in DB for target={target}") + + # Return raw tuple — preserves existing caller contract + return row + except sqlite3.Error as e: + logger.error(f"Failed to get DB height range: {e}", exc_info=True) + raise diff --git a/backend/src/services/rpc_service.py b/backend/src/services/rpc_service.py new file mode 100644 index 0000000..0d559b8 --- /dev/null +++ b/backend/src/services/rpc_service.py @@ -0,0 +1,303 @@ +import configparser +import itertools +import json +import os +import logging +from typing import Any, Dict, List, Optional +from functools import lru_cache + +import requests + +logger = logging.getLogger("rpc_service") + +# --------------------------------------------------------------------------- +# Config — walk up from this file's directory until rpc_config.ini is found, +# or use the RPC_CONFIG_PATH env var to set it explicitly. +# --------------------------------------------------------------------------- +def _find_config(filename: str = "rpc_config.ini") -> Optional[str]: + if env_path := os.environ.get("RPC_CONFIG_PATH"): + return env_path + directory = os.path.dirname(os.path.abspath(__file__)) + # Walk up a maximum of 5 levels to find the config file + for _ in range(5): + candidate = os.path.join(directory, filename) + if os.path.isfile(candidate): + return candidate + directory = os.path.dirname(directory) + return None + +_CONFIG_PATH = _find_config() +if _CONFIG_PATH: + logger.debug(f"Loading RPC config from: {_CONFIG_PATH}") +else: + logger.warning("rpc_config.ini not found — relying solely on environment variables.") + +_config = configparser.ConfigParser() +if _CONFIG_PATH: + _config.read(_CONFIG_PATH) + +def _get_config_val(section: str, option: str, default: Optional[str] = None) -> Optional[str]: + try: + return _config.get(section, option) + except (configparser.NoSectionError, configparser.NoOptionError): + return default + + +# --------------------------------------------------------------------------- +# Credentials — private, validated eagerly at import time +# --------------------------------------------------------------------------- +_URL = os.environ.get("RPC_URL") or _get_config_val("RPC_INFO", "URL") +_RPCUSER = os.environ.get("RPC_USER") or _get_config_val("RPC_INFO", "RPC_USER") +_RPCPASSWORD = os.environ.get("RPC_PASSWORD") or _get_config_val("RPC_INFO", "RPC_PASSWORD") + +if not _URL: + raise EnvironmentError( + "Bitcoin RPC URL is not configured. " + "Set the RPC_URL environment variable or add URL under [RPC_INFO] in rpc_config.ini." + ) + +DEFAULT_TIMEOUT_SECONDS = 30 + +# Reuse TCP connection across all RPC calls +_session = requests.Session() + +# Monotonically increasing JSON-RPC request IDs +_rpc_id_counter = itertools.count(1) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _clamp_target(target: int) -> int: + """Bitcoin Core treats targets ≤ 1 the same as 2.""" + return max(2, target) + + +def _rpc_call(method: str, params: List[Any]) -> Any: + payload = json.dumps({ + "method": method, + "params": params, + "id": next(_rpc_id_counter), + }) + auth = (_RPCUSER, _RPCPASSWORD) if (_RPCUSER or _RPCPASSWORD) else None + try: + response = _session.post(_URL, data=payload, auth=auth, timeout=DEFAULT_TIMEOUT_SECONDS) + data = response.json() + if data.get("error"): + raise RuntimeError(f"RPC Error ({method}): {data['error']}") + return data.get("result") + except RuntimeError: + raise + except Exception as e: + # Wrap transport-level errors without re-logging — callers decide log level + raise RuntimeError(f"RPC call '{method}' failed: {type(e).__name__}") from e + + +# --------------------------------------------------------------------------- +# Block stats — cached, returns a copy to prevent cache corruption +# --------------------------------------------------------------------------- + +@lru_cache(maxsize=2000) +def _get_single_block_stats_cached(height: int) -> tuple: + """ + Returns a frozen (JSON-serialised) snapshot so the lru_cache holds + immutable data. Use get_single_block_stats() for normal access. + """ + result = _rpc_call("getblockstats", [height, ["height", "feerate_percentiles", "minfeerate", "maxfeerate", "total_weight"]]) + return json.dumps(result) # freeze as string + + +def get_single_block_stats(height: int) -> Dict[str, Any]: + """Returns a fresh dict each call — safe to mutate without corrupting the cache.""" + return json.loads(_get_single_block_stats_cached(height)) + + +# --------------------------------------------------------------------------- +# Public RPC wrappers +# --------------------------------------------------------------------------- + +def get_block_count() -> int: + return _rpc_call("getblockcount", []) + + +def get_mempool_health_statistics() -> List[Dict[str, Any]]: + """ + Fetches stats for the last 5 blocks to compare their weights with + the current mempool's readiness. + """ + current_height = get_block_count() + stats = [] + + # Using getmempoolfeeratediagram for accurate total weight + mempool_diagram = _rpc_call("getmempoolfeeratediagram", []) + total_mempool_weight = mempool_diagram[-1]["weight"] if mempool_diagram else 0 + + for h in range(current_height - 4, current_height + 1): + try: + b = get_single_block_stats(h) + weight = b.get("total_weight", 0) + + stats.append({ + "block_height": h, + "block_weight": weight, + "mempool_txs_weight": total_mempool_weight, + "ratio": min(1.0, total_mempool_weight / 4_000_000) + }) + except Exception: + continue + return stats + + +def estimate_smart_fee(conf_target: int, mode: str = "unset", verbosity_level: int = 2) -> Dict[str, Any]: + effective_target = _clamp_target(conf_target) + result = _rpc_call("estimatesmartfee", [effective_target, mode, verbosity_level]) + if result and "feerate" in result: + # feerate is BTC/kVB → sat/vB: × 1e8 (BTC→sat) ÷ 1e3 (kVB→vB) = × 1e5 + result["feerate_sat_per_vb"] = result["feerate"] * 100_000 + + # Include health stats for the frontend + try: + result["mempool_health_statistics"] = get_mempool_health_statistics() + except Exception as e: + logger.error(f"Failed to include health stats: {e}") + + return result + + +def get_mempool_feerate_diagram_analysis() -> Dict[str, Any]: + raw_points = _rpc_call("getmempoolfeeratediagram", []) + if not raw_points: + return {"raw": [], "windows": {}} + + # Weight of a standard full block in weight units + BLOCK_WEIGHT = 4_000_000 + max_weight = raw_points[-1]["weight"] + + # Pre-calculate per-segment feerates + # Conversion: (fee_BTC / weight_WU) × 4e8 = sat/vB + # (1 vB = 4 WU; 1 BTC = 1e8 sat → factor = 1e8 / 4 = 25_000_000... but + # raw_points["fee"] is in BTC and weight in WU, so sat/vB = fee/weight × 4e8 / 4 + # = fee/weight × 1e8 — however Bitcoin Core actually returns fee in BTC and weight + # in WU where 1 vB = 4 WU, so sat/vB = (fee_BTC × 1e8) / (weight_WU / 4) + # = fee_BTC × 4e8 / weight_WU. Factor 400_000_000 is correct.) + segments = [] + for i, p in enumerate(raw_points): + if i == 0: + fr = (p["fee"] / p["weight"]) * 400_000_000 if p["weight"] > 0 else 0 + else: + prev = raw_points[i - 1] + dw = p["weight"] - prev["weight"] + df = p["fee"] - prev["fee"] + fr = (df / dw) * 400_000_000 if dw > 0 else 0 + segments.append({"w": p["weight"], "fr": fr}) + + def _feerate_at_weight(w_target: float) -> float: + for seg in segments: + if seg["w"] >= w_target: + return seg["fr"] + return segments[-1]["fr"] if segments else 0 + + def _window_percentiles(weight_limit: int) -> Dict[str, float]: + actual_limit = min(weight_limit, max_weight) + return { + str(int(p * 100)): _feerate_at_weight(p * actual_limit) + for p in (0.05, 0.25, 0.50, 0.75, 0.95) + } + + windows = { + "1": _window_percentiles(BLOCK_WEIGHT), + "2": _window_percentiles(BLOCK_WEIGHT * 2), + "3": _window_percentiles(BLOCK_WEIGHT * 3), + "all": _window_percentiles(max_weight), + } + + return { + "raw": raw_points, + "windows": windows, + "total_weight": max_weight, + "total_fee": raw_points[-1]["fee"], + } + + +# --------------------------------------------------------------------------- +# Performance / summary logic +# --------------------------------------------------------------------------- + +def get_performance_data(start_height: int, count: int = 100, target: int = 2) -> Dict[str, Any]: + import services.database_service as db_service # late import — breaks circular dep + + effective_target = _clamp_target(target) + db_rows = db_service.get_estimates_in_range(start_height, start_height + count, effective_target) + + # Deduplicate to latest estimate per height (dict preserves insertion order in Py3.7+) + latest_estimates_map = {row["poll_height"]: row["estimate_feerate"] for row in db_rows} + estimates = [{"height": h, "rate": latest_estimates_map[h]} for h in sorted(latest_estimates_map)] + + blocks = [] + for h in range(start_height, start_height + count): + try: + b = get_single_block_stats(h) + p = b.get("feerate_percentiles", [0, 0, 0, 0, 0]) + blocks.append({"height": h, "low": p[0], "high": p[4]}) + except Exception: + logger.debug(f"Skipping block stats for height {h} — RPC unavailable") + continue + + return {"blocks": blocks, "estimates": estimates} + + +def calculate_local_summary(target: int = 2) -> Dict[str, Any]: + import services.database_service as db_service # late import — breaks circular dep + + effective_target = _clamp_target(target) + current_h = get_block_count() + + db_rows = db_service.get_estimates_in_range(current_h - 1000, current_h, effective_target) + + total = 0 + over = 0 + under = 0 + within = 0 + + for row in db_rows: + poll_h = row["poll_height"] + target_val = row["target"] + est = row["estimate_feerate"] + window_end = poll_h + target_val + + if window_end > current_h: + continue + + total += 1 + is_under = True + is_over = False + + for h in range(poll_h + 1, window_end + 1): + try: + b = get_single_block_stats(h) + p = b.get("feerate_percentiles", [0, 0, 0, 0, 0]) + if est >= p[0]: + is_under = False + if est > p[4]: + is_over = True + except Exception: + logger.debug(f"Skipping block {h} in summary calculation — RPC unavailable") + continue + + if is_under: + under += 1 + elif is_over: + over += 1 + else: + within += 1 + + return { + "total": total, + "within_val": within, + "within_perc": within / total if total > 0 else 0, + "overpayment_val": over, + "overpayment_perc": over / total if total > 0 else 0, + "underpayment_val": under, + "underpayment_perc": under / total if total > 0 else 0, + } diff --git a/backend/test.md b/backend/test.md new file mode 100644 index 0000000..d4617d3 --- /dev/null +++ b/backend/test.md @@ -0,0 +1,64 @@ +# Testing Guide + +## Prerequisites + +Ensure all dependencies including test tools are installed: + +```bash +pip install pytest pytest-cov +``` + +--- + +## Test Structure + +```text +tests/ +├── conftest.py # Pytest path and app setup +├── helpers.py # Shared app factory for tests +├── test_app.py # HTTP layer (routes, validation) +├── test_rpc_service.py # RPC conversion and calculation logic +└── test_database_service.py # SQLite writes and query filtering +``` + +--- + +## Running Tests + +All commands should be run from the `backend/` directory. + +**Run the full suite:** +```bash +python -m pytest tests/ -v +``` + +**Run a single file:** +```bash +python -m pytest tests/test_app.py -v +python -m pytest tests/test_rpc_service.py -v +python -m pytest tests/test_database_service.py -v +``` + +**Run a single test by name:** +```bash +python -m pytest tests/test_rpc_service.py::test_feerate_conversion_is_correct -v +``` + +**Stop on first failure:** +```bash +python -m pytest tests/ -v -x +``` + +--- + +## Coverage Report + +**Print coverage summary in terminal:** +```bash +python -m pytest tests/ -v --cov=src --cov-report=term-missing +``` + +**Generate an HTML report:** +```bash +python -m pytest tests/ --cov=src --cov-report=html +``` diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..12124e4 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +import os + +# Make `services` and `app` importable when running pytest from the tests/ directory +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) diff --git a/backend/tests/helpers.py b/backend/tests/helpers.py new file mode 100644 index 0000000..a7028d8 --- /dev/null +++ b/backend/tests/helpers.py @@ -0,0 +1,16 @@ +import os +import sys +from unittest.mock import patch + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) + + +def make_app(): + """Create a Flask test app with all side effects patched out.""" + with patch('services.database_service.init_db', return_value=None), \ + patch('services.collector_service.start_background_collector', return_value=None): + from app import create_app + app = create_app() + app.config['TESTING'] = True + app.config['RATELIMIT_ENABLED'] = False + return app diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py new file mode 100644 index 0000000..e7a3781 --- /dev/null +++ b/backend/tests/test_app.py @@ -0,0 +1,125 @@ +import unittest +from unittest.mock import patch +from helpers import make_app + + +class TestApp(unittest.TestCase): + + def setUp(self): + self.client = make_app().test_client() + + # --- /blockcount -------------------------------------------------------- + + @patch('services.rpc_service.get_block_count', return_value=800000) + def test_block_count_success(self, _): + r = self.client.get('/blockcount') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json['blockcount'], 800000) + + @patch('services.rpc_service.get_block_count', side_effect=RuntimeError("node down")) + def test_block_count_error_does_not_leak(self, _): + r = self.client.get('/blockcount') + self.assertEqual(r.status_code, 500) + self.assertNotIn('node down', r.json.get('error', '')) + + # --- /fees/// -------------------------------------- + + @patch('services.rpc_service.estimate_smart_fee', return_value={"feerate": 0.0001, "blocks": 2}) + def test_fees_success(self, _): + r = self.client.get('/fees/2/economical/2') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json['feerate'], 0.0001) + + def test_fees_all_valid_modes_accepted(self): + for mode in ('economical', 'conservative', 'unset'): + with patch('services.rpc_service.estimate_smart_fee', return_value={"feerate": 0.0001}): + r = self.client.get(f'/fees/2/{mode}/2') + self.assertEqual(r.status_code, 200, msg=f"Mode '{mode}' should be accepted") + + def test_fees_invalid_mode_returns_400(self): + r = self.client.get('/fees/2/BADMODE/2') + self.assertEqual(r.status_code, 400) + self.assertIn('error', r.json) + + @patch('services.rpc_service.estimate_smart_fee', side_effect=RuntimeError("rpc error")) + def test_fees_rpc_error_does_not_leak(self, _): + r = self.client.get('/fees/2/economical/2') + self.assertEqual(r.status_code, 500) + self.assertNotIn('rpc error', r.json.get('error', '')) + + # --- /mempool-diagram --------------------------------------------------- + + @patch('services.rpc_service.get_mempool_feerate_diagram_analysis', return_value={ + "raw": [], "windows": {}, "total_weight": 0, "total_fee": 0 + }) + def test_mempool_diagram_success(self, _): + r = self.client.get('/mempool-diagram') + self.assertEqual(r.status_code, 200) + self.assertIn('raw', r.json) + self.assertIn('windows', r.json) + + @patch('services.rpc_service.get_mempool_feerate_diagram_analysis', side_effect=RuntimeError("fail")) + def test_mempool_diagram_error_does_not_leak(self, _): + r = self.client.get('/mempool-diagram') + self.assertEqual(r.status_code, 500) + self.assertNotIn('fail', r.json.get('error', '')) + + # --- /performance-data/ ------------------------------------ + + @patch('services.rpc_service.get_performance_data', return_value={ + "blocks": [{"height": 800000, "low": 5, "high": 20}], + "estimates": [{"height": 800000, "rate": 10.0}] + }) + def test_performance_data_success(self, _): + r = self.client.get('/performance-data/800000/') + self.assertEqual(r.status_code, 200) + self.assertIn('blocks', r.json) + self.assertIn('estimates', r.json) + + def test_performance_data_passes_target_query_param(self): + with patch('services.rpc_service.get_performance_data', return_value={"blocks": [], "estimates": []}) as mock: + self.client.get('/performance-data/800000/?target=7') + mock.assert_called_once_with(start_height=800000, count=100, target=7) + + @patch('services.rpc_service.get_performance_data', side_effect=RuntimeError("db fail")) + def test_performance_data_error_does_not_leak(self, _): + r = self.client.get('/performance-data/800000/') + self.assertEqual(r.status_code, 500) + self.assertNotIn('db fail', r.json.get('error', '')) + + # --- /fees-sum/ -------------------------------------------- + + @patch('services.rpc_service.calculate_local_summary', return_value={ + "total": 100, "within_val": 85, "within_perc": 0.85, + "overpayment_val": 10, "overpayment_perc": 0.1, + "underpayment_val": 5, "underpayment_perc": 0.05, + }) + def test_fees_sum_success(self, _): + r = self.client.get('/fees-sum/800000/') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.json['within_perc'], 0.85) + for key in ('total', 'within_val', 'within_perc', 'overpayment_val', + 'overpayment_perc', 'underpayment_val', 'underpayment_perc'): + self.assertIn(key, r.json, msg=f"Missing key: {key}") + + def test_fees_sum_passes_target_query_param(self): + with patch('services.rpc_service.calculate_local_summary', return_value={"total": 0}) as mock: + self.client.get('/fees-sum/800000/?target=144') + mock.assert_called_once_with(target=144) + + @patch('services.rpc_service.calculate_local_summary', side_effect=RuntimeError("summary fail")) + def test_fees_sum_error_does_not_leak(self, _): + r = self.client.get('/fees-sum/800000/') + self.assertEqual(r.status_code, 500) + self.assertNotIn('summary fail', r.json.get('error', '')) + + # --- Error handlers ----------------------------------------------------- + + def test_404_returns_json(self): + r = self.client.get('/nonexistent-route') + self.assertEqual(r.status_code, 404) + self.assertIn('error', r.json) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/tests/test_database_service.py b/backend/tests/test_database_service.py new file mode 100644 index 0000000..ffc1701 --- /dev/null +++ b/backend/tests/test_database_service.py @@ -0,0 +1,138 @@ +import os +import sqlite3 +import tempfile +import unittest + + +class TestDatabaseService(unittest.TestCase): + + def setUp(self): + """Each test gets its own isolated temporary SQLite DB.""" + self.tmp = tempfile.NamedTemporaryFile(suffix='.db', delete=False) + self.tmp.close() + + import services.database_service as db + self._orig_path = db.DB_PATH + db.DB_PATH = self.tmp.name + self.db = db + self.db.init_db() + + def tearDown(self): + self.db.DB_PATH = self._orig_path + os.unlink(self.tmp.name) + + # --- init_db ------------------------------------------------------------ + + def test_creates_table(self): + conn = sqlite3.connect(self.tmp.name) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='fee_estimates'") + self.assertIsNotNone(cursor.fetchone()) + conn.close() + + def test_creates_all_indexes(self): + conn = sqlite3.connect(self.tmp.name) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='index'") + index_names = {row[0] for row in cursor.fetchall()} + conn.close() + self.assertIn('idx_poll_height', index_names) + self.assertIn('idx_target', index_names) + self.assertIn('idx_poll_height_target', index_names) + + def test_is_idempotent(self): + try: + self.db.init_db() + self.db.init_db() + except Exception as e: + self.fail(f"init_db raised on repeated call: {e}") + + # --- save_estimate / get_estimates_in_range ----------------------------- + + def test_save_and_retrieve(self): + self.db.save_estimate(poll_height=800000, target=2, feerate=15.5) + rows = self.db.get_estimates_in_range(800000, 800000, target=2) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['poll_height'], 800000) + self.assertAlmostEqual(rows[0]['estimate_feerate'], 15.5) + + def test_expected_height_computed_correctly(self): + self.db.save_estimate(poll_height=800000, target=7, feerate=10.0) + conn = sqlite3.connect(self.tmp.name) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute('SELECT expected_height FROM fee_estimates WHERE poll_height=800000') + row = cursor.fetchone() + conn.close() + self.assertEqual(row['expected_height'], 800007) + + def test_filters_by_target(self): + self.db.save_estimate(800000, target=2, feerate=10.0) + self.db.save_estimate(800000, target=7, feerate=20.0) + self.db.save_estimate(800000, target=144, feerate=5.0) + + self.assertAlmostEqual(self.db.get_estimates_in_range(800000, 800000, target=2)[0]['estimate_feerate'], 10.0) + self.assertAlmostEqual(self.db.get_estimates_in_range(800000, 800000, target=7)[0]['estimate_feerate'], 20.0) + self.assertAlmostEqual(self.db.get_estimates_in_range(800000, 800000, target=144)[0]['estimate_feerate'], 5.0) + + def test_range_is_inclusive(self): + for h in (800000, 800001, 800002): + self.db.save_estimate(h, target=2, feerate=10.0) + rows = self.db.get_estimates_in_range(800000, 800002, target=2) + heights = [r['poll_height'] for r in rows] + self.assertIn(800000, heights) + self.assertIn(800001, heights) + self.assertIn(800002, heights) + + def test_empty_range_returns_empty_list(self): + rows = self.db.get_estimates_in_range(999999, 1000000, target=2) + self.assertEqual(len(rows), 0) + + def test_oversized_range_does_not_raise(self): + try: + self.db.get_estimates_in_range(0, self.db.MAX_RANGE_BLOCKS * 100, target=2) + except Exception as e: + self.fail(f"Oversized range raised unexpectedly: {e}") + + def test_results_ordered_by_poll_height(self): + for h in (800002, 800000, 800001): + self.db.save_estimate(h, target=2, feerate=float(h)) + rows = self.db.get_estimates_in_range(800000, 800002, target=2) + heights = [r['poll_height'] for r in rows] + self.assertEqual(heights, sorted(heights)) + + def test_multiple_saves_same_height_stored(self): + for feerate in (10.0, 11.0, 12.0): + self.db.save_estimate(800000, target=2, feerate=feerate) + rows = self.db.get_estimates_in_range(800000, 800000, target=2) + self.assertGreaterEqual(len(rows), 1) + + # --- get_db_height_range ------------------------------------------------ + + def test_height_range_empty_db(self): + row = self.db.get_db_height_range(target=2) + self.assertIsNone(row[0]) + self.assertIsNone(row[1]) + + def test_height_range_returns_min_max(self): + for h in (800000, 800100, 800050): + self.db.save_estimate(h, target=2, feerate=10.0) + row = self.db.get_db_height_range(target=2) + self.assertEqual(row[0], 800000) + self.assertEqual(row[1], 800100) + + def test_height_range_respects_target(self): + self.db.save_estimate(800000, target=2, feerate=10.0) + self.db.save_estimate(800500, target=7, feerate=10.0) + + row_t2 = self.db.get_db_height_range(target=2) + self.assertEqual(row_t2[0], 800000) + self.assertEqual(row_t2[1], 800000) + + row_t7 = self.db.get_db_height_range(target=7) + self.assertEqual(row_t7[0], 800500) + self.assertEqual(row_t7[1], 800500) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/tests/test_rpc_service.py b/backend/tests/test_rpc_service.py new file mode 100644 index 0000000..c38b202 --- /dev/null +++ b/backend/tests/test_rpc_service.py @@ -0,0 +1,148 @@ +import importlib +import json +import unittest +from unittest.mock import MagicMock, patch + + +class TestRpcService(unittest.TestCase): + + def setUp(self): + # Reload module each time so lru_cache and counters start fresh + import services.rpc_service as rpc + importlib.reload(rpc) + self.rpc = rpc + + def _mock_post(self, result=None, error=None): + mock_response = MagicMock() + mock_response.json.return_value = {"result": result, "error": error, "id": 1} + return MagicMock(return_value=mock_response) + + # --- _clamp_target ------------------------------------------------------ + + def test_clamp_target_below_2(self): + self.assertEqual(self.rpc._clamp_target(1), 2) + self.assertEqual(self.rpc._clamp_target(0), 2) + self.assertEqual(self.rpc._clamp_target(-5), 2) + + def test_clamp_target_at_or_above_2(self): + self.assertEqual(self.rpc._clamp_target(2), 2) + self.assertEqual(self.rpc._clamp_target(7), 7) + self.assertEqual(self.rpc._clamp_target(144), 144) + + # --- _rpc_call ---------------------------------------------------------- + + def test_rpc_call_success(self): + with patch.object(self.rpc._session, 'post', self._mock_post(result=42)): + self.assertEqual(self.rpc._rpc_call("getblockcount", []), 42) + + def test_rpc_call_rpc_error_raises(self): + with patch.object(self.rpc._session, 'post', self._mock_post(error={"code": -1, "message": "bad"})): + with self.assertRaises(RuntimeError) as ctx: + self.rpc._rpc_call("getblockcount", []) + self.assertIn("RPC Error", str(ctx.exception)) + + def test_rpc_call_transport_error_does_not_leak_details(self): + mock_post = MagicMock(side_effect=ConnectionError("refused")) + with patch.object(self.rpc._session, 'post', mock_post): + with self.assertRaises(RuntimeError) as ctx: + self.rpc._rpc_call("getblockcount", []) + self.assertNotIn('refused', str(ctx.exception)) + + def test_rpc_call_uses_incrementing_ids(self): + captured_ids = [] + + def capture(url, data, **kwargs): + captured_ids.append(json.loads(data)['id']) + resp = MagicMock() + resp.json.return_value = {"result": 1, "error": None, "id": captured_ids[-1]} + return resp + + with patch.object(self.rpc._session, 'post', side_effect=capture): + for _ in range(3): + self.rpc._rpc_call("getblockcount", []) + + self.assertEqual(len(set(captured_ids)), 3) + self.assertEqual(captured_ids, sorted(captured_ids)) + + # --- estimate_smart_fee ------------------------------------------------- + + def test_adds_feerate_sat_per_vb(self): + with patch.object(self.rpc, '_rpc_call', return_value={"feerate": 0.0001, "blocks": 2}): + result = self.rpc.estimate_smart_fee(2, "unset", 2) + self.assertAlmostEqual(result['feerate_sat_per_vb'], 0.0001 * 100_000) + + def test_feerate_conversion_is_correct(self): + # 1 BTC/kVB = 100_000 sat/vB + with patch.object(self.rpc, '_rpc_call', return_value={"feerate": 1.0, "blocks": 2}): + result = self.rpc.estimate_smart_fee(2, "unset", 2) + self.assertAlmostEqual(result['feerate_sat_per_vb'], 100_000.0) + + def test_no_feerate_key_does_not_crash(self): + with patch.object(self.rpc, '_rpc_call', return_value={"blocks": 2}): + result = self.rpc.estimate_smart_fee(2, "unset", 2) + self.assertNotIn('feerate_sat_per_vb', result) + + def test_clamps_target_in_rpc_call(self): + with patch.object(self.rpc, '_rpc_call', return_value={"feerate": 0.0001}) as mock: + self.rpc.estimate_smart_fee(1, "unset", 2) + self.assertEqual(mock.call_args[0][1][0], 2) # params[0] should be 2 + + # --- get_single_block_stats cache safety -------------------------------- + + def test_mutation_does_not_corrupt_cache(self): + stats = {"height": 800000, "feerate_percentiles": [1, 2, 3, 4, 5]} + with patch.object(self.rpc, '_rpc_call', return_value=stats): + result1 = self.rpc.get_single_block_stats(800000) + result1['mutated'] = True + + with patch.object(self.rpc, '_rpc_call', return_value=stats): + result2 = self.rpc.get_single_block_stats(800000) + + self.assertNotIn('mutated', result2) + + def test_second_call_hits_cache(self): + stats = {"height": 800000, "feerate_percentiles": [1, 2, 3, 4, 5]} + with patch.object(self.rpc, '_rpc_call', return_value=stats) as mock: + self.rpc.get_single_block_stats(800000) + self.rpc.get_single_block_stats(800000) + mock.assert_called_once() + + # --- get_mempool_feerate_diagram_analysis -------------------------------- + + def test_empty_raw_returns_defaults(self): + with patch.object(self.rpc, '_rpc_call', return_value=None): + result = self.rpc.get_mempool_feerate_diagram_analysis() + self.assertEqual(result, {"raw": [], "windows": {}}) + + def test_diagram_output_structure(self): + raw_points = [ + {"weight": 1_000_000, "fee": 0.001}, + {"weight": 2_000_000, "fee": 0.002}, + {"weight": 4_000_000, "fee": 0.004}, + ] + with patch.object(self.rpc, '_rpc_call', return_value=raw_points): + result = self.rpc.get_mempool_feerate_diagram_analysis() + + self.assertEqual(result['total_weight'], 4_000_000) + self.assertEqual(result['total_fee'], 0.004) + for window_key in ('1', '2', '3', 'all'): + self.assertIn(window_key, result['windows']) + for window in result['windows'].values(): + for p_key in ('5', '25', '50', '75', '95'): + self.assertIn(p_key, window) + + def test_diagram_feerates_non_negative(self): + raw_points = [ + {"weight": 500_000, "fee": 0.0005}, + {"weight": 4_000_000, "fee": 0.004}, + ] + with patch.object(self.rpc, '_rpc_call', return_value=raw_points): + result = self.rpc.get_mempool_feerate_diagram_analysis() + + for window in result['windows'].values(): + for fr in window.values(): + self.assertGreaterEqual(fr, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/bitcoin_core_rpc.py b/bitcoin_core_rpc.py deleted file mode 100644 index ebd90b9..0000000 --- a/bitcoin_core_rpc.py +++ /dev/null @@ -1,7 +0,0 @@ -from json_rpc_request import make_request - -def estimatesmartfee(conf_target=1, mode="economical", block_policy_only=False, verbosity_level=1): - params = [conf_target, mode, block_policy_only, verbosity_level] - method = "estimatesmartfee" - return make_request(method, params) - diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..62b87d1 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,31 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, + { + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + }, + }, +]; + +export default eslintConfig; diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 0000000..ffacc85 --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,11 @@ +const { createDefaultPreset } = require("ts-jest"); + +const tsJestTransformCfg = createDefaultPreset().transform; + +/** @type {import("jest").Config} **/ +module.exports = { + testEnvironment: "node", + transform: { + ...tsJestTransformCfg, + }, +}; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..e9ffa30 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..051b454 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build --turbopack", + "start": "next start", + "lint": "eslint", + "test": "jest" + }, + "dependencies": { + "d3": "^7.9.0", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "recharts": "^3.7.0" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/d3": "^7.4.3", + "@types/jest": "^30.0.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "15.5.10", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "tailwindcss": "^4", + "ts-jest": "^29.4.6", + "typescript": "^5" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts new file mode 100644 index 0000000..f13cb05 --- /dev/null +++ b/frontend/src/app/api/[...path]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; + +const BACKEND_URL = process.env.BACKEND_URL ?? "http://127.0.0.1:5001"; + +// Whitelist of first path segments allowed to be forwarded to the backend. +// Anything not in this set gets a 404 — prevents SSRF and internal endpoint probing. +const ALLOWED_PATH_ROOTS = new Set([ + "blockcount", + "mempool-diagram", + "fees", + "performance-data", + "fees-sum", +]); + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path } = await params; + + // Validate root path segment before forwarding anything + const rootSegment = path[0]; + if (!rootSegment || !ALLOWED_PATH_ROOTS.has(rootSegment)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + const searchParams = request.nextUrl.searchParams.toString(); + const pathStr = path.join("/"); + const targetUrl = `${BACKEND_URL}/${pathStr}${searchParams ? `?${searchParams}` : ""}`; + + if (process.env.NODE_ENV === "development") { + console.log(`[Proxy] Forwarding to: ${targetUrl}`); + } + + try { + const response = await fetch(targetUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + cache: "no-store", + }); + + if (!response.ok) { + // Log full details server-side, return generic message to client + const errorText = await response.text(); + console.error(`[Proxy] Backend error ${response.status} for ${pathStr}: ${errorText}`); + return NextResponse.json( + { error: "Backend request failed" }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + console.error(`[Proxy] Failed to reach backend for ${pathStr}:`, error); + return NextResponse.json( + { error: "Backend service unavailable" }, + { status: 502 } + ); + } +} diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..f1d991c Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..8eac7a8 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,54 @@ +@import "tailwindcss"; + +@theme { + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +:root { + --background: #ffffff; + --foreground: #0f172a; + --card: #f8fafc; + --card-border: #e2e8f0; + --muted: #64748b; + --accent: #f97316; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #020617; + --foreground: #f8fafc; + --card: #0f172a; + --card-border: #1e293b; + --muted: #94a3b8; + --accent: #f97316; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + transition: background-color 0.3s, color 0.3s; +} + +.custom-scrollbar::-webkit-scrollbar { + height: 4px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: var(--card-border); + border-radius: 10px; +} + +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..dd6f35a --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Bitcoin Core Fee Rate Estimator", + description: "Real-time Bitcoin fee estimation and mempool health analysis powered by Bitcoin Core.", + icons: { + icon: [ + { + url: 'data:image/svg+xml,', + type: 'image/svg+xml', + }, + ], + }, +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/frontend/src/app/mempool/page.tsx b/frontend/src/app/mempool/page.tsx new file mode 100644 index 0000000..8e9c499 --- /dev/null +++ b/frontend/src/app/mempool/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { api, MempoolDiagramResponse } from "../../services/api"; +import { Header } from "../../components/common/Header"; +import MempoolDiagramChart from "../../components/mempool/MempoolDiagramChart"; +import { Activity, Database, AlertCircle, RefreshCw, Layers, TrendingUp, Scale, Database as DbIcon } from "lucide-react"; + +export default function MempoolPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [blocksToShow, setBlocksToShow] = useState(1); + + const fetchData = async () => { + try { + setLoading(true); + setError(null); + const result = await api.getMempoolDiagram(); + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch mempool diagram"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + const interval = setInterval(fetchData, 30000); + return () => clearInterval(interval); + }, []); + + const rawData = data?.raw || []; + const currentWindowKey = blocksToShow.toString(); + const currentPercentiles = data?.windows[currentWindowKey] || {}; + + const totalWeight = data?.total_weight || 0; + const totalFee = data?.total_fee || 0; + + return ( +
+
+ +
+ {/* Sleek Header Bar with Total Stats */} +
+
+
+ Total Size + {(totalWeight / 1000000).toFixed(2)} MWU +
+
+ Total Fees + {totalFee.toFixed(4)} BTC +
+
+ Mempool Chunks + {rawData.length || "---"} +
+
+ +
+
+ {[1, 2, 3, "all"].map((b) => ( + + ))} +
+ + +
+
+ + {error && ( +
+ +

Error: {error}

+
+ )} + + {/* Hero Section: Windowed Percentiles */} +
+ {["5", "25", "50", "75", "95"].map((p) => ( +
+

{p}th Percentile

+
+

+ {currentPercentiles[p] ? currentPercentiles[p].toFixed(1) : "---"} +

+ sat/vB +
+
+ ))} +
+ + {/* Main Diagram Area */} +
+
+
+

+ Mempool Fee/Weight Diagram +

+

+ {blocksToShow === "all" ? "Full mempool accumulation" : `Accumulation across first ${blocksToShow} block window`} +

+
+
+
Cumulative Fee
+
Block Boundary
+
+
+ +
+ {loading && rawData.length === 0 ? ( +
+
+

Syncing mempool state...

+
+ ) : data ? ( + + ) : ( +
+ +

Mempool analysis unavailable

+
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..cc7826b --- /dev/null +++ b/frontend/src/app/page.tsx @@ -0,0 +1,256 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { api } from "../services/api"; +import { AlertCircle, BarChart2, Activity, Loader2, ChevronLeft, ChevronRight } from "lucide-react"; +import { FeeEstimateResponse, MempoolHealthStats } from "../types/api"; +import { Header } from "../components/common/Header"; + +type FeeMode = "economical" | "conservative"; + +export default function LandingPage() { + const [target, setTarget] = useState(2); + const [mode, setMode] = useState("economical"); + const [feeData, setFeeData] = useState(null); + const [initialLoading, setInitialLoading] = useState(true); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + + const fetchFee = useCallback(async (confTarget: number, feeMode: FeeMode, silent = false) => { + try { + if (!silent) setInitialLoading(true); + else setIsUpdating(true); + + setError(null); + // Backend automatically maps target <= 1 to 2 + const data = await api.getFeeEstimate(confTarget, feeMode, 2); + setFeeData(data); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to fetch fee data"; + setError(msg); + } finally { + setInitialLoading(false); + setIsUpdating(false); + } + }, []); + + useEffect(() => { + fetchFee(target, mode, true); + }, [fetchFee, target, mode]); + + const toggleMode = () => { + setMode(prev => prev === "economical" ? "conservative" : "economical"); + }; + + const scroll = (direction: 'left' | 'right') => { + if (scrollRef.current) { + const { scrollLeft, clientWidth } = scrollRef.current; + const scrollTo = direction === 'left' ? scrollLeft - clientWidth : scrollLeft + clientWidth; + scrollRef.current.scrollTo({ left: scrollTo, behavior: 'smooth' }); + } + }; + + return ( +
+
+ +
+
+ +
+
+
+
+ NETWORK: MAINNET +
+ +
+ {[2, 7, 144].map((t) => ( + + ))} +
+
+ +
+ {/* Fee Card Section */} +
+
+ +
+
+
+ + ESTIMATE MODE + +

+ {mode} +

+
+ +
+ +
+ {initialLoading ? ( + + ) : error ? ( +
+ +

{error}

+
+ ) : ( +
+
+ + {feeData?.feerate_sat_per_vb ? feeData.feerate_sat_per_vb.toFixed(1) : "---"} + + sat/vB +
+

+ Confirmation within {target} blocks +

+
+ )} +
+
+ + {/* Mode Dots */} +
+
+
+
+
+ + {/* Horizontal Mempool Health */} +
+
+
+ + Mempool Health +
+
+ + +
+
+ +
+ {initialLoading ? ( + Array(4).fill(0).map((_, i) => ( +
+ )) + ) : ( + <> + {feeData?.mempool_health_statistics?.map((stat: any, i: number) => ( + + ))} + {!feeData?.mempool_health_statistics?.length && ( +
+ Mempool metrics unavailable for this node. +
+ )} + + )} +
+
+
+
+
+ +
+
+ Powered by Bitcoin Core RPC +
+
+
+ ); +} + +function HealthBlock({ stat }: { stat: MempoolHealthStats }) { + const ratioPerc = (stat.ratio * 100).toFixed(1); + const color = stat.ratio > 0.95 ? "bg-green-500" : stat.ratio > 0.7 ? "bg-orange-500" : "bg-red-500"; + + return ( +
+
+ Block {stat.block_height} + + {ratioPerc}% + +
+ +
+
+
+ Block + {(stat.block_weight / 1000).toFixed(0)} kWU +
+
+
+
+
+ +
+
+ Mempool + {(stat.mempool_txs_weight / 1000).toFixed(0)} kWU +
+
+
+
+
+
+
+ ); +} + +function LoadingSpinner() { + return ( +
+
+ Estimating... +
+ ); +} + +function RateDetail({ label, value }: any) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/frontend/src/app/stats/page.tsx b/frontend/src/app/stats/page.tsx new file mode 100644 index 0000000..e06dead --- /dev/null +++ b/frontend/src/app/stats/page.tsx @@ -0,0 +1,216 @@ +"use client"; + +import { useState } from "react"; +import { BarChart3, TrendingUp, AlertCircle, CheckCircle2, Search, Activity, Database, ArrowRight, RefreshCw, Scale } from "lucide-react"; +import { useStats } from "../../hooks/useStats"; +import { Header } from "../../components/common/Header"; +import FeeHistoryChart from "../../components/stats/FeeHistoryChart"; + +export default function StatsPage() { + const [target, setTarget] = useState(2); + const [scaleType, setScaleType] = useState<"log" | "linear">("linear"); + const { + blocks, + estimates, + summary, + loading, + error, + startBlock, + setStartBlock, + endBlock, + setEndBlock, + latestBlock, + handleApply, + syncHeight + } = useStats(target); + + const handleStartChange = (val: number) => { + setStartBlock(val); + if (endBlock !== null && (endBlock - val) > 1000) setEndBlock(val + 1000); + }; + + const handleEndChange = (val: number) => { + setEndBlock(val); + if (startBlock !== null && (val - startBlock) > 1000) setStartBlock(val - 1000); + }; + + const handleSyncLatest = async () => { + const current = await syncHeight(); + if (current) { + setEndBlock(current); + setStartBlock(current - 100); + } + }; + + const hasBlocks = blocks && blocks.length > 0; + + return ( +
+
+ +
+ {/* Sleek Control Bar */} +
+
+

+ Latest Block: {latestBlock || "---"} +

+
+ +
+ + +
+ {[2, 7, 144].map((t) => ( + + ))} +
+ +
+
+
+ Start + handleStartChange(Number(e.target.value))} + className="bg-transparent border-none focus:ring-0 text-sm w-20 p-0 outline-none font-mono font-black" + /> +
+ +
+ End + handleEndChange(Number(e.target.value))} + className="bg-transparent border-none focus:ring-0 text-sm w-20 p-0 outline-none font-mono font-black" + /> +
+
+ + +
+
+
+ + {error && ( +
+ +

Error: {error}

+
+ )} + +
+ } + colorClass="text-green-500" + bgColorClass="bg-green-500/10" + total={summary?.total} + /> + } + colorClass="text-red-500" + bgColorClass="bg-red-500/10" + total={summary?.total} + /> + } + colorClass="text-yellow-500" + bgColorClass="bg-yellow-500/10" + total={summary?.total} + /> +
+ +
+
+
+

+ + Inclusion History +

+

p10 to p90 block fee distribution

+
+
+
p10-p90
+
Fee Estimate
+
+
+ +
+ {loading ? ( +
+
+

Syncing data...

+
+ ) : hasBlocks ? ( + + ) : ( +
+ +
+

No range data available

+

Try refreshing or syncing to the latest block height.

+
+
+ )} +
+
+
+
+ ); +} + +function SummaryCard({ title, value, percent, icon, colorClass, bgColorClass, total }: any) { + return ( +
+
+
{icon}
+
+ + {percent !== undefined ? (percent * 100).toFixed(1) : "0"}% + +
Accuracy
+
+
+

{title}

+

+ {value || 0} / {total || 0} estimates +

+
+ ); +} diff --git a/frontend/src/components/common/Header.tsx b/frontend/src/components/common/Header.tsx new file mode 100644 index 0000000..7b1c5f2 --- /dev/null +++ b/frontend/src/components/common/Header.tsx @@ -0,0 +1,50 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export function Header() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/frontend/src/components/mempool/MempoolDiagramChart.tsx b/frontend/src/components/mempool/MempoolDiagramChart.tsx new file mode 100644 index 0000000..5ce5019 --- /dev/null +++ b/frontend/src/components/mempool/MempoolDiagramChart.tsx @@ -0,0 +1,114 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { MempoolDiagramPoint } from "../../services/api"; + +interface Props { + data: MempoolDiagramPoint[]; + percentiles: Record; + blocksToShow: number | "all"; + loading: boolean; +} + +export default function MempoolDiagramChart({ data, percentiles, blocksToShow, loading }: Props) { + const svgRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!svgRef.current || !containerRef.current || loading || data.length === 0) return; + + d3.select(svgRef.current).selectAll("*").remove(); + + const margin = { top: 40, right: 60, bottom: 60, left: 80 }; + const width = containerRef.current.clientWidth - margin.left - margin.right; + const height = 500 - margin.top - margin.bottom; + + const svg = d3.select(svgRef.current) + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + svg.append("rect") + .attr("width", width) + .attr("height", height) + .attr("fill", "#ebebeb") + .attr("rx", 8); + + const plotData = [{ weight: 0, fee: 0 }, ...data]; + const BLOCK_WEIGHT = 4000000; + const maxDataWeight = data[data.length - 1].weight; + const currentMaxWeight = blocksToShow === "all" ? maxDataWeight : blocksToShow * BLOCK_WEIGHT; + + const filteredData = plotData.filter(d => d.weight <= currentMaxWeight); + + const x = d3.scaleLinear().domain([0, currentMaxWeight]).range([0, width]); + const y = d3.scaleLinear().domain([0, d3.max(filteredData, d => d.fee) || 1]).range([height, 0]); + + // Grid - Faded + svg.append("g").attr("transform", `translate(0,${height})`) + .call(d3.axisBottom(x).ticks(10).tickSize(-height).tickFormat(() => "")) + .selectAll("line").attr("stroke", "#fff").attr("stroke-width", 1.5); + svg.append("g") + .call(d3.axisLeft(y).ticks(10).tickSize(-width).tickFormat(() => "")) + .selectAll("line").attr("stroke", "#fff").attr("stroke-width", 1.5); + + // --- Block Boundaries --- + const numBlocks = Math.floor(currentMaxWeight / BLOCK_WEIGHT); + for (let i = 1; i <= numBlocks; i++) { + const xPos = x(i * BLOCK_WEIGHT); + if (xPos <= width) { + svg.append("line").attr("x1", xPos).attr("x2", xPos).attr("y1", 0).attr("y2", height) + .attr("stroke", "#666").attr("stroke-dasharray", "4,4").style("opacity", 0.3); + } + } + + // --- Growth Curve --- + const line = d3.line().x(d => x(d.weight)).y(d => y(d.fee)).curve(d3.curveLinear); + svg.append("path").datum(filteredData).attr("fill", "none").attr("stroke", "#f97316").attr("stroke-width", 3.5).attr("d", line); + + // --- Global Window Percentiles --- + Object.entries(percentiles).forEach(([perc, rate]) => { + const targetW = (Number(perc) / 100) * currentMaxWeight; + + const bisect = d3.bisector((d: any) => d.weight).left; + const idx = bisect(filteredData, targetW); + let targetFee = 0; + if (idx > 0 && idx < filteredData.length) { + const d0 = filteredData[idx-1]; + const d1 = filteredData[idx]; + const t = (targetW - d0.weight) / (d1.weight - d0.weight); + targetFee = d0.fee + t * (d1.fee - d0.fee); + } else if (idx < filteredData.length) { + targetFee = filteredData[idx].fee; + } + + const posX = x(targetW); + const posY = y(targetFee); + + svg.append("circle").attr("cx", posX).attr("cy", posY).attr("r", 4).attr("fill", "#333").attr("stroke", "#fff").attr("stroke-width", 1.5); + + // Feerate Label + svg.append("text").attr("x", posX).attr("y", posY - 15).attr("text-anchor", "middle").style("font-size", "10px").style("font-weight", "black").attr("fill", "#333").text(`${rate.toFixed(1)}`); + + // Percentile label (faded) + svg.append("text").attr("x", posX).attr("y", posY + 20).attr("text-anchor", "middle").style("font-size", "8px").style("font-weight", "bold").attr("fill", "#999").text(`${perc}%`); + }); + + // Axes Labels - Faded + svg.append("g").attr("transform", `translate(0,${height})`) + .call(d3.axisBottom(x).ticks(5).tickFormat(d => `${(Number(d) / 1000000).toFixed(1)}M`)) + .selectAll("text").attr("fill", "#aaa").style("font-size", "10px").style("font-weight", "bold"); + + svg.append("g").call(d3.axisLeft(y).ticks(10)) + .selectAll("text").attr("fill", "#aaa").style("font-size", "10px").style("font-weight", "bold"); + + }, [data, percentiles, blocksToShow, loading]); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/stats/FeeHistoryChart.tsx b/frontend/src/components/stats/FeeHistoryChart.tsx new file mode 100644 index 0000000..3362c65 --- /dev/null +++ b/frontend/src/components/stats/FeeHistoryChart.tsx @@ -0,0 +1,198 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +interface Props { + blocks: { height: number; low: number; high: number }[]; + estimates: { height: number; rate: number }[]; + loading: boolean; + scaleType: "log" | "linear"; +} + +export default function FeeHistoryChart({ blocks, estimates, loading, scaleType }: Props) { + const svgRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + if (!svgRef.current || !containerRef.current || loading || blocks.length === 0) return; + + // Clear previous + d3.select(svgRef.current).selectAll("*").remove(); + + const margin = { top: 50, right: 30, bottom: 50, left: 60 }; + const width = containerRef.current.clientWidth - margin.left - margin.right; + const height = 500 - margin.top - margin.bottom; + + const svg = d3.select(svgRef.current) + .attr("width", width + margin.left + margin.right) + .attr("height", height + margin.top + margin.bottom) + .append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + // ggplot background + svg.append("rect") + .attr("width", width) + .attr("height", height) + .attr("fill", "#ebebeb") + .attr("rx", 8); + + const xDomain = d3.extent(blocks, d => d.height) as [number, number]; + const x = d3.scaleLinear().domain(xDomain).range([0, width]); + + // 1. CLIPPING: Only show estimates that fall within the block height range + const visibleEstimates = estimates.filter(e => e.height >= xDomain[0] && e.height <= xDomain[1]); + + const yMaxBlocks = d3.max(blocks, d => d.high) || 100; + const yMaxEstimates = d3.max(visibleEstimates, d => d.rate) || 0; + + // Generous top padding (20%) to ensure highest rate is visible + const yMax = Math.max(yMaxBlocks, yMaxEstimates) * 1.2; + + // 2. SCALE PADDING: Start slightly below 0 (-0.5 or -1) for better visualization + const yMin = scaleType === "log" ? -0.1 : -0.5; + + const y = scaleType === "log" + ? d3.scaleSymlog().domain([yMin, yMax]).range([height, 0]).constant(1) + : d3.scaleLinear().domain([yMin, yMax]).range([height, 0]); + + // Grid lines + const numTicks = Math.min(blocks.length, 10); + svg.append("g").attr("transform", `translate(0,${height})`) + .call(d3.axisBottom(x).ticks(numTicks).tickSize(-height).tickFormat(() => "")) + .selectAll("line").attr("stroke", "#fff").attr("stroke-width", 1.5); + + svg.append("g") + .call(d3.axisLeft(y).ticks(10).tickSize(-width).tickFormat(() => "")) + .selectAll("line").attr("stroke", "#fff").attr("stroke-width", 1.5); + + // 3. Area (p10 - p90) + const area = d3.area() + .x(d => x(d.height)) + .y0(d => y(d.low)) + .y1(d => y(d.high)) + .curve(d3.curveMonotoneX); + + svg.append("path") + .datum(blocks) + .attr("fill", "#999") + .attr("fill-opacity", 0.3) + .attr("d", area); + + // 4. Fee Estimate Line (Clipped to blocks) + if (visibleEstimates.length > 0) { + const line = d3.line() + .x(d => x(d.height)) + .y(d => y(d.rate)) + .curve(d3.curveMonotoneX); + + svg.append("path") + .datum(visibleEstimates.sort((a, b) => a.height - b.height)) + .attr("fill", "none") + .attr("stroke", "#3b82f6") + .attr("stroke-width", 3) + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .attr("d", line); + } + + // Axes + svg.append("g").attr("transform", `translate(0,${height})`) + .call(d3.axisBottom(x).ticks(numTicks).tickFormat(d3.format("d"))) + .selectAll("text").attr("fill", "#666").style("font-size", "11px").style("font-weight", "bold"); + + // Y Axis Ticks (filtering out negative labels if we don't want them visible) + const yTicks = + scaleType === "log" + ? [0, 1, 2, 5, 10, 20, 50, 100, 250, 500, 1000].filter(v => v <= yMax) + : 10; + const yAxis = d3.axisLeft(y).tickFormat(d3.format("d")); + if (Array.isArray(yTicks)) { + yAxis.tickValues(yTicks); + } else { + yAxis.ticks(yTicks); + } + + svg.append("g") + .call(yAxis) + .selectAll("text") + .attr("fill", "#666") + .style("font-size", "11px") + .style("font-weight", "bold"); + + // Interaction Tooltip + const tooltip = d3.select(containerRef.current) + .append("div") + .attr("class", "chart-tooltip") + .style("position", "absolute") + .style("visibility", "hidden") + .style("background", "var(--card)") + .style("border", "1px solid var(--card-border)") + .style("padding", "12px") + .style("border-radius", "8px") + .style("box-shadow", "0 10px 15px -3px rgba(0,0,0,0.1)") + .style("pointer-events", "none") + .style("z-index", "100") + .style("color", "var(--foreground)"); + + const mouseLine = svg.append("line") + .attr("stroke", "#666") + .attr("stroke-width", 1) + .attr("stroke-dasharray", "4,4") + .style("opacity", 0); + + const mouseG = svg.append("g").style("opacity", 0); + mouseG.append("circle").attr("r", 4).attr("fill", "#3b82f6").attr("stroke", "#fff").attr("stroke-width", 2); + + svg.append("rect") + .attr("width", width) + .attr("height", height) + .attr("fill", "transparent") + .on("mousemove", (event) => { + const [mouseX] = d3.pointer(event); + const heightVal = Math.round(x.invert(mouseX)); + + const b = blocks.find(b => b.height === heightVal); + const e = visibleEstimates.find(e => Math.round(e.height) === heightVal); + + if (b) { + mouseLine.attr("x1", x(heightVal)).attr("x2", x(heightVal)).attr("y1", 0).attr("y2", height).style("opacity", 1); + + if (e) { + mouseG.attr("transform", `translate(${x(heightVal)},${y(e.rate)})`).style("opacity", 1); + } + + tooltip + .style("visibility", "visible") + .style("left", `${event.pageX + 15}px`) + .style("top", `${event.pageY - 15}px`) + .html(` +
+
BLOCK #${heightVal}
+
+ Range: + ${b.low.toFixed(1)} - ${b.high.toFixed(1)} +
+ ${e ? ` +
+ Estimate: + ${e.rate.toFixed(2)} +
` : ''} +
+ `); + } + }) + .on("mouseleave", () => { + tooltip.style("visibility", "hidden"); + mouseLine.style("opacity", 0); + mouseG.style("opacity", 0); + }); + + }, [blocks, estimates, loading, scaleType]); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/hooks/useStats.ts b/frontend/src/hooks/useStats.ts new file mode 100644 index 0000000..bc46dcc --- /dev/null +++ b/frontend/src/hooks/useStats.ts @@ -0,0 +1,85 @@ +import { useState, useEffect, useCallback } from "react"; +import { api } from "../services/api"; +import { AnalyticsSummary, MempoolHealthStats } from "../types/api"; + +export function useStats(target: number = 2) { + const [performanceData, setPerformanceData] = useState<{ blocks: any[]; estimates: any[] }>({ blocks: [], estimates: [] }); + const [summary, setSummary] = useState(null); + const [healthStats, setHealthStats] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [startBlock, setStartBlock] = useState(null); + const [endBlock, setEndBlock] = useState(null); + const [latestBlock, setLatestBlock] = useState(null); + + const fetchData = useCallback(async (start: number, end: number, confTarget: number) => { + try { + setLoading(true); + setError(null); + + const count = Math.max(1, end - start); + + const [pData, fSum, feeEst] = await Promise.all([ + api.getPerformanceData(start, count, confTarget), + api.getFeesSum(start, confTarget), + api.getFeeEstimate(confTarget, "unset", 2) + ]); + + setPerformanceData(pData); + setSummary(fSum); + setHealthStats(feeEst.mempool_health_statistics || []); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to fetch performance data"; + setError(msg); + } finally { + setLoading(false); + } + }, []); + + const syncHeight = useCallback(async () => { + try { + const { blockcount } = await api.getBlockCount(); + setLatestBlock(blockcount); + return blockcount; + } catch (err) { + return null; + } + }, []); + + useEffect(() => { + const init = async () => { + const currentHeight = await syncHeight(); + if (currentHeight && startBlock === null) { + const s = currentHeight - 100; // Default to 100 for clarity + const e = currentHeight; + setStartBlock(s); + setEndBlock(e); + fetchData(s, e, target); + } + }; + init(); + }, [syncHeight]); + + const handleApply = () => { + if (startBlock !== null && endBlock !== null) { + fetchData(startBlock, endBlock, target); + } + }; + + return { + blocks: performanceData.blocks, + estimates: performanceData.estimates, + summary, + healthStats, + loading, + error, + startBlock, + setStartBlock, + endBlock, + setEndBlock, + latestBlock, + handleApply, + syncHeight + }; +} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts new file mode 100644 index 0000000..da1d162 --- /dev/null +++ b/frontend/src/services/api.test.ts @@ -0,0 +1,48 @@ +import { BitcoinCoreAPI } from './api'; + +describe('BitcoinCoreAPI', () => { + let api: BitcoinCoreAPI; + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = jest.fn(); + (global as any).fetch = fetchMock; + api = new BitcoinCoreAPI('http://test-api:5001'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should fetch fee estimate', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ feerate: 0.0001, blocks: 2 }), + }); + + const result = await api.getFeeEstimate(2, 'economical', 2); + expect(fetchMock).toHaveBeenCalledWith('http://test-api:5001/fees/2/economical/2', undefined); + expect(result.feerate).toBe(0.0001); + }); + + it('should fetch block count', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ blockcount: 800000 }), + }); + + const result = await api.getBlockCount(); + expect(fetchMock).toHaveBeenCalledWith('http://test-api:5001/blockcount', undefined); + expect(result.blockcount).toBe(800000); + }); + + it('should handle fetch errors', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + await expect(api.getBlockCount()).rejects.toThrow('API error: status=500'); + }); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..4aef036 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,67 @@ +import { + AnalyticsSummary, + BlockStatsMap, + FeesStatsMap, + BlockchainInfo, + FeeEstimateResponse, +} from "../types/api"; + +const API_BASE_PATH = "/api"; + +export interface MempoolDiagramPoint { + weight: number; + fee: number; +} + +export interface MempoolDiagramResponse { + raw: MempoolDiagramPoint[]; + windows: Record>; + total_weight: number; + total_fee: number; +} + +export class BitcoinCoreAPI { + private baseUrl: string = API_BASE_PATH; + + constructor() { + console.debug(`[API Service] Using relative proxy path: ${this.baseUrl}`); + } + + private async fetchJson(path: string, options?: RequestInit): Promise { + const cleanPath = path.replace(/^\/+/, "").replace(/\/+$/, ""); + const url = `${this.baseUrl}/${cleanPath}`; + try { + const response = await fetch(url, options); + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error: status=${response.status} message=${text}`); + } + return await response.json() as T; + } catch (error) { + console.error(`[API Service] Failed to fetch: ${url}`, error); + throw error; + } + } + + async getFeeEstimate(target: number = 2, mode: string = "economical", level: number = 2): Promise { + return this.fetchJson(`fees/${target}/${mode}/${level}`); + } + + async getBlockCount(): Promise { + return this.fetchJson(`blockcount`); + } + + async getPerformanceData(startBlock: number, count: number = 100, target: number = 2): Promise { + return this.fetchJson(`performance-data/${startBlock}/?target=${target}&count=${count}`); + } + + async getFeesSum(startBlock: number, target: number = 2): Promise { + return this.fetchJson(`fees-sum/${startBlock}?target=${target}`); + } + + async getMempoolDiagram(): Promise { + return this.fetchJson(`mempool-diagram`); + } +} + +export const api = new BitcoinCoreAPI(); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..e204d74 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,38 @@ +export interface AnalyticsSummary { + total: number; + overpayment_val: number; + overpayment_perc: number; + underpayment_val: number; + underpayment_perc: number; + within_val: number; + within_perc: number; +} + +export interface BlockStats { + height: number; + min: number | null; + max: number | null; + estimated: number | null; + actual: number | null; +} + +export type BlockStatsMap = Record; +export type FeesStatsMap = Record; + +export interface BlockchainInfo { + blockcount: number; +} + +export interface MempoolHealthStats { + block_height: number; + block_weight: number; + mempool_txs_weight: number; + ratio: number; +} + +export interface FeeEstimateResponse { + feerate: number; + blocks: number; + errors?: string[]; + mempool_health_statistics?: MempoolHealthStats[]; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c133409 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/json_rpc_request.py b/json_rpc_request.py deleted file mode 100644 index ba622cb..0000000 --- a/json_rpc_request.py +++ /dev/null @@ -1,20 +0,0 @@ -import configparser -import json -import requests - -Config = configparser.ConfigParser() -Config.read("rpc_config.ini") - -URL = Config.get("RPC_INFO", "URL") -RPCUSER = Config.get("RPC_INFO", "RPC_USER") -RPCPASSWORD = Config.get("RPC_INFO", "RPC_PASSWORD") - -def getjson_payload(method, params): - return json.dumps({"method": method, "params": params}) - -def make_request(method, params): - payload = getjson_payload(method, params) - headers = {'content-type': "application/json", 'cache-control': "no-cache"} - response = requests.request("POST", URL, data=payload, headers=headers, auth=(RPCUSER, RPCPASSWORD)) - return json.loads(response.text)["result"] - diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b057661 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,857 @@ +{ + "name": "bitcoin-core-fees", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "next": "^15.5.4" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz", + "integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz", + "integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz", + "integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz", + "integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz", + "integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz", + "integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz", + "integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz", + "integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz", + "integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001748", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz", + "integrity": "sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz", + "integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.4", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.4", + "@next/swc-darwin-x64": "15.5.4", + "@next/swc-linux-arm64-gnu": "15.5.4", + "@next/swc-linux-arm64-musl": "15.5.4", + "@next/swc-linux-x64-gnu": "15.5.4", + "@next/swc-linux-x64-musl": "15.5.4", + "@next/swc-win32-arm64-msvc": "15.5.4", + "@next/swc-win32-x64-msvc": "15.5.4", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c470060 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "next": "^15.5.4" + } +} diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..2db5c5a --- /dev/null +++ b/restart.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +echo "Stopping existing services..." + +# Kill backend +pkill -f "src/app.py" +pkill -f "gunicorn" +# Kill frontend +pkill -f "next-server" +pkill -f "next dev" +pkill -f "next start" + +# Wait for ports to clear +sleep 2 + +echo "Starting Backend on port 5001..." +cd backend + +if [ ! -d ".venv" ]; then + python3 -m venv .venv +fi +source .venv/bin/activate +pip install -r requirements.txt + +# Production: gunicorn with multiple workers instead of raw python +nohup env PYTHONPATH=src .venv/bin/gunicorn \ + --workers 4 \ + --bind 127.0.0.1:5001 \ + --timeout 120 \ + --access-logfile access.log \ + --error-logfile error.log \ + "app:app" > debug.log 2>&1 & +echo "Backend started (PID: $!)" + +cd .. + +echo "Building Frontend..." +cd frontend + +if [ ! -d "node_modules" ]; then + npm install +fi + +# Production: build first, then start (not next dev) +npm run build + +nohup npm run start > frontend.log 2>&1 & +echo "Frontend started (PID: $!)" + +cd .. + +echo "------------------------------------------" +echo "Services are starting in the background." +echo "Backend: http://localhost:5001" +echo "Frontend: http://localhost:3000" +echo "------------------------------------------" +echo "Logs: backend/debug.log, backend/access.log, frontend/frontend.log"