T16: saldo liquid akun BP di dashboard (API databisnis + cache TTL)
Sel stat SALDO LIQUID menampilkan account.core_liquid_balance dari
GET {DATABISNIS_API}/v2/state/get_account. Cache in-memory per worker
selama DASH_LIQUID_TTL (60s); gagal fetch → nilai lama (atau '—' bila
belum pernah sukses) + cooldown supaya API tak dipukul berulang. Stats
grid 4 kolom desktop, 2x2 tablet. Oracle + smoke lolos.
This commit is contained in:
1 parent
eb4f941b37
commit
f0fc4715a1
8 files changed
+112
-8
No files matched your search
@@ -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
|
||||
@@ -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=<BP>`. 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
|
||||
|
||||
|
||||
@@ -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=<substring>` → 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
|
||||
|
||||
@@ -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'))
|
||||
+42
-3
@@ -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,
|
||||
|
||||
+4
-1
@@ -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 ————— */
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
<span class="cell-value">{{ top_staked|vex }}</span>
|
||||
<span class="cell-label">STAKE TERTINGGI</span>
|
||||
</div>
|
||||
<div class="spec-cell">
|
||||
<span class="cell-value">{{ liquid_vex|vex if liquid_vex is not none else '—' }}</span>
|
||||
<span class="cell-label">SALDO LIQUID</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="search" method="get" action="{{ url_for('index') }}">
|
||||
|
||||
+44
-2
@@ -10,6 +10,8 @@ import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import requests
|
||||
|
||||
import dashboard
|
||||
|
||||
|
||||
@@ -48,6 +50,8 @@ def main():
|
||||
make_db(db, rows)
|
||||
|
||||
dashboard.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan)
|
||||
real_get_liquid = dashboard._get_liquid_vex
|
||||
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
|
||||
client = dashboard.app.test_client()
|
||||
|
||||
# V10: judul halaman & kolom list (bobot suara tak ditampilkan, vote segar ada)
|
||||
@@ -59,8 +63,10 @@ def main():
|
||||
check('V15 kolom BOBOT SUARA tak tampil', 'BOBOT SUARA' not in html)
|
||||
check('V15 tak ada sel .weight', 'class="weight"' not in html)
|
||||
check('V15 tak ada penanda basi', 'stale-mark' not in html and 'BASI' not in html)
|
||||
check('V15 stat basi hilang (3 sel stats)',
|
||||
html.count('spec-cell') == 3)
|
||||
check('V16 4 sel stats (3 pemilih + saldo liquid)',
|
||||
html.count('spec-cell') == 4)
|
||||
check('V16 sel SALDO LIQUID', 'SALDO LIQUID' in html)
|
||||
check('V16 nilai saldo diformat id-ID', '50.691,4676' in html)
|
||||
|
||||
staked_vals = []
|
||||
for i in range(1, 4):
|
||||
@@ -143,6 +149,42 @@ def main():
|
||||
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
|
||||
check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2)
|
||||
check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28)
|
||||
check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id')
|
||||
check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60)
|
||||
|
||||
# ————— V16: saldo liquid — cache TTL + cooldown kegagalan —————
|
||||
dashboard._get_liquid_vex = real_get_liquid
|
||||
dashboard._liquid_cache.update(at=0.0, value=None)
|
||||
calls = {'n': 0}
|
||||
|
||||
def fake_ok(url, params=None, timeout=None):
|
||||
calls['n'] += 1
|
||||
return type('R', (), {
|
||||
'ok': True,
|
||||
'raise_for_status': lambda self: None,
|
||||
'json': lambda self: {'account': {'core_liquid_balance': '12345.6789 VEX'}},
|
||||
})()
|
||||
|
||||
def fake_fail(url, params=None, timeout=None):
|
||||
calls['n'] += 1
|
||||
raise requests.exceptions.ConnectionError('api down')
|
||||
|
||||
dashboard.requests.get = fake_ok
|
||||
check('V16 fetch saldo → float', dashboard._get_liquid_vex() == 12345.6789)
|
||||
check('V16 TTL belum lewat → 1 fetch', calls['n'] == 1)
|
||||
check('V16 panggilan kedua dari cache',
|
||||
dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 1)
|
||||
|
||||
dashboard._liquid_cache.update(at=0.0, value=12345.6789) # cache kedaluwarsa
|
||||
dashboard.requests.get = fake_fail
|
||||
check('V16 gagal → nilai lama tetap',
|
||||
dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 2)
|
||||
check('V16 cooldown → API tak dipukul lagi',
|
||||
dashboard._get_liquid_vex() == 12345.6789 and calls['n'] == 2)
|
||||
|
||||
dashboard._liquid_cache.update(at=0.0, value=None)
|
||||
check('V16 gagal tanpa cache → None',
|
||||
dashboard._get_liquid_vex() is None and calls['n'] == 3)
|
||||
|
||||
|
||||
def _extract_stakes(text):
|
||||
|
||||
Reference in new issue
Block a user