diff --git a/.env.example b/.env.example index 29b0cad..34d5fe6 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,9 @@ DASH_WORKERS=2 # Ambang batas vote "basi" (hari sejak revote terakhir) untuk penanda staleness VEX_STALE_DAYS=28 + +# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard) +DATABISNIS_API=https://api.databisnis.id + +# Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown +DASH_LIQUID_TTL=60 diff --git a/AGENTS.md b/AGENTS.md index 388bcc8..05df0dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - Node reference script: `node get_voters.js` (no deps, Node 18+). - Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`. - Web dashboard (read-only, reads `voters.db`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans. -- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. +- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. - Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py gunicorn.conf.py test_get_voters.py test_dashboard.py`. - Tests (the verification oracles): `./venv/bin/python test_get_voters.py` and `./venv/bin/python test_dashboard.py` must both exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. @@ -22,6 +22,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6). - Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`. - The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column. +- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB. ## Layout diff --git a/SPEC.md b/SPEC.md index 1543cfd..5916dc1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,6 +10,7 @@ Re-runnable scheduled. Python 3.12, venv `./venv`. - config via env/`.env` (`config.py`, python-dotenv); default = konstanta lama - db: SQLite stdlib `sqlite3` → `DB_PATH` (default `voters.db`); WAL journal mode → pembaca tak terblokir saat snapshot harian - api: `API_NODE` (default `https://v2.vexascan.com:2096`, public, flaky → retry) +- saldo liquid akun BP: API databisnis (`DATABISNIS_API`, default `https://api.databisnis.id`) — diambil dashboard, cache TTL `DASH_LIQUID_TTL` (default 60s), cooldown kegagalan - contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥) — ⊥ env-able - filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP` - `staked` scaled ×10000 → store ÷10000 @@ -29,7 +30,7 @@ cmd: `./venv/bin/python get_voters.py` → stdout summary (id-ID) web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 50/halaman, `ORDER BY staked DESC, owner ASC`; ⊥ mutation (read-only) web: GET `/` + `?q=` → filter owner (server-side, no-JS fallback); pager bawa `q` web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote}]}`, rank global, cap 500 -env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS` — via `config.py` (`.env`) +env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL` — via `config.py` (`.env`) ## §V — Invariants V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry) @@ -46,6 +47,7 @@ V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensit V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config` V13: `last_vote` = `2000-01-01 + log2(last_vote_weight / (staked×10000)) × 365.25 hari` (None bila bobot/stake nol); estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` atau `< now − VEX_STALE_DAYS` dibuang di `normalize` (⊥ disimpan) → DB hanya berisi pemilih segar V15: web list = kolom RANK/AKUN/STAKE (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA, ⊥ penanda/stat/filter basi; stats = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI); `/api/search` hasil tetap `{owner,staked,weight,rank,last_vote}` (weight disimpan & di-API, ⊥ dirender) +V16: saldo liquid akun `TARGET_BP` dari `GET {DATABISNIS_API}/v2/state/get_account` (parse `account.core_liquid_balance`) → sel stat SALDO LIQUID; cache in-memory per worker TTL `DASH_LIQUID_TTL`; gagal fetch → nilai lama (atau `—` bila belum pernah sukses) + `at` ikut diset (cooldown, ⊥ pukulan berulang); read-only, ⊥ tulis DB ## §T — Tasks id|status|task|cites @@ -64,6 +66,7 @@ T12|x|dashboard oracle + search/API/config tests|V11,V10 T13|x|produksi web: gunicorn (gunicorn.conf.py, DASH_WORKERS) + WAL di persist|V12,I.web,I.env T14|x|stale-vote: last_vote di scan (rasio bobot/stake); basi/unverifiable dibuang di normalize (⊥ disimpan)|V13,I.db T15|x|hapus bobot suara + UI basi di web (kolom/stats/penanda/filter `?stale=1`); oracle diperbarui|V15,I.web +T16|x|saldo liquid akun di dashboard: API databisnis + cache TTL + cooldown; sel SALDO LIQUID|V16,I.web,I.env ## §B — Bug log id|date|cause|fix diff --git a/config.py b/config.py index 483d99b..48b897c 100644 --- a/config.py +++ b/config.py @@ -34,3 +34,9 @@ DASH_WORKERS = int(os.getenv('DASH_WORKERS', '2')) # Ambang batas vote "basi" (hari tanpa revote) — pemilih lewat batas ini dibuang di scan VEX_STALE_DAYS = int(os.getenv('VEX_STALE_DAYS', '28')) + +# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard) +DATABISNIS_API = os.getenv('DATABISNIS_API', 'https://api.databisnis.id') + +# Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown +DASH_LIQUID_TTL = int(os.getenv('DASH_LIQUID_TTL', '60')) diff --git a/dashboard.py b/dashboard.py index c40e364..fa13543 100644 --- a/dashboard.py +++ b/dashboard.py @@ -1,21 +1,31 @@ """Dashboard web pemilih BP databisnisid — baca `voters.db`, sajikan spec-list. -Read-only (V10/V11): tidak pernah menulis ke DB. Data disegarkan oleh `get_voters.py` -(run harian / cron). Styling mengikuti `DESIGN.md`. Konfigurasi dari env/`.env`. +Read-only (V10/V11): tidak pernah menulis ke DB. Data pemilih disegarkan oleh +`get_voters.py` (run harian / cron). Saldo liquid akun diambil langsung dari +API databisnis dengan cache TTL singkat (V16). Styling per `DESIGN.md`. +Konfigurasi dari env/`.env`. """ import sqlite3 +import time from datetime import datetime +import requests from flask import Flask, abort, jsonify, render_template, request -from config import DASH_HOST, DASH_PORT, DB_PATH, PAGE_SIZE, TARGET_BP +from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API, + DB_PATH, PAGE_SIZE, TARGET_BP) SEARCH_CAP = 500 MAX_Q_LEN = 64 app = Flask(__name__) +# Cache saldo liquid per worker (in-memory, ⊥ tulis DB). `at` mencatat +# kapan sukses ATAU gagal terakhir → API dihubungi paling banyak sekali +# per TTL (cooldown saat gagal pun ikut mencegah pukulan berulang). +_liquid_cache = {'at': 0.0, 'value': None} + @app.template_filter('vex') def vex(value): @@ -34,6 +44,33 @@ def shortdate(value): return '—' +def _get_liquid_vex(): + """V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan. + + Ambil `account.core_liquid_balance` dari API databisnis. Bila TTL belum + lewat → nilai cache. Bila API gagal → nilai lama tetap dipakai (atau None + bila belum pernah sukses), dan `at` ikut diset → API tak dipukul berulang. + """ + now = time.monotonic() + if _liquid_cache['value'] is not None and now - _liquid_cache['at'] < DASH_LIQUID_TTL: + return _liquid_cache['value'] + value = None + try: + resp = requests.get( + f'{DATABISNIS_API}/v2/state/get_account', + params={'account': TARGET_BP}, + timeout=8, + ) + resp.raise_for_status() + raw = resp.json().get('account', {}).get('core_liquid_balance') + if raw: + value = float(raw.split()[0]) + except (requests.RequestException, KeyError, TypeError, ValueError): + value = _liquid_cache['value'] # gagal → nilai lama bila ada + _liquid_cache.update(at=now, value=value) + return value + + def _escape_like(q): """Escape wildcard LIKE agar `%`/`_` dari pengguna tidak jadi pola.""" return (q.replace('\\', '\\\\') @@ -114,6 +151,7 @@ def index(): abort(400) q = request.args.get('q', '').strip()[:MAX_Q_LEN] total_voters, total_staked, top_staked, scanned = _stats() + liquid_vex = _get_liquid_vex() filtered_count = _count(q) total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE) page = min(page, total_pages) @@ -128,6 +166,7 @@ def index(): total_voters=total_voters, total_staked=total_staked, top_staked=top_staked, + liquid_vex=liquid_vex, scanned=scanned, q=q, filtered_count=filtered_count, diff --git a/static/style.css b/static/style.css index 8a4a67d..04e13ec 100644 --- a/static/style.css +++ b/static/style.css @@ -98,7 +98,7 @@ body { /* ————— stats band ————— */ .stats { display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); margin: 0 var(--sp-lg) var(--sp-xl); @@ -268,6 +268,9 @@ button.pill { .list-header, .list-row { grid-template-columns: 56px 1fr 160px 150px; gap: var(--sp-md); } .spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); } + .stats { grid-template-columns: repeat(2, 1fr); } + .spec-cell:nth-child(3) { border-left: none; } + .spec-cell:nth-child(n+3) { border-top: 1px solid var(--hairline); } } /* ————— mobile (<768px): list → kartu berlabel ————— */ diff --git a/templates/index.html b/templates/index.html index dfb5376..50db966 100644 --- a/templates/index.html +++ b/templates/index.html @@ -36,6 +36,10 @@ {{ top_staked|vex }} STAKE TERTINGGI +
+ {{ liquid_vex|vex if liquid_vex is not none else '—' }} + SALDO LIQUID +