From 475e37778eaaf0f8dab4d6825f99f1285f72ea2d Mon Sep 17 00:00:00 2001 From: ismaelsadeeq Date: Fri, 27 Feb 2026 10:05:05 +0000 Subject: [PATCH 1/5] Initial project structure and GitHub Actions workflow Set up root directory, documentation, and automated test workflow. Removed old files moved to backend module. Co-authored-by: Gemini Co-authored-by: Claude --- .github/workflows/tests.yml | 46 ++ .gitignore | 15 + Readme.md | 4 + app.py | 15 - bitcoin_core_rpc.py | 7 - example.cfg | 8 - json_rpc_request.py | 20 - package-lock.json | 857 ++++++++++++++++++++++++++++++++++++ package.json | 5 + restart.sh | 57 +++ rpc_config.ini.example | 4 - 11 files changed, 984 insertions(+), 54 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 Readme.md delete mode 100644 app.py delete mode 100644 bitcoin_core_rpc.py delete mode 100644 example.cfg delete mode 100644 json_rpc_request.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 restart.sh delete mode 100644 rpc_config.ini.example 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 new file mode 100644 index 0000000..2ffa0c0 --- /dev/null +++ b/Readme.md @@ -0,0 +1,4 @@ +Bitcoin Core Feerate API + +ASAP: https://bitcoincorefeerate.com/fees/2/economical/2 + 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/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/example.cfg b/example.cfg deleted file mode 100644 index 2f3b6fa..0000000 --- a/example.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[Section1] -an_int = 15 -a_bool = true -a_float = 3.1415 -baz = fun -bar = Python -foo = %(bar)s is %(baz)s! - 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" diff --git a/rpc_config.ini.example b/rpc_config.ini.example deleted file mode 100644 index 5fb44d7..0000000 --- a/rpc_config.ini.example +++ /dev/null @@ -1,4 +0,0 @@ -[RPC_INFO] -URL = -RPC_USER = -RPC_PASSWORD = From 809e8868d2e19b87b1f93683cb76198d15ffedaf Mon Sep 17 00:00:00 2001 From: ismaelsadeeq Date: Fri, 27 Feb 2026 10:10:06 +0000 Subject: [PATCH 2/5] Backend: Core services, tests, and configuration Implemented core backend services (collector, database, rpc) and added comprehensive tests. Co-authored-by: Gemini Co-authored-by: Claude Co-authored-by: b-l-u-e Co-authored-by: mercie-ux --- backend/.gitignore | 8 + backend/doc.md | 69 ++++++ backend/requirements.txt | 6 + backend/rpc_config.ini.example | 4 + backend/src/app.py | 128 +++++++++++ backend/src/services/collector_service.py | 48 ++++ backend/src/services/database_service.py | 104 +++++++++ backend/src/services/rpc_service.py | 268 ++++++++++++++++++++++ backend/test.md | 80 +++++++ backend/tests/conftest.py | 5 + backend/tests/helpers.py | 16 ++ backend/tests/test_app.py | 125 ++++++++++ backend/tests/test_database_service.py | 138 +++++++++++ backend/tests/test_rpc_service.py | 148 ++++++++++++ 14 files changed, 1147 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/doc.md create mode 100644 backend/requirements.txt create mode 100644 backend/rpc_config.ini.example create mode 100644 backend/src/app.py create mode 100644 backend/src/services/collector_service.py create mode 100644 backend/src/services/database_service.py create mode 100644 backend/src/services/rpc_service.py create mode 100644 backend/test.md create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/helpers.py create mode 100644 backend/tests/test_app.py create mode 100644 backend/tests/test_database_service.py create mode 100644 backend/tests/test_rpc_service.py 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..fe57401 --- /dev/null +++ b/backend/doc.md @@ -0,0 +1,69 @@ +# Backend - Bitcoin Core Fees API + +This service provides a Flask-based REST API to interact with Bitcoin Core RPC and provide fee analytics. + +## 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 and keep it running after you disconnect from the terminal, use the following `nohup` command: + +```bash +nohup .venv/bin/python app.py > debug.log 2>&1 & +``` + +**What this command does:** +* `nohup`: Stands for "No Hang Up". It allows the command to continue running even after you logout or close the terminal. +* `.venv/bin/python app.py`: Executes the Flask app using the Python interpreter inside your virtual environment. +* `> debug.log`: Redirects standard output (logs) to a file named `debug.log`. +* `2>&1`: Redirects standard error (errors) to the same location as standard output (`debug.log`). +* `&`: Puts the command in the background, allowing you to continue using the terminal. + +### 3. Monitoring Logs +To see the logs in real-time: +```bash +tail -f debug.log +``` + +### 4. Stopping the App +To stop the background process, you can find the Process ID (PID) and kill it, or use `pkill`: + +**Option A (Using pkill):** +```bash +pkill -f "python app.py" +``` + +**Option B (By Port):** +```bash +kill $(lsof -t -i:5001) +``` + +**Option C (Manual):** +1. Find the PID: `ps aux | grep "python app.py"` +2. Kill the process: `kill ` + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/health` | GET | Check service and RPC connection status. | +| `/blockchain/info` | GET | Get general info about the Bitcoin blockchain. | +| `/blockcount` | GET | Get the current block height. | +| `/mempool/info` | GET | Get current mempool state (size, bytes, etc.). | +| `/fees///` | GET | Get `estimatesmartfee` from Bitcoin Core. | +| `/fees/mempool` | GET | Get fee estimates based on current mempool percentiles. | +| `/api/v1/fees/estimate` | GET | Unified endpoint for mempool, historical, or hybrid estimates. | +| `/analytics/summary` | GET | Get summarized fee and block analytics (internal or external fallback). | +| `/blockstats/` | GET | Get detailed stats for a specific block height. | +| `/external/block-stats/` | GET | Proxy to external API for block statistics. | +| `/external/fees-stats/` | GET | Proxy to external API for fee statistics. | +| `/external/fees-sum/` | GET | Proxy to external API for fee summation analytics. | diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8a0f4cc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.1.3 +Flask-CORS==4.0.2 +Flask-Limiter==4.1.1 +requests==2.32.5 +configparser==6.0.0 +gunicorn diff --git a/backend/rpc_config.ini.example b/backend/rpc_config.ini.example new file mode 100644 index 0000000..5fb44d7 --- /dev/null +++ b/backend/rpc_config.ini.example @@ -0,0 +1,4 @@ +[RPC_INFO] +URL = +RPC_USER = +RPC_PASSWORD = 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..457d396 --- /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 (1s 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..5d909de --- /dev/null +++ b/backend/src/services/rpc_service.py @@ -0,0 +1,268 @@ +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"]]) + 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 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 + 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..632e0d0 --- /dev/null +++ b/backend/test.md @@ -0,0 +1,80 @@ +# Testing Guide + +## Prerequisites + +`requirements.txt` should include: + +``` +Flask==3.1.3 +Flask-CORS==4.0.2 +Flask-Limiter==4.1.1 +requests==2.32.3 +configparser==6.0.0 +pytest +pytest-cov +``` + +--- + +## Test Structure + +``` +tests/ +├── conftest.py # pytest path setup +├── helpers.py # shared app factory +├── test_app.py # HTTP layer — routes, error handlers, mode validation +├── test_rpc_service.py # RPC logic, fee math, caching, mempool diagram +├── test_database_service.py # SQLite writes, queries, indexes, edge cases +└── test_collector_service.py # Collector lifecycle, duplicate guard, error resilience +``` + +--- + +## Running Tests + +All commands should be run from the `backend/` directory. + +**Install all required packages** +``` +pip install -r requirements.txt +``` + +**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 +python -m pytest tests/test_collector_service.py -v +``` + +**Run a single test by name:** +```bash +python -m pytest tests/test_rpc_service.py::TestRpcService::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/services --cov=src/app --cov-report=term-missing +``` + +**Generate an HTML report (opens in browser):** +```bash +python -m pytest tests/ --cov=src/services --cov=src/app --cov-report=html +open htmlcov/index.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() From 959afbd859d422034ae73e1c6be2eca6b7745c26 Mon Sep 17 00:00:00 2001 From: ismaelsadeeq Date: Fri, 27 Feb 2026 10:10:09 +0000 Subject: [PATCH 3/5] Frontend: Stats page, mempool diagrams, and UI components Implemented Next.js frontend with dynamic charts and stats visualization. Co-authored-by: Gemini Co-authored-by: Claude Co-authored-by: b-l-u-e Co-authored-by: mercie-ux --- frontend/.gitignore | 41 +++ frontend/README.md | 36 +++ frontend/eslint.config.mjs | 31 +++ frontend/jest.config.js | 11 + frontend/next.config.ts | 7 + frontend/package.json | 36 +++ frontend/postcss.config.mjs | 5 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/next.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/src/app/api/[...path]/route.ts | 63 +++++ frontend/src/app/favicon.ico | Bin 0 -> 15406 bytes frontend/src/app/globals.css | 54 ++++ frontend/src/app/layout.tsx | 43 +++ frontend/src/app/mempool/page.tsx | 152 +++++++++++ frontend/src/app/page.tsx | 256 ++++++++++++++++++ frontend/src/app/stats/page.tsx | 216 +++++++++++++++ frontend/src/components/common/Header.tsx | 50 ++++ .../mempool/MempoolDiagramChart.tsx | 114 ++++++++ .../src/components/stats/FeeHistoryChart.tsx | 198 ++++++++++++++ frontend/src/hooks/useStats.ts | 85 ++++++ frontend/src/services/api.test.ts | 48 ++++ frontend/src/services/api.ts | 67 +++++ frontend/src/types/api.ts | 38 +++ frontend/tsconfig.json | 27 ++ 27 files changed, 1583 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/jest.config.js create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/src/app/api/[...path]/route.ts create mode 100644 frontend/src/app/favicon.ico create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/mempool/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/app/stats/page.tsx create mode 100644 frontend/src/components/common/Header.tsx create mode 100644 frontend/src/components/mempool/MempoolDiagramChart.tsx create mode 100644 frontend/src/components/stats/FeeHistoryChart.tsx create mode 100644 frontend/src/hooks/useStats.ts create mode 100644 frontend/src/services/api.test.ts create mode 100644 frontend/src/services/api.ts create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/tsconfig.json 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..25092da --- /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.544.0", + "next": "15.5.10", + "react": "19.1.5", + "react-dom": "19.1.5", + "recharts": "^3.2.1" + }, + "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 0000000000000000000000000000000000000000..f1d991cde02049fc5d7ad7ed2dfa373f1bc63b34 GIT binary patch literal 15406 zcmeHOd2m$6nI9*qBwp8U>`h{JIfS?c;S$&g3Ot#h-I|khn5)v9oBXJloiH{g#Fc<;>90(3Jm$AcX5SQNTH%IU9*E9X5=kZ1d zko=Q$Rr8)^=Jor2{oUW!1Om4Nz8JXu_5iM#fhWEc2#gB^0-2fdzq9TL1eW8o(W4XJ z4-N#jeKQcq!f)^ho``>rOP9u=61#u@5^GVv5^G0hiFG=o*gD;Nne~33W!Cc+WM;7?`(kD+vt*y8mcw;&7f4W?9DMd$RbuV13R?~> zx8;y>OJW^4$Y&iqFZ&eRtIah=)rg;stg_{ZN?U#lQ?FC|K}0_OnvhF(g|DHfZ>hDA z6pXF42aH*6JK5E?9F56!s`iHEdncmu)RmZgd1qMeJ{eW?n7I0t*rIQx)i0+`EV{eK zmUm%d9m#cOHrn!|^HF(O#N?sRqcR+o@>j>AGOxi-SqFQxa6+B6V|=YG$6@Mq#$v7W zR!qKeB%Avv*dKBCXH;UJM^uSzGaALBX+a^UHV8Ql z^Vh)G9*(JJ*5m!Z1O|@PzmKT>8H+vE`#ieJT3FO7`(q7fI@ZwZkY^h|jwt@Uc{Cy$ z4}|5cX6%o#&tq$Z7*}WYqYM-@i$yb=gv@UglI#5B-LQP(7mhr9A?D&DmV5L*bDcV& zbwH0=N7KXonVi@kwa@jCUp&KS_2A#LTg0NmX5pk;2YVzYue26= zpVe#dTGVRyFKQ9b&k8x)3!9x&Gn<@KGn$<3`AuSBekhLD{QZsI)6QgK=3>%5-SAi- zzc8?*eSE(X@m#-By9Ijs>x>fnBj`M)wG*?scd>Y;cWL{$0DgPJ?@KJuzg&K8V3~Lp zI(dwC3-(V<*M4am2KxhhD0?qe^GNTd?avH+MgGlo(;?+8S!MRT!R6N3L9lt+#%TNL zrNgzQ1|7X#X+H~^9uHux?$9x!r0tHO74`;?O_A44$1?j>?qgSI$gaNHb6BO=uWU8! ze!?ClZ9naQ7xn}GDehbJek`*-$Sl6{cOB6%qCws{mSj9`2Gb_eqr1S`i(JJa{htE`tR3_M#n=tp_9WKJ14ZD|z;#e-!By${z|JRDHpL~&n?*ot!6AsuB`@0wUIo@0DMAKVo>-v2C#|HU0)S5AwX zg?#su5auT;;{&Wl~7eBhE-fq1I@7Hls4(`*@!5Hd_lLY!b#80N2Qhxt%Ir;tD zi0xoU)%2yKLNrcVDRz;!j3GLsgYljoHpHKFQfFPg#XWUU5AKy4_xjOMZoPl+O8f96 zBhJ&YjJqEOUtV}W$$P$^`nK@Bu$+gOh;h-YhvGV^-`{v3;@U7jI=b{2fuV&zGxaoBPc}?N@d3ZnLY(F|)fgbbIb+q`(f*t$C+dPW=G{+%)PWN zy@n3(Voa?wmi8BM`7xa*m~=4iU$iYO*FmliUr4oa=KIWh>O}!~W9Dssbih_9`%51% zvspZ+aswkjFzL{Ipox*pY-IL0{YK9Etw0M_JcH-bRf4rowM@ul-Rf+WY1%cr1LE= z9p8V=kwp9^d@c8bbda80ypy&=+YidVwx^`QkB*!b*7u3q99a66!WOY!=U9GpP|v>e zs)L+S$OC6%lD^;6+b+>jC)W7IU!>)MHS%`kXTL?R2N_E21+E?OMeFU9M6Rv#$u#@1 zZ!CCi;`ZJ@v`YScQAq434L6exKgRbrP)OH&BcCO25i0Ju=HhR%V%)lUXYMt8a-_pHXVPkx?qP;kvC)iS>G)5>eZ$ zMEtl{sXe=Ai9Mu8sl0PoNPgLG%|FFwSu5n%2UUuP`j^=+A$Qq|+Ru69PcdD8Vco;5 zeXtI!>M+Z!7_R3~k9oJ(vi28ymWT&>FOh%yr=Xed=)L5`t%E9^c>_!By#q?y+dz9% z=U#5@#>meV2IlCPs+OI^A$u*gqP>>d7kVvi-_x_$c`UO=e)W%qW8@3+wyer!l8VB=7OpU>}&X67lv2bn&#>qX)4-Q>N zOR4Gk@dJGD@`E)#@}p0&v!rKnd!L(uVR)q&IjlT_X1e4qcn+j$C;$`0B?p>yuu^ z*3-wJ!@1p|KfB%;HKN-7)T|Z8 z^U72VzBm$cHKdzzrTLM@ULEgSEQWRkLw1AgJF;5rV@|B|;?8hDH&XbKRbk1O(9=1D zo*L_*dB_(B0#A0eg*D@~S*;&hH?+OdI8=Ss)Q{r!-Mvd?uZ}Q`tdqAPe_J}LS~$8^ z>dmda^#(t=Vou5$3I}To_qW(eM;cH!S&P0F^KRNHJ!z}DV6U9MrS{^EZ0nfi;k@kS z;$lke$f)^xdE(Y9Ji0L%ImiAp@tpL~=P_097><3v8ZQmYt>~4KA1O8Rg#Ym3(ELzx zRbrpdC=E~a8IUFGGzWgxcs2-wvyq0rR%Gs1IFE(7O7= zh^kjIztdw0>WkWDrNfaH++$a!*9%y~N280bBlgolrioD}GSpP`-nHkTF)d^F5RPu|fF_?BzaH&+yh( zhyyc4rciq#{j4v(gWhjPAqgCCAW%}mP`YZvTs6T@kv2G|iE*664ci%~OW-o|}UI1CI52lPSf zhqhJ8_zJOZ+0{8Dz7VpMQ|G*NcdZ@8RCRh?t9P+r|7lZHt&98s4zFDO>qa4+WGPQU zxP;Oo>sEe)+&Br$?9;Q>X4dqzTxs3VI8M~IpbvN$wHfwbw!r2@Jm2%T)P5iI1)1DVn`!b#k*5$mE;9`OQyZzIT7}8$QSRR&QGipMof~#p>35Z zSK!Ur{uTD%3H9QE33YZ%*TWPJZ=J9Ua;0mKnl9F2Su3M_>q#t>!TFoR*T@y?^8bd~ z__Z-fvPYx&q2#K}iVZHaXWdgT7N{Nx>U+SUY$7qF#o;4YtPAqYi8(f*N5fi~>U$>a z6>$&)KchT(NA=!FYK)kOOX9kbyj>x1pO|CH2l!|@dWnG9F%GH zANakW-6an8puD5;cc5RNhXU8rc37myuEqFP2@jUuroNLiD@T<|&Ha)r_uJ0)^VuBwjxrdH-E1h>H zgMM#q*TmtOq56Ni>5S3eT`V6WQosNzfbj6BRozv$a*gCpYDLoXWyOY9C%Ja z$0cT*=<*|hMeB#s^GdsFYE$^9s*i-;NgPM~?3wB}c7TJtAl_EcW)5=VyX!=Xo-AXhq;MAaq>3)aqiIFy!AJ|Lsf|Gm7 z&&h|`AtCpkGKd0kpSQ=<-uvnKM~K_ZK_9_Bla8%ad}YK|UL4wgC>&KHIC8S zNb3iBv}Z;kewY@NJrIw-r}~oY)pme`d(~N-97K-Go;G`>Q}KJsVLsxX1zS>m9C5I> zO#7w%U^*P6Ur*)Ffdl#ArtIbNn~a~QH90R&Z*-!}D>xOF1TRc}%xM+sjHfqNF!z)( zD9^xdhuvom&vU5EcXjS-{+2Nt<&p7vM=`rsu7Dvry2k!LU4KW9I$Fnb- z1r1*MMt+mC*W24QF{G2L$C0<3h0bzZgwJKYnQLo2KdKwV*XJkNsG0Pgb8FaItvg>Ze=6N;W7rtW|v*XL)#bN{`>;JUIQ>2dJm; zTsiZ7=8grR-SmUnUTGW(i=SN8h}f7K=Mj^3-p>u4xD|4=6X$*vh7=rras?dZ3FA!C z&Yt@MoP(xqHP3?m@K+A(tgUKTyiSKM>$A}v#a9}Kk}1j-uqd9?+FNsLj{5dF3R=Rs z=sC8j9=*{YPbXLCXRC7q-0QQ@1ICrC|L9zXdQN%~qnvrhwcND7aWuu*9RsrXg^3CzHy=_ zzB1&>L7ido*iL#gv)8p}0K*=fk$2B3nEdd{)eOiG`S1X2E9HsyeD#M>c?30|a@4Nq z&$SR$GVgHC2iogdg*NPu$czc(13LRw7Px^sVj zs~8FS-U|$l=7m?Tl+Epez0z`(g2PX)Rtj-%o!FHd6g)<7!(;Y3nK8T7`8m$Kw2>Dn z`~clISFXhQ$w7N@UKe$>tKjm{o4IeboPwBtC-g#LNWpP~a)rH))IMh`-HWefds4d%f@d N%X5F(ft#@d{|}VA?EU}% literal 0 HcmV?d00001 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"] +} From 0516d8014445ffdc56855577d75028129b9502a9 Mon Sep 17 00:00:00 2001 From: ismaelsadeeq Date: Fri, 27 Feb 2026 10:11:47 +0000 Subject: [PATCH 4/5] Docs: Improve Readme.md with project overview and credits Enhanced documentation with: - Project overview and feature highlights. - Clear project structure and usage guide. - Detailed architecture and integration sections. - Comprehensive credits for all contributors. Co-authored-by: Gemini Co-authored-by: Claude --- Readme.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/Readme.md b/Readme.md index 2ffa0c0..27e6d99 100644 --- a/Readme.md +++ b/Readme.md @@ -1,4 +1,79 @@ -Bitcoin Core Feerate API +# Bitcoin Core Fee Tracker & Visualizer -ASAP: https://bitcoincorefeerate.com/fees/2/economical/2 +A comprehensive full-stack application for monitoring and visualizing Bitcoin Core transaction fees, mempool dynamics, and block statistics. This tool provides real-time insights through a modern web interface powered by a robust backend that interfaces directly with a Bitcoin Core node. +## Overview + +This project provides a centralized platform to track and analyze Bitcoin transaction fees. It bridges the gap between raw Bitcoin Core RPC data and human-readable visualizations. + +### Key Features +- **Real-time Fee Estimation**: Get feerate estimates based on current mempool percentiles and historical data. +- **Interactive Charts**: Visualize fee history, block statistics, and mempool status using Recharts and D3. +- **Mempool Diagram**: View the current state of the mempool in a graphical format. +- **Unified API**: A clean REST API that handles multiple data sources and internal caching. + +## Architecture & Integration + +The project is divided into two main modules that are linked through a secure proxy layer: + +- **Backend (Python/Flask)**: Communicates with your Bitcoin Core node via RPC. It handles data collection, persistence in SQLite, and serves as the source of truth for all analytics. +- **Frontend (Next.js/TypeScript)**: Provides the user interface. It communicates with the backend via an internal API route (`frontend/src/app/api/[...path]/route.ts`) which proxies requests to the backend service. This architecture simplifies deployment and enhances security. + +## Project Structure + +```text +. +├── backend/ # Flask API, data collector, and database services +│ ├── src/ # Application logic (services, app.py) +│ └── tests/ # Pytest suite for backend validation +├── frontend/ # Next.js web application +│ ├── src/app/ # App router, pages, and secure API proxy +│ └── src/components/ # Reusable UI components and dynamic charts +└── .github/workflows/ # Automated testing workflow (GitHub Actions) +``` + +## How to Use + +### Prerequisites +- **Bitcoin Core Node**: Access to a running Bitcoin Core node with RPC enabled. +- **Python**: Version 3.12+ +- **Node.js**: Version 22+ + +### 1. Configuration +- **Backend**: Navigate to `backend/`, copy `rpc_config.ini.example` to `rpc_config.ini`, and fill in your node's details. +- **Frontend**: Ensure the `BACKEND_URL` environment variable is set (defaults to `http://127.0.0.1:5001`). + +### 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 (Production-like) +A `restart.sh` script is provided in the root directory to stop any existing instances and start both services in the background using Gunicorn (backend) and Next.js (frontend): +```bash +chmod +x restart.sh +./restart.sh +``` + +## Credits + +This project is a collaborative effort between: +- **Ismael Sadeeq**: Main contributor and maintainer. +- **Gemini**: AI-assisted development, architectural design, and test automation. +- **Claude**: AI-assisted development, code optimization, and documentation. +- **b-l-u-e** ([winnie.gitau282@gmail.com](mailto:winnie.gitau282@gmail.com)): Contributions to core services, backend logic, and test suites. +- **mercie-ux** ([mbaomercy0@gmail.com](mailto:mbaomercy0@gmail.com)): Contributions to user experience, frontend design, and visual components. + +The codebase represents a merge of PR work from contributors and AI-generated improvements for a complete, robust experience. From 5417968eb569852edcd1d31ab85ad7bc3fcb593e Mon Sep 17 00:00:00 2001 From: ismaelsadeeq Date: Fri, 27 Feb 2026 10:20:30 +0000 Subject: [PATCH 5/5] docs: clean up bluffs, sync with code, and upgrade dependencies - Corrected Readme.md, backend/doc.md, and backend/test.md to accurately reflect current codebase. - Fixed collector log (7s vs 1s interval). - Implemented missing 'mempool_health_statistics' in backend to fix frontend gap. - Upgraded backend and frontend dependencies to latest production-ready versions. --- Readme.md | 75 ++++++++++------------- backend/doc.md | 51 +++++---------- backend/requirements.txt | 6 +- backend/src/services/collector_service.py | 2 +- backend/src/services/rpc_service.py | 37 ++++++++++- backend/test.md | 42 ++++--------- frontend/package.json | 10 +-- 7 files changed, 109 insertions(+), 114 deletions(-) diff --git a/Readme.md b/Readme.md index 27e6d99..a706ca1 100644 --- a/Readme.md +++ b/Readme.md @@ -1,49 +1,47 @@ -# Bitcoin Core Fee Tracker & Visualizer +### Bitcoin Core Fee Rate Estimator -A comprehensive full-stack application for monitoring and visualizing Bitcoin Core transaction fees, mempool dynamics, and block statistics. This tool provides real-time insights through a modern web interface powered by a robust backend that interfaces directly with a Bitcoin Core node. +- 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 +### Overview -This project provides a centralized platform to track and analyze Bitcoin transaction fees. It bridges the gap between raw Bitcoin Core RPC data and human-readable visualizations. +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 -- **Real-time Fee Estimation**: Get feerate estimates based on current mempool percentiles and historical data. -- **Interactive Charts**: Visualize fee history, block statistics, and mempool status using Recharts and D3. -- **Mempool Diagram**: View the current state of the mempool in a graphical format. -- **Unified API**: A clean REST API that handles multiple data sources and internal caching. +#### 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 & Integration +#### Architecture -The project is divided into two main modules that are linked through a secure proxy layer: +- **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. -- **Backend (Python/Flask)**: Communicates with your Bitcoin Core node via RPC. It handles data collection, persistence in SQLite, and serves as the source of truth for all analytics. -- **Frontend (Next.js/TypeScript)**: Provides the user interface. It communicates with the backend via an internal API route (`frontend/src/app/api/[...path]/route.ts`) which proxies requests to the backend service. This architecture simplifies deployment and enhances security. - -## Project Structure +#### Project Structure ```text . -├── backend/ # Flask API, data collector, and database services -│ ├── src/ # Application logic (services, app.py) +├── 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, pages, and secure API proxy -│ └── src/components/ # Reusable UI components and dynamic charts -└── .github/workflows/ # Automated testing workflow (GitHub Actions) +│ ├── src/app/ # App router and pages +│ └── src/components/ # D3 and Recharts visualization components +└── .github/workflows/ # Automated testing workflow ``` -## How to Use +#### How to Use -### Prerequisites -- **Bitcoin Core Node**: Access to a running Bitcoin Core node with RPC enabled. -- **Python**: Version 3.12+ -- **Node.js**: Version 22+ +#### Prerequisites +- **Bitcoin Core Node**: Access to a node with RPC enabled (`getblockstats` support required). +- **Python**: 3.12+ +- **Node.js**: 22+ -### 1. Configuration -- **Backend**: Navigate to `backend/`, copy `rpc_config.ini.example` to `rpc_config.ini`, and fill in your node's details. -- **Frontend**: Ensure the `BACKEND_URL` environment variable is set (defaults to `http://127.0.0.1:5001`). +#### 1. Configuration +- **Backend**: Copy `backend/rpc_config.ini.example` to `backend/rpc_config.ini` and provide RPC credentials. -### 2. Manual Startup +#### 2. Manual Startup **Backend:** ```bash cd backend @@ -60,20 +58,15 @@ npm install npm run dev ``` -### 3. Automated Startup (Production-like) -A `restart.sh` script is provided in the root directory to stop any existing instances and start both services in the background using Gunicorn (backend) and Next.js (frontend): +#### 3. Automated Startup +Use the provided `restart.sh` script to launch both services in the background: ```bash chmod +x restart.sh ./restart.sh ``` -## Credits - -This project is a collaborative effort between: -- **Ismael Sadeeq**: Main contributor and maintainer. -- **Gemini**: AI-assisted development, architectural design, and test automation. -- **Claude**: AI-assisted development, code optimization, and documentation. -- **b-l-u-e** ([winnie.gitau282@gmail.com](mailto:winnie.gitau282@gmail.com)): Contributions to core services, backend logic, and test suites. -- **mercie-ux** ([mbaomercy0@gmail.com](mailto:mbaomercy0@gmail.com)): Contributions to user experience, frontend design, and visual components. - -The codebase represents a merge of PR work from contributors and AI-generated improvements for a complete, robust experience. +### 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/backend/doc.md b/backend/doc.md index fe57401..38ebb2c 100644 --- a/backend/doc.md +++ b/backend/doc.md @@ -1,6 +1,6 @@ # Backend - Bitcoin Core Fees API -This service provides a Flask-based REST API to interact with Bitcoin Core RPC and provide fee analytics. +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 @@ -15,19 +15,12 @@ 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 and keep it running after you disconnect from the terminal, use the following `nohup` command: +To start the application in the background: ```bash -nohup .venv/bin/python app.py > debug.log 2>&1 & +nohup env PYTHONPATH=src .venv/bin/gunicorn --workers 4 --bind 127.0.0.1:5001 app:app > debug.log 2>&1 & ``` -**What this command does:** -* `nohup`: Stands for "No Hang Up". It allows the command to continue running even after you logout or close the terminal. -* `.venv/bin/python app.py`: Executes the Flask app using the Python interpreter inside your virtual environment. -* `> debug.log`: Redirects standard output (logs) to a file named `debug.log`. -* `2>&1`: Redirects standard error (errors) to the same location as standard output (`debug.log`). -* `&`: Puts the command in the background, allowing you to continue using the terminal. - ### 3. Monitoring Logs To see the logs in real-time: ```bash @@ -35,35 +28,23 @@ tail -f debug.log ``` ### 4. Stopping the App -To stop the background process, you can find the Process ID (PID) and kill it, or use `pkill`: - -**Option A (Using pkill):** -```bash -pkill -f "python app.py" -``` - -**Option B (By Port):** +To stop the process: ```bash -kill $(lsof -t -i:5001) +pkill -f "gunicorn" ``` -**Option C (Manual):** -1. Find the PID: `ps aux | grep "python app.py"` -2. Kill the process: `kill ` - ## API Endpoints | Endpoint | Method | Description | |----------|--------|-------------| -| `/health` | GET | Check service and RPC connection status. | -| `/blockchain/info` | GET | Get general info about the Bitcoin blockchain. | -| `/blockcount` | GET | Get the current block height. | -| `/mempool/info` | GET | Get current mempool state (size, bytes, etc.). | -| `/fees///` | GET | Get `estimatesmartfee` from Bitcoin Core. | -| `/fees/mempool` | GET | Get fee estimates based on current mempool percentiles. | -| `/api/v1/fees/estimate` | GET | Unified endpoint for mempool, historical, or hybrid estimates. | -| `/analytics/summary` | GET | Get summarized fee and block analytics (internal or external fallback). | -| `/blockstats/` | GET | Get detailed stats for a specific block height. | -| `/external/block-stats/` | GET | Proxy to external API for block statistics. | -| `/external/fees-stats/` | GET | Proxy to external API for fee statistics. | -| `/external/fees-sum/` | GET | Proxy to external API for fee summation analytics. | +| `/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 index 8a0f4cc..64fde51 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,8 @@ Flask==3.1.3 -Flask-CORS==4.0.2 +Flask-CORS==6.0.2 Flask-Limiter==4.1.1 requests==2.32.5 configparser==6.0.0 -gunicorn +gunicorn==25.1.0 +pytest==9.0.2 +pytest-cov==7.0.0 diff --git a/backend/src/services/collector_service.py b/backend/src/services/collector_service.py index 457d396..4f4cd36 100644 --- a/backend/src/services/collector_service.py +++ b/backend/src/services/collector_service.py @@ -8,7 +8,7 @@ _collector_started = False def run_collector(): - logger.info("Starting high-resolution fee estimate collector (1s interval)...") + 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] diff --git a/backend/src/services/rpc_service.py b/backend/src/services/rpc_service.py index 5d909de..0d559b8 100644 --- a/backend/src/services/rpc_service.py +++ b/backend/src/services/rpc_service.py @@ -104,7 +104,7 @@ 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"]]) + result = _rpc_call("getblockstats", [height, ["height", "feerate_percentiles", "minfeerate", "maxfeerate", "total_weight"]]) return json.dumps(result) # freeze as string @@ -121,12 +121,47 @@ 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 diff --git a/backend/test.md b/backend/test.md index 632e0d0..d4617d3 100644 --- a/backend/test.md +++ b/backend/test.md @@ -2,30 +2,23 @@ ## Prerequisites -`requirements.txt` should include: +Ensure all dependencies including test tools are installed: -``` -Flask==3.1.3 -Flask-CORS==4.0.2 -Flask-Limiter==4.1.1 -requests==2.32.3 -configparser==6.0.0 -pytest -pytest-cov +```bash +pip install pytest pytest-cov ``` --- ## Test Structure -``` +```text tests/ -├── conftest.py # pytest path setup -├── helpers.py # shared app factory -├── test_app.py # HTTP layer — routes, error handlers, mode validation -├── test_rpc_service.py # RPC logic, fee math, caching, mempool diagram -├── test_database_service.py # SQLite writes, queries, indexes, edge cases -└── test_collector_service.py # Collector lifecycle, duplicate guard, error resilience +├── 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 ``` --- @@ -34,11 +27,6 @@ tests/ All commands should be run from the `backend/` directory. -**Install all required packages** -``` -pip install -r requirements.txt -``` - **Run the full suite:** ```bash python -m pytest tests/ -v @@ -49,12 +37,11 @@ python -m pytest tests/ -v 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 -python -m pytest tests/test_collector_service.py -v ``` **Run a single test by name:** ```bash -python -m pytest tests/test_rpc_service.py::TestRpcService::test_feerate_conversion_is_correct -v +python -m pytest tests/test_rpc_service.py::test_feerate_conversion_is_correct -v ``` **Stop on first failure:** @@ -68,13 +55,10 @@ python -m pytest tests/ -v -x **Print coverage summary in terminal:** ```bash -python -m pytest tests/ -v --cov=src/services --cov=src/app --cov-report=term-missing +python -m pytest tests/ -v --cov=src --cov-report=term-missing ``` -**Generate an HTML report (opens in browser):** +**Generate an HTML report:** ```bash -python -m pytest tests/ --cov=src/services --cov=src/app --cov-report=html -open htmlcov/index.html +python -m pytest tests/ --cov=src --cov-report=html ``` - - diff --git a/frontend/package.json b/frontend/package.json index 25092da..051b454 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,11 +11,11 @@ }, "dependencies": { "d3": "^7.9.0", - "lucide-react": "^0.544.0", - "next": "15.5.10", - "react": "19.1.5", - "react-dom": "19.1.5", - "recharts": "^3.2.1" + "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",