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:
proitlab committed 2026-08-05 10:41:44 +07:00
1 parent eb4f941b37
commit f0fc4715a1
8 files changed
+112 -8

No files matched your search

+6
View File
@@ -25,3 +25,9 @@ DASH_WORKERS=2
# Ambang batas vote "basi" (hari sejak revote terakhir) untuk penanda staleness # Ambang batas vote "basi" (hari sejak revote terakhir) untuk penanda staleness
VEX_STALE_DAYS=28 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
+2 -1
View File
@@ -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+). - 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`. - 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. - 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`. - 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. - 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). - 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`. - 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. - 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 ## Layout
+4 -1
View File
@@ -10,6 +10,7 @@ Re-runnable scheduled. Python 3.12, venv `./venv`.
- config via env/`.env` (`config.py`, python-dotenv); default = konstanta lama - 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 - 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) - 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 - contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥) — ⊥ env-able
- filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP` - filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP`
- `staked` scaled ×10000 → store ÷10000 - `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 `/` (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 `/` + `?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 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 ## §V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry) 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` 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 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) 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 ## §T — Tasks
id|status|task|cites 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 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 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 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 ## §B — Bug log
id|date|cause|fix id|date|cause|fix
+6
View File
@@ -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 # Ambang batas vote "basi" (hari tanpa revote) — pemilih lewat batas ini dibuang di scan
VEX_STALE_DAYS = int(os.getenv('VEX_STALE_DAYS', '28')) 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
View File
@@ -1,21 +1,31 @@
"""Dashboard web pemilih BP databisnisid — baca `voters.db`, sajikan spec-list. """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` Read-only (V10/V11): tidak pernah menulis ke DB. Data pemilih disegarkan oleh
(run harian / cron). Styling mengikuti `DESIGN.md`. Konfigurasi dari env/`.env`. `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 sqlite3
import time
from datetime import datetime from datetime import datetime
import requests
from flask import Flask, abort, jsonify, render_template, request 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 SEARCH_CAP = 500
MAX_Q_LEN = 64 MAX_Q_LEN = 64
app = Flask(__name__) 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') @app.template_filter('vex')
def vex(value): def vex(value):
@@ -34,6 +44,33 @@ def shortdate(value):
return '—' 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): def _escape_like(q):
"""Escape wildcard LIKE agar `%`/`_` dari pengguna tidak jadi pola.""" """Escape wildcard LIKE agar `%`/`_` dari pengguna tidak jadi pola."""
return (q.replace('\\', '\\\\') return (q.replace('\\', '\\\\')
@@ -114,6 +151,7 @@ def index():
abort(400) abort(400)
q = request.args.get('q', '').strip()[:MAX_Q_LEN] q = request.args.get('q', '').strip()[:MAX_Q_LEN]
total_voters, total_staked, top_staked, scanned = _stats() total_voters, total_staked, top_staked, scanned = _stats()
liquid_vex = _get_liquid_vex()
filtered_count = _count(q) filtered_count = _count(q)
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE) total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages) page = min(page, total_pages)
@@ -128,6 +166,7 @@ def index():
total_voters=total_voters, total_voters=total_voters,
total_staked=total_staked, total_staked=total_staked,
top_staked=top_staked, top_staked=top_staked,
liquid_vex=liquid_vex,
scanned=scanned, scanned=scanned,
q=q, q=q,
filtered_count=filtered_count, filtered_count=filtered_count,
+4 -1
View File
@@ -98,7 +98,7 @@ body {
/* ————— stats band ————— */ /* ————— stats band ————— */
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
border-top: 1px solid var(--hairline); border-top: 1px solid var(--hairline);
border-bottom: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline);
margin: 0 var(--sp-lg) var(--sp-xl); margin: 0 var(--sp-lg) var(--sp-xl);
@@ -268,6 +268,9 @@ button.pill {
.list-header, .list-header,
.list-row { grid-template-columns: 56px 1fr 160px 150px; gap: var(--sp-md); } .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); } .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 ————— */ /* ————— mobile (<768px): list → kartu berlabel ————— */
+4
View File
@@ -36,6 +36,10 @@
<span class="cell-value">{{ top_staked|vex }}</span> <span class="cell-value">{{ top_staked|vex }}</span>
<span class="cell-label">STAKE TERTINGGI</span> <span class="cell-label">STAKE TERTINGGI</span>
</div> </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> </section>
<form class="search" method="get" action="{{ url_for('index') }}"> <form class="search" method="get" action="{{ url_for('index') }}">
+44 -2
View File
@@ -10,6 +10,8 @@ import sqlite3
import tempfile import tempfile
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import requests
import dashboard import dashboard
@@ -48,6 +50,8 @@ def main():
make_db(db, rows) make_db(db, rows)
dashboard.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan) 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() client = dashboard.app.test_client()
# V10: judul halaman & kolom list (bobot suara tak ditampilkan, vote segar ada) # 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 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 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 tak ada penanda basi', 'stale-mark' not in html and 'BASI' not in html)
check('V15 stat basi hilang (3 sel stats)', check('V16 4 sel stats (3 pemilih + saldo liquid)',
html.count('spec-cell') == 3) 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 = [] staked_vals = []
for i in range(1, 4): for i in range(1, 4):
@@ -143,6 +149,42 @@ def main():
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db') check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2) check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2)
check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28) 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): def _extract_stakes(text):