50 KiB
SPEC — databisnisid voter fetcher (Python)
§G — Goal
Scan Vexanium voters table → persist databisnisid voters + weight in SQLite.
Re-runnable scheduled. Python 3.12, venv ./venv.
Profit-share: tiap hari 10:00 lokal → kirim saldo liquid VEX akun BP ke pemilih segar (pro-rata stake), catat tiap transfer (riwayat per-pemilih) + view riwayat dashboard.
Auto-claim: tiap hari di jendela klaim (last_claim_time + 24 jam) → vexcore::claimrewards utk TARGET_BP (poll tiap menit sampai diterima chain) → kirim 10% reward ke fee wallet bpdbsjasprod; reward mengisi saldo yang dibagi distribute.
§C — Constraints
- venv
./venvexists (py3.12, only pip) — install deps there, ⊥ global - lib:
requests,flask,python-dotenv,gunicorn,pyntelope! pip-installed;pymysqlutk backend mysql (opt-in); others stdlib - config via env/
.env(config.py, python-dotenv); default = konstanta lama - db:
db.pyabstraksi backend —sqlite(default, stdlibsqlite3→DB_PATH, WAL → pembaca tak terblokir) ataumysql(MariaDB/MySQL via PyMySQL,VEX_DB_*); container uji viadocker-compose.dev.yml - api:
API_NODE(defaulthttps://v2.vexascan.com:2096, public, flaky → retry) - saldo liquid akun BP: API databisnis (
DATABISNIS_API, defaulthttps://api.databisnis.id) — diambil dashboard, cache TTLDASH_LIQUID_TTL(default 60s), cooldown kegagalan - contract=scope=
vexcore(≠vexio, EOS convention ⊥) — ⊥ env-able - filter: ∃ voter where
producers.length==1 & producers[0]==TARGET_BP stakedscaled ×10000 → store ÷10000- weight =
last_vote_weight(string, high-precision decimal) — keep verbatim - staked ≥
MIN_STAKED_VEX(default 1000 VEX) ! required (÷10000 dulu) last_vote(perkiraan waktu revote) diturunkan saat scan dari rasiolast_vote_weight/staked_raw(V13);VEX_STALE_DAYSdefault 28- voter basi (revote >
VEX_STALE_DAYS) ataulast_votetak diketahui → dibuang dinormalize(⊥ disimpan); hanya pemilih segar yang masuk DB - output/comments: Indonesian
- run harian → ganti snapshot (DROP+INSERT tiap skan; ⊥ history/append)
- produksi stack: Docker Swarm multi-node (node DB
server5.saltis.id) — image dari registrygit.proit.id/proitlab/databisnisid-web&-scan&-dist(⊥build:di stack), deploydocker stack deploy -c docker-compose.yml databisnisid; mariadb bind mount/data/db/mariadb/databisnisid/datapd nodeserver5.saltis.id(placement constraintnode.hostname == server5.saltis.id), web/scan/distribute di node mana pun via overlayappnet; scan berjalan pd jamSCAN_HOURS(default 0,8,16, waktu lokalTZdefault Asia/Jakarta) + skan-awalSCAN_RUN_ON_START; distribute berjalan pd jamDISTRIBUTE_HOUR(default 10) viadistribute_loop.py(image-dist), key aktif BP dibaca dari bind NFS/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro(⊥ interpolasi) - token VEX = kontrak
vex.token(⊥eosio.token— tak ada di chain; konstanta chain, ⊥ env-able), precision 4 desimal, issuervexcore - chain_id =
f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f(nodeos v1.2.2) — utk signing - saldo distribusi: chain
get_currency_balancepdvex.tokenutkTARGET_BP(⊥ Hyperion; = SALDO LIQUID dashboard) - signing:
pyntelope(Python; eospy→eospyo→pyntelope); key aktif BPVEX_BP_PRIVATE_KEY— ⊥ repo, ⊥ log; di stack dibaca dari/app/.envhasil bind NFSconfig.env(load_dotenv), di dev via env/.envlokal - distribusi: harian
DISTRIBUTE_HOUR(default 10) lokalTZ→ SELURUH saldo liquid pro-ratastakedke pemilih segar tersimpan (snapshot terkini = scan 08:00) - share = floor(balance × stake ÷ total_stake), 4 desimal; sisa (dust) TETAP di akun (⊥ drain penuh)
- memo per run, sama utk semua transfer:
DATABISNISID PROFIT SHARE YYYY-MM-DD - riwayat: tiap transfer dicatat per-pemilih (
distribute_payments: pending→sent/failed) + ringkasan per run (distribute_runs); append ⊥ hapus - retry dalam hari: ulang hanya baris
failed; ⊥ kirim ulangsent; sebelum resend → verifikasi txid on-chain (Hyperionget_transaction→executed) - ⊥ freezing/carryover: tiap hari hitung ulang segar dari snapshot tersimpan; kegagalan sisa → ikut run besok
- pemilih kosong | saldo < 1 unit shareable → no-op senyap (⊥ catat)
- kill-switch distribusi:
DISTRIBUTE_ENABLEDdefault FALSE — payout ⊥ jalan tanpa disettrue(V35);--dry-runtetap boleh (read-only) - notifikasi Telegram dua kanal (V30/V36): bot token tunggal
TELEGRAM_BOT_TOKEN; KOMUNITASTELEGRAM_COMMUNITY_CHAT_IDShanya brief mulai/selesai distribusi; INTERNALTELEGRAM_INTERNAL_CHAT_IDSdetail distribusi + semua kegagalan + klaim reward (⊥ komunitas); best-effort (⊥ crash); tanpa token/daftar → kanal senyap; di stack nilai dari NFSconfig.env(/app/.env) - dashboard read-only + view riwayat
/history(runs + per-voter log) - CPU/NET akun BP besar (limit 41.5M µs, NET 3.26M words) → ratusan transfer/hari sepele; Vexanium tanpa fee
- klaim reward BP: aksi
vexcore::claimrewards(owner=TARGET_BP), sign pyntelope key aktifVEX_BP_PRIVATE_KEY; 1 klaim per 24 jam — jendela =last_claim_time(tabelproducers) + 24 jam; klaim di luar jendela ditolak chain - reward diukur = selisih saldo liquid (
get_currency_balance) setelah − sebelum klaim (settle delay); fee = floor(VEX_BP_FEE_PERCENT× reward), 4 desimal (ROUND_FLOOR), →VEX_BP_FEE_WALLET(defaultbpdbsjasprod), memoBP FEE YYYY-MM-DD; reward 0 → fee skipped - service
claimstack (image-claim):claim_loop.pytidur sampai mendekati jendela (margin), lalu pollclaimrewardstiapCLAIM_RETRY_SECONDS(default 60) sampai diterima chain; ⊥ spam 1440 tx gagal/hari - siklus klaim selesai ⊥ bila klaim tercatat di
claim_runsDAN fee terkirim; fee pending/failed diulang tiap siklus + dilanjutkan saat restart (crash-safe, ⊥ fee hilang); setelah klaim sukses jendela dihitung ulang darilast_claim_timesegar - ⊥ mutex dgn distribute: distribute beku saldo di awal run (V20) → klaim yang jatuh di tengah run didefer ke run berikutnya
- klaim butuh
VEX_BP_PRIVATE_KEY+DATABISNIS_API(verifikasi txid V22); Telegram notif klaim sukses/gagal (best-effort V30)
§I — Interfaces
api: POST https://v2.vexascan.com:2096/v1/chain/get_table_rows
→ body {json:true, code:vexcore, scope:vexcore, table:voters, lower_bound, limit:500}
→ rows[owner, proxy, producers[], staked, last_vote_weight, proxied_vote_weight, is_proxy], more, next_key
db: db.py → table voters (owner PK, weight, staked, scanned_at, last_vote) via connect/query/queryone/replace_snapshot; sqlite = DROP+CREATE+INSERT (WAL), mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi; ganti tiap skan; weight disimpan (audit/asal last_vote) tapi ⊥ ditampilkan web
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,total_reward}]}, rank global, cap 500
env: VEX_TARGET_BP, VEX_API_NODE, VEX_DB_PATH, VEX_DB_BACKEND, VEX_DB_HOST, VEX_DB_PORT, VEX_DB_USER, VEX_DB_PASS, VEX_DB_NAME, VEX_MIN_STAKED_VEX, DASH_PAGE_SIZE, DASH_HOST, DASH_PORT, DASH_WORKERS, VEX_STALE_DAYS, DATABISNIS_API, DASH_LIQUID_TTL, SCAN_HOURS, SCAN_RUN_ON_START, TZ, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY, DISTRIBUTE_FIRST_RUN (dashboard banner V38; web service di stack menerima TZ + jadwal) — via config.py (.env)
api: POST https://v2.vexascan.com:2096/v1/chain/get_currency_balance → body {code:vex.token, account, symbol:VEX} → ["X.XXXX VEX"]
api: POST https://v2.vexascan.com:2096/v1/chain/get_currency_stats → {supply, max_supply, issuer} (cek precision)
api: POST https://v2.vexascan.com:2096/v1/chain/push_transaction (signed via pyntelope) → txid
api: POST https://v2.vexascan.com:2096/v1/chain/get_info → chain_id (utk signing)
api: GET https://api.databisnis.id/v2/history/get_transaction?id=<txid> (Hyperion) → {executed, trx_id, lib} — guard resend
db: table distribute_runs (run_id PK, run_date, balance_start, total_voters, total_staked, total_sent, status, created_at) via db.py; append
db: table distribute_payments (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via db.py; append; ⊖ kolom memo (V23 hanya on-chain)
cmd: ./venv/bin/python distribute.py → stdout id-ID (rencana + hasil); --dry-run → rencana tanpa sign
web: GET /history → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile
env: VEX_BP_PRIVATE_KEY (stack: bind NFS /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env → /app/.env dibaca load_dotenv, :ro; dev: env/.env lokal), DISTRIBUTE_HOUR (default 10), DISTRIBUTE_WEEKDAY (default sat), DISTRIBUTE_FIRST_RUN (YYYY-MM-DD, default baked 2026-08-17; env override), DISTRIBUTE_MAX_ATTEMPTS, DISTRIBUTE_ENABLED (default false), TELEGRAM_BOT_TOKEN, TELEGRAM_COMMUNITY_CHAT_IDS, TELEGRAM_INTERNAL_CHAT_IDS (CSV) — via config.py (.env)
cmd: ./venv/bin/python telegram.py — modul notifikasi (⊥ CLI); TELEGRAM_BOT_TOKEN + daftar chat kosong → kanal no-op senyap; send_text(text, chat_ids=None) = komunitas, send_internal(text) = internal
db: table claim_runs (claim_id PK, run_date, claim_txid, reward, fee_amount, fee_status[pending|sent|failed|skipped], fee_txid, claimed_at, fee_sent_at, created_at) via db.py; append; ⊖ kolom memo (memo fee on-chain saja, V23 pola)
api: POST https://v2.vexascan.com:2096/v1/chain/get_table_rows → body {json:true, code:vexcore, scope:vexcore, table:producers, limit:1, lower_bound, upper_bound} → rows[owner, last_claim_time, unpaid_blocks] — sumber jendela klaim
api: aksi vexcore::claimrewards (owner name) sign pyntelope → push_transaction → txid; verifikasi via Hyperion get_transaction (V22) sebelum resend
cmd: ./venv/bin/python claim_loop.py → scheduler klaim harian (image databisnisid-claim, service claim); claim.py modul logika klaim (⊥ CLI)
env: CLAIM_RETRY_SECONDS, VEX_BP_FEE_WALLET, VEX_BP_FEE_PERCENT — via config.py (.env); key/Telegram dari bind NFS config.env di stack (sama dgn distribute)
cmd: ./venv/bin/python test_claim.py — oracle V32..V34 (mock chain+pyntelope, temp DB)
cmd: ./venv/bin/python test_distribute_loop.py — oracle V37 (logika kalender mingguan + one-off, ⊥ jaringan/DB)
§V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
V2: page loop terminates: advance lower_bound → last owner; stop on more=false | empty rows
V3: ∀ page after first → shift 1st row (dup guard)
V4: skip owner="...........q" (uint64 0 sentinel) rows
V5: persist ∃ row where producers length 1 & [0]==TARGET_BP
V6: weight stored string verbatim, ⊥ float conversion
V7: each scan replaces table; ∀ row → latest snapshot; ⊥ append/duplicate owner; V41: scan juga record_first_seen tiap owner ke tabel voter_first_seen (INSERT OR IGNORE/IGNORE, append-only, PK owner) — first_seen_at = kemunculan pertama, ⊥ overwrite; voters tetap snapshot murni
V8: normalize coerces staked → float (node kadang memberi string)
V9: normalize drops voters where staked < 1000 VEX (setelah ÷10000)
V10: dashboard baca voters.db → rows sort staked DESC (terbesar dulu); ⊥ mutasi DB
V11: search q → filter owner substring (LIKE, wildcard-escape, case-insensitive) di SEMUA band tampil (V40); urut+paging terjaga; ?q= (daftar datar ber-tag) & /api/search read-only; tiap hasil membawa group (baru|valid|kadaluarsa) + expired; rank = ordinal DALAM kelompok (⊥ rank global lintas band, V40)
V12: produksi web disajikan gunicorn (dashboard:app, gunicorn.conf.py); DASH_WORKERS default 2; .env ikut termuat via import config
V13: last_vote = epoch 2000-01-01 + round(52 × log2(last_vote_weight / (staked×10000))) PEKAN (Vexanium/EOSIO stake2vote: eksponen = int64((now − epoch)/(86400×7)) / 52.0 → bobot dikuantisasi pekan utuh; jadi tanggal kelipatan 7 hari, 00:00:00; regresi oracle baris nyata ..tg 1291 pekan → 2024-09-28, 1.crownz 1314 pekan → 2025-03-08); None bila bobot/stake nol; estimasi yang melewati now (unstake tanpa revote) diklamp ke now; voter dgn last_vote=None dibuang di normalize (bobot tak bisa diverifikasi → tanggal tak diketahui, ⊥ bisa diklasifikasi); V40: vote basi (> VEX_STALE_DAYS) TIDAK lagi dibuang di scan — SEMUA umur disimpan; banding/tampil/bayar menghitung umur saat query (⊥ peran scan)
V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA; stats = SALDO LIQUID/TOTAL PEMILIH/STAKE TERTINGGI atas semua baris TAMPIL (V40: ⊖ yang tersembunyi > 31 hari); TOTAL VEX dihapus (V48: raw single-vote stake mudah disalahartikan); V40/V42/V43: BARU (PEMILIH BARU · BELUM MATANG, new saja) + daftar utama — kadaluarsa menyatu DI ATAS baris VALID (dipin, sorot amber + badge VOTE ULANG + legenda BARIS AMBER = VOTE LEWAT …), lalu VALID (pager) = matang-baru + REVOTE (tag mint REVOTE + legenda TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG, V43) + badge amber dashed VOTE ULANG SEGERA (V43) pada baris exp_cut < last_vote ≤ warn_cut + legenda TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH; REVOTE ⊖ lagi bagian sendiri (V42); /api/search hasil {owner,staked,weight,rank,last_vote,total_reward,group,expired,expiring} — group = baru|revote|valid|kadaluarsa (weight disimpan & di-API, ⊥ dirender); V47: catatan stats-note menyatakan TOTAL PEMILIH = akun suara tunggal ke BP stake ≥ MIN_STAKED_VEX; V51: nama akun (index + history pay-list + search live) jadi link https://vexascan.com/account/{owner} (.owner-link, target=_blank rel=noopener) — display-only
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
V17: penyimpanan lewat db.py (backend VEX_DB_BACKEND = sqlite default | mysql); query berbagi sintaks pakai placeholder %s (diterjemahkan ? utk sqlite); LIKE escape pakai ESCAPE '!' — backslash memutus literal string MySQL (B2); db.query → list (fetchall sqlite=list / PyMySQL=tuple diseragamkan, B3); replace snapshot mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi (MVCC → pembaca dapat snapshot konsisten saat ganti harian, tak kena torn read); oracle utama (test_get_voters/test_dashboard) tetap sqlite hermetik; test_mariadb.py opt-in (skip exit 0 bila VEX_DB_BACKEND != mysql)
V18: stack produksi = Docker Swarm (docker-compose.yml, docker stack deploy -c docker-compose.yml databisnisid): mariadb bind mount /data/db/mariadb/databisnisid/data pd node server5.saltis.id + placement constraint node.hostname == server5.saltis.id (data node-lokal → mariadb harus selalu di node itu; di swarm yg ⊥ punya node itu task-nya Pending, bukan bug); web (gunicorn, publik :5000) + scan (scan_loop.py) + distribute (distribute_loop.py, image databisnisid-dist) di node mana pun, terhubung via overlay appnet; image dari registry git.proit.id/proitlab/databisnisid-web & -scan & -dist (⊥ build:); scan berjalan pd jam SCAN_HOURS (default 0,8,16, lokal TZ default Asia/Jakarta via datetime.now()) + skan-awal SCAN_RUN_ON_START (setelah DB siap) → tabel voters langsung ada (dashboard ⊥ 500); distribute loop harian DISTRIBUTE_HOUR (default 10, lokal) — ⊥ distribusi-awal saat start (snapshot bisa basi); key VEX_BP_PRIVATE_KEY dari bind /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro dibaca config.py load_dotenv (⊥ interpolasi env compose); loop tak pernah keluar; stack deploy ⊥ env_file (env inline via ${VAR} interpolasi .env); image web set DASH_HOST=0.0.0.0 agar ingress menjangkau; healthcheck web = cek socket TCP (⊥ ketergantungan isi DB)
V19: target transfer = kontrak vex.token (konstanta chain hardcoded; ⊥ diubah ke eosio.token)
V20: saldo distribusi dari chain get_currency_balance pd vex.token; share = floor(balance×stake÷total_stake) 4 desimal; Σshare ≤ balance; sisa (dust) tetap di akun
V21: ∀ transfer → dicatat di distribute_payments sbg pending SEBELUM kirim; status → sent|failed dr hasil tx; ⊥ hapus baris (append)
V22: retry → hanya baris failed; baris sent ⊥ dikirim ulang; sebelum resend, verifikasi txid via GET {DATABISNIS_API}/v2/history/get_transaction → executed → tandai sent (⊥ duplikat)
V23: memo per run = DATABISNISID PROFIT SHARE YYYY-MM-DD — sama utk seluruh transfer run, hanya on-chain (⊖ disimpan DB)
V24: run harian DISTRIBUTE_HOUR (default 10) waktu lokal TZ; ⊥ beku/carryover — hitung ulang tiap hari dari snapshot tersimpan
V25: pemilih kosong | saldo < 1 unit shareable → no-op senyap, ⊥ tulis DB
V26: VEX_BP_PRIVATE_KEY ⊥ pernah di-log/print; --dry-run utk uji lokal (⊥ sign)
V27: dashboard read-only; /history baca distribute_* via db.py; ⊥ mutasi
V28: web list desktop → kolom AKUN/STAKE/REWARD/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE/REWARD/VOTE TERAKHIR center; tablet → RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; TXID = link explorer vexascan.com/transaction/<txid>; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML no-store + stylesheet versioned
V29: kolom TOTAL REWARD (VEX) per pemilih = Σ amount di distribute_payments status=sent (semua run, sepanjang masa); voter tanpa transfer → 0,0000; failed/pending ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); /api/search menyertakan total_reward; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker)
V30: notifikasi Telegram dua kanal (best-effort): notify_start saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), notify_finish setelah run tutup (status ok/partial), notify_failure saat exception di main (lalu re-raise); KOMUNITAS (TELEGRAM_COMMUNITY_CHAT_IDS) hanya brief mulai/selesai (tanggal + status, ⊥ detail); INTERNAL (TELEGRAM_INTERNAL_CHAT_IDS) detail (pemilih/total share/saldo/terkirim/gagal) + semua kegagalan; no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa TELEGRAM_BOT_TOKEN/daftar → kanal senyap; gagal kirim → log warning, ⊥ pernah crash distribusi
V31: ∀ image stack (web/scan/dist/claim) → tiap modul .py yang diimpor modul yang di-COPY ikut disalin (⊥ impor intra-proyek yang ⊥ ada di image); oracle test_images.py memindai import tiap image → daftar COPY memuat semua impor intra-proyek
V32: klaim reward BP via aksi vexcore::claimrewards (owner=TARGET_BP), sign pyntelope VEX_BP_PRIVATE_KEY; jendela klaim = last_claim_time (tabel producers) + 24 jam; claim_loop.py tidur sampai mendekati jendela lalu poll tiap CLAIM_RETRY_SECONDS (default 60) sampai diterima chain; ⊥ klaim di luar jendela (ditolak chain → retry); setelah sukses jendela dihitung ulang dari last_claim_time segar; RECOVERY: bila ≥24 jam sudah lewat sejak last → jendela = now (klaim dikejar segera, ⊥ spin-timeout utk jendela yang terlewat / container down); loop tak pernah keluar
V39: respons chain get_table_rows SELALU dict {rows:[...]} (⊥ list); semua parser memakai data.get('rows'); oracle memakai bentuk respons asli (mock _post_json, ⊥ mock fungsi parse) agar bentuk tak dikenal/tak terduga tertangkap; get_currency_balance memang list string — jangan 'diperbaiki'
V40: band umur vote (dashboard + distribusi, UTC-naive konsisten stempel scan): age = now − last_vote; BARU → bagian sendiri (paling atas), ⊖ reward — V52: BARU = akun baru (first_seen_at None/> now − MATURITY) dgn last_vote > now − STALE (maturity diukur dari first_seen_at, ⊖ last_vote terkuantisasi pekan); VALID last_vote > now−STALE DAN akun matang (first_seen_at ≤ now−MATURITY) → bagian utama + pager, satu-satunya penerima reward; KADALUARSA STALE < age ≤ STALE + VEX_EXPIRED_DAYS (31) → baris menyatu di daftar utama, DIPIN di atas baris VALID, sorot amber + badge VOTE ULANG + legenda; HIDDEN age > 31 → disimpan scan tapi ⊖ tampil; scan menyimpan semua umur (V13); stats/count/rank/search dibatasi band yang tampil (rank ordinal per band; jendela VALID utk pager); distribusi query last_vote > now−STALE AND first_seen_at ≤ now−MATURITY (V52) — pemilih BARU (first_seen baru/NULL) ⊥ dibayar siklus itu (anti-gaming); akun yang sudah terlihat ≥ VEX_MATURITY_DAYS lalu (REVOTE) dibayar langsung, ⊖ menunggu matang
V41: pembagian pemilih belum-matang menjadi PEMILIH BARU (new) vs REVOTE (pemilih lama yang revote/perpanjang): first_seen_at (tabel voter_first_seen, V7) dibandingkan dgn new_cut = now − MATURITY — first_seen > new_cut (atau NULL/DB lama) → BARU; first_seen ≤ new_cut → REVOTE; klasifikasi tampilan/search (V15); cold-start: skan pertama mencatat semua owner sbg first-seen-now → semua BARU tampak new satu siklus, klasifikasi benar mulai siklus revote berikutnya
V42: REVOTE dianggap VALID LANGSUNG (dashboard + distribusi, V41): pemilih dgn first_seen_at ≤ now − MATURITY ⊖ perlu masa matang — memenuhi syarat reward begitu last_vote > now − STALE; hanya pemilih BARU (first_seen NULL atau > now − MATURITY) yang harus lewat masa matang (V52: dari first_seen_at, bukan last_vote); jendela VALID dashboard (_db_rows/_count/rank) = jendela eligibilitas distribusi = last_vote > now−STALE AND first_seen_at ≤ now−MATURITY — konsisten; baris REVOTE menyatu di daftar utama dgn tag mint REVOTE (⊥ bagian sendiri, V15); _stats partisi n_new + n_valid + n_expired == total_voters (n_revote yang dirender = subset tag 1-hari); distribusi LEFT JOIN voter_first_seen (ensure_first_seen_schema); oracle test_distribute (rev7 umur-1hr first_seen-60hr → dibayar; new1 umur-1hr → ⊖) + test_dashboard (revoter1 di #rows bertag)
V43: tag & badge display (display-only, ⊥ ubah eligibilitas/payout V40/V42): tag mint REVOTE HANYA utk pemilih lama yang revote dalam VEX_REVOTE_TAG_DAYS (default 1) terakhir (last_vote > revote_cut = now − VEX_REVOTE_TAG_DAYS AND first_seen ≤ now − MATURITY) — setelah itu tampil VALID polos (tetap dibayar langsung); badge amber VOTE ULANG SEGERA (outline dashed, elemen ke-9) utk baris VALID dgn expired_cut < last_vote ≤ warn_cut = now − (VEX_STALE_DAYS − VEX_WARN_DAYS) (default 3 → umur 25..28 hr, overlay band VALID, ⊖ pin/tinting); _freshness_cutoffs → 4-tuple (new_cut, warn_cut, exp_cut, hid_cut); _search_rows tag revote HANYA bila last_vote > revote_cut (revoter lebih lama → valid), + field expiring (main list, ?q=, /api/search); legenda mint TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG · N AKUN + legenda amber TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH; _stats 9-tuple (+n_expiring, n_revote = tag-1-hari utk legenda); oracle test_dashboard (revoter1 _iso(0) tetap bertag; expire1 _iso(26) staked-1290 → valid + expiring; TOTAL 129; config defaults)
V33: reward klaim diukur PRIORITAS dari isi tx via Hyperion get_transaction — jumlah transfer vex.bpay + vex.vpay → TARGET_BP (reward_from_tx; ⊥ selisih saldo yang flaky/stale saat node baca saldo di belakang); fallback selisih saldo (setelah − sebelum, settle delay, retry baca) bila Hyperion tak terjangkau; reward ⊥ terukur keduanya → baris dicatat fee pending + notif internal KLAIM MENDARAT · REWARD TAK TERUKUR (⊥ sukses-0 menyesatkan, V45); fee = floor(BP_FEE_PERCENT × reward) 4 desimal → VEX_BP_FEE_WALLET (default bpdbsjasprod), memo BP FEE YYYY-MM-DD, verifikasi txid Hyperion sebelum resend (pola V22); reward ≤ 0 → fee skipped; Σfee ≤ reward
V34: siklus klaim selesai ⊥ bila klaim tercatat (claim_runs) DAN fee terkirim; fee pending/failed diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi
V45: anti false-success klaim: klaim dianggap "mendarat" ⊥ cukup karena last_claim_time dalam 24 jam terakhir (heuristik lama — klaim yang DITOLAK sebelum jendela bisa salah dianggap sukses bila ada klaim lama <24 jam); deteksi = Hyperion executed ATAU last_claim_time benar-benar MAJU dari nilai sebelum kirim (_claim_time_advanced, after > before; baseline tak terbaca → ⊥ dianggap maju, retry); next_window baca last_claim_time di-retry (node flaky ⊥ langsung dianggap "belum pernah klaim" → clamp now prematur → percobaan ditolak); reward tak terukur → fee pending + notif cek manual, ⊥ skipped diam-diam (V33); oracle test_claim (last maju vs tak maju, reward_from_tx parse bpay+vpay, unmeasured → pending+notif)
V46: ⊥ pernah percaya signed.send() semata utk deteksi mendarat — pyntelope TIDAK raise saat chain menolak (Net._request mengembalikan body HTTP 500 sbg JSON, ⊥ exception), jadi "sukses kirim" bisa berarti DITOLAK (mis. claimrewards sebelum jendela 24 jam) → false success reward 0. Landing dikonfirmasi di KEDUA jalur (send sukses maupun exception) lewat _confirm_landed = Hyperion executed ATAU last_claim_time maju dari baseline; tak terkonfirmasi → retry senyap (⊥ catat claim_runs, ⊥ notif sukses) — klaim sah yang belum terindeks Hyperion → retry 60s berikutnya terverifikasi (self-healing dalam 2 jam poll). next_window: semua pembacaan producer gagal → raise (loop catch-and-sleep), ⊥ clamp now prematur; now hanya utk pembacaan sukses tanpa klaim sebelumnya (BP baru). Oracle test_claim: send-normal+verify-False+last-maju → claimed (V50); send-normal+verify-False+last-tak-maju → retry + ⊥ baris + ⊥ notif; baca-producer-gagal → raise
V49: math jendela klaim memakai _utcnow() (UTC-naive, datetime.now(timezone.utc).replace(tzinfo=None)) — konsisten dgn last_claim_time chain (UTC) & get_voters/dashboard; step()/poll_claim/_try_claim_once memakai _utcnow() (⊥ datetime.now() lokal yang bergeser +TZ di container → jendela maju ~7 jam → poll prematur → ditolak chain → retry tanpa henti, B13); oracle test_claim (_utcnow tzinfo None; step before window → sleep)
V50: _confirm_landed ⊥ langsung menyerah saat Hyperion executed: false (verify_txid → False): tx yang BARU SAJA mendarat bisa belum terindeks Hyperion (lag, response executed: false utk id yg belum diindeks) — menganggap False sbg "tak mendarat" membuat klaim sukses masuk retry-loop permanen (B14). Landing = Hyperion executed ATAU last_claim_time maju dari baseline; utk verified False MAUPUN None tetap cek _claim_time_advanced; hanya bila itu juga False → retry (klaim ditolak ⊥ memajukan last_claim_time). Oracle test_claim: send-normal+verify-False+last-maju → claimed + reward tercatat (V50); send-normal+verify-False+last-tak-maju → retry + ⊥ baris (V46)
V52: maturity pemilih baru diukur dari first_seen_at (stempel scan presisi detik, tabel voter_first_seen) BUKAN last_vote — last_vote terkuantisasi PEKAN oleh stake2vote (B12) sehingga voter baru yang vote Senin–Kamis tampak 4–6 hari ⇒ lolos maturity 3-hari tanpa menunggu (B15). Aturan (dashboard + distribusi): BARU = last_vote > now−STALE DAN (first_seen_at NULL atau > now−MATURITY); VALID = last_vote > now−STALE DAN first_seen_at ≤ now−MATURITY (akun matang, ⊖ perlu vote tua). _edge_rows/_db_rows/_count/_stats/_search_rows + distribusi memakai ambang first_seen_at; _stats partisi n_new + n_valid + n_expired == total_voters; BARU band kolom MATURITY PERIOD = countdown VALID DALAM N HARI (first_seen + MATURITY, filter countdown; ⊖ TOTAL REWARD yang selalu 0 utk pemilih baru); oracle test_distribute (make_voters_db seed first_seen matang default, make_first_seen REPLACE utk BARU/REVOTE) + test_dashboard (acct### seed first_seen matang; band check MATURITY PERIOD) | V52,V40,V42,V41
V53: kartu promo VEXWALLET di index (display-only): section .news tag BY DATABISNISID, judul VEXWALLET · ANDROID, link logo static/idrs.png (.promo-logo) + label UNDUH DI PLAY STORE → https://play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet&hl=id (target=_blank rel=noopener); oracle test_dashboard (logo idrs.png + link + tag + label ada di /); history ⊖ berubah
V54: baseline landing klaim STABIL per window (B16): poll_claim baca last_claim_time SEKALI di awal window dan mengoper ke SETIAP _try_claim_once(last_before) (⊥ tiap attempt baca ulang — baseline ikut maju setelah klaim mendarat, membuat _claim_time_advanced tak pernah True → retry-loop permanen walau klaim sukses); _confirm_landed baca last_after dgn retry singkat (node RPC bisa lag mencerminkan last_claim_time baru); attempt 1 yg baca basi → retry, attempt 2 (baseline orisinil vs last_after maju) → claimed. Oracle test_claim: stale-lalu-maju → poll_claim claimed + reward tercatat | V54,V50,V46
V55: reward klaim TAK PERNAH tercatat/notif sbg 0.0000 sukses bila tak terukur (B17): _measure_reward (1) RETRY reward_from_tx 4× dgn jeda SETTLE_SECONDS (default 30) — tx klaim baru mendarat bisa belum terindeks Hyperion sesaat → None palsu; (2) fallback selisih saldo 3× HANYA mengakui delta POSITIF (after > before) sbg terukur — delta 0 ambigu (node balik saldo lama / saldo belum ter-update) → tetap None; keduanya gagal → fee pending + alert KLAIM MENDARAT · REWARD TAK TERUKUR (jalur V45), ⊖ KLAIM REWARD SUKSES 0.0000 menyesatkan. Reward 0 ASLI (tx executed tanpa transfer bpay/vpay) tetap terbaca via isi tx (reward_from_tx → Decimal('0')) → fee skipped (V33). Oracle test_claim: V33 reward-0 via isi tx (bukan delta 0); B17-a reward_from_tx None + delta 0 → claimed + fee pending + alert ⊖ sukses-0; B17-b reward_from_tx None lalu berhasil → reward asli tercatat | V55,V45,V33
V35: kill-switch distribusi DISTRIBUTE_ENABLED default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; --dry-run tetap menampilkan rencana (read-only); nilai true/1/yes → normal
V36: notifikasi klaim reward (sukses notify_claim & gagal notify_claim_failure) → kanal INTERNAL saja, ⊥ pernah ke KOMUNITAS (V30); fee gagal ⊥ spam — satu notif per klaim (saat transisi ke failed)
V37: jadwal distribusi MINGGUAN (⊥ harian): distribute_loop menunggu DISTRIBUTE_WEEKDAY — SATU hari (sat) atau CSV beberapa hari (wed,sat), nama mon..sun atau 0-6 (V44: multi-hari, event terdekat di antara hari-hari terdaftar) — jam DISTRIBUTE_HOUR (lokal TZ); plus satu one-off DISTRIBUTE_FIRST_RUN (YYYY-MM-DD; default baked 2026-08-17) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila DISTRIBUTE_ENABLED=false (V35) — jadwal tetap maju, run jadi no-op; distribute_loop.main baca jadwal via config (⊥ os.getenv sendiri) agar konsisten dgn dashboard V38
V38: dashboard index menampilkan banner BERITA jadwal distribusi berikutnya (DISTRIBUSI BERIKUTNYA + tanggal id-ID SENIN · 17 AGUSTUS 2026 · 10:00 WIB); baris berulang SETIAP RABU & SABTU · 10:00 (V44: hari di-join & sesuai CSV DISTRIBUTE_WEEKDAY) HANYA utk putaran mingguan — ⊥ tampil saat event berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak konsisten; _is_first_run_next); setelah launch lewat → baris muncul lagi; logika boundary MIRROR distribute_loop.next_boundary (⊥ impor lintas-image, V31; drift dijaga oracle test_dashboard); TZ lokal utk konsistensi dgn jadwal; stack: web service juga menerima DISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN/TZ (interpolasi .env)
V44: jadwal distribusi MULTI-HARI (Rabu & Sabtu): DISTRIBUTE_WEEKDAY menerima CSV (wed,sat default; nilai tunggal tetap valid) → parse_weekdays (list int unik terurut, mirror distribute_loop/dashboard); next_boundary(now, hour, weekdays, first_run) pilih event terdekat di antara hari-hari terdaftar, hormati gating first_run (hari sebelum launch dilewati); dashboard _recurrence_label = SETIAP {hari & hari} · HH:00; default baked wed,sat (config/compose/.env.example); oracle test_distribute_loop (multi-hari) + test_dashboard (drift-guard list + skalar)
§T — Tasks
id|status|task|cites
T1|x|setup: pip install requests, requirements.txt, project files|
T2|x|scan loop: retry + pagination|V1,V2,V3,I.api
T3|x|filter + normalize (owner, staked÷10000, weight ≥1000)|V4,V5,V6,V8,V9
T4|x|sqlite schema + replace-snapshot w/ scanned_at|V7,I.db
T5|x|CLI entry + Indonesian summary stdout|I.cmd
T6|x|dashboard: Flask read-only, paged spec-list sort staked DESC|V10,I.web,I.db
T7|x|dashboard: styling per DESIGN.md (canvas hitam, typografi, hairline)|I.web
T8|x|dashboard: test oracle + smoke|V10,I.web
T9|x|env config: config.py + .env/.env.example + dotenv; get_voters & dashboard pakai config|I.env
T10|x|search: ?q= server filter + /api/search JSON (substring, escape, rank global)|V11,I.web
T11|x|live JS filter (app.js) + responsive (DESIGN.md breakpoints, list→kartu mobile)|V11,I.web
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
T17|x|backend shared db.py (sqlite|mysql, %s→?, ESCAPE '!'), config VEX_DB_*, refactor fetcher+dashboard, test_mariadb.py opt-in, runbook docker-compose|V17,I.db,I.env
T18|x|stack swarm: Dockerfile web + Dockerfile.scan, scan_loop.py (SCAN_HOURS 0,8,16 lokal, skan-awal), docker-compose.yml stack (mariadb+web+scan), docker-compose.dev.yml utk uji lokal|V18,I.web,I.env
T19|x|db.py: tabel distribute_runs + distribute_payments (sqlite/mysql, %s, append) + helper (record_run, record_payment, update_payment_status, list_payments)|V21,V22,I.db
T20|x|config.py: env VEX_BP_PRIVATE_KEY, DISTRIBUTE_HOUR, DISTRIBUTE_MAX_ATTEMPTS + konstanta chain (vex.token, chain_id); requirements.txt += pyntelope|V19,I.env
T21|x|distribute.py: fetch_balance (chain get_currency_balance) + compute_shares (floor 4-des, sisa di akun)|V20,I.api
T22|x|distribute.py: run_distribution — pending → sign (pyntelope) → push_transaction → sent/failed; memo per run; retry loop (backoff, dalam hari)|V21,V22,V23,V24,I.api
T23|x|distribute.py: guard resend — verifikasi txid via Hyperion get_transaction sebelum kirim ulang|V22,V23,I.api
T24|x|distribute.py: --dry-run (rencana ⊥ sign) + no-op saat pemilih kosong/saldo kecil|V25,V26,I.cmd
T25|x|scheduler: service distribute di stack swarm (Dockerfile.dist + distribute_loop.py, image databisnisid-dist, loop tunggu DISTRIBUTE_HOUR lokal, ⊥ distribusi-awal; key VEX_BP_PRIVATE_KEY dari bind NFS config.env:/app/.env:ro)|V18,V24,I.env
T26|x|dashboard: route /history (runs + per-voter log) + template, gaya DESIGN.md, read-only|V27,I.web
T27|x|tests: test_distribute.py (mock chain+pyntelope, temp DB: math share, memo, no-op, status/retry, guard duplikat) + extend test_dashboard.py (/history)|V20,V21,V22,V25,V26,V27,I.db
T28|x|hygiene: .gitignore += .oc/; stage penghapusan README.md (stub kosong)|
T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; /history 6-kolom + kartu mobile berlabel; CSS cache-bust + HTML no-store|V28,I.web
T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE center|V28,I.web
T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web
T32|x|dashboard list: kolom TOTAL REWARD (VEX) = Σ sent per pemilih (LEFT JOIN subquery, status=sent); desktop 5 kolom rata, tablet 5 kolom, kartu mobile + label; TXID history jadi link explorer vexascan|V15,V28,V29,I.web
T33|x|notifikasi Telegram: telegram.py (send_text best-effort + notify_start/finish/failure), hook di distribute.py (mulai/selesai/gagal; ⊥ dry-run/no-op), config TELEGRAM_BOT_TOKEN+TELEGRAM_CHAT_IDS, Dockerfile.dist += telegram.py, oracle|V30,I.env
T34|x|image mandiri: distribute_loop self-contained (duplikasi helper scan_loop, ⊥ impor lintas-image) + oracle test_images.py (import intra-proyek ⊆ daftar COPY tiap image)|V31
T35|x|klaim config: CLAIM_RETRY_SECONDS, VEX_BP_FEE_WALLET, VEX_BP_FEE_PERCENT di config.py + db.py tabel claim_runs (ensure/record_claim/update_claim_fee/pending_claim_fee, %s dua backend)|V32,V34,I.db,I.env
T36|x|claim.py: fetch last_claim_time (tabel producers) + next_window + build_claim_action (pyntelope vexcore::claimrewards) + ukur reward (delta saldo) + hitung fee floor|V32,V33,I.api
T37|x|claim.py: poll_claim (retry tiap CLAIM_RETRY_SECONDS sampai sukses/deadline) + _send_fee (retry-until-sent, verifikasi txid V22) + step state machine (resume fee → jadwal → poll)|V33,V34
T38|x|claim_loop.py self-contained (duplikasi next_boundary/_wait_db) + Dockerfile.claim (image databisnisid-claim) + service claim di docker-compose.yml (env DB/TZ/CLaim + bind NFS config.env)|V32,V34,I.env
T39|x|telegram.py: notify_claim (reward+fee) + notify_claim_failure + hook di claim.py (sukses klaim / fee gagal pertama; best-effort)|V30,V34
T40|x|test_claim.py oracle (mock chain+pyntelope, temp DB: jendela, math fee, siklus penuh, retry fee, resume, reward 0) + test_images.py += Dockerfile.claim & modul claim/claim_loop|V31,V32,V33,V34,I.db
T41|x|kill-switch distribusi: DISTRIBUTE_ENABLED (config default false) + gate run_distribution (non-dry-run no-op: exit 0, ⊥ sign/tulis/notif; dry-run tetap) + env compose ${DISTRIBUTE_ENABLED:-false} + oracle (default off, disabled no-op, disabled+dry-run)|V35,I.env
T42|x|Telegram dua kanal: rename TELEGRAM_CHAT_IDS → TELEGRAM_COMMUNITY_CHAT_IDS + tambah TELEGRAM_INTERNAL_CHAT_IDS (config), telegram.py send_text(chat_ids=None)=komunitas + send_internal; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env
T43|x|jadwal mingguan: distribute_loop.py next_boundary = {one-off DISTRIBUTE_FIRST_RUN @ jam (jika depan)} ∪ {mingguan DISTRIBUTE_WEEKDAY @ jam ≥ tanggal launch; Sabtu sebelum launch dilewati} + parser hari/nama + config.py (DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN) + env compose + .env.example + oracle test_distribute_loop.py|V37,I.env
T44|x|dashboard banner BERITA jadwal distribusi berikutnya: dashboard.py mirror _next_boundary (⊥ impor lintas-image, V31) + _next_schedule/_fmt_schedule (id-ID) + _recurrence_label (baris berulang hanya utk mingguan, ⊥ saat first-run, _is_first_run_next); section .news di index.html + CSS; env compose web += TZ/DISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN; oracle V38 (banner + drift-guard vs distribute_loop)|V38,V37,I.env
T49|x|jadwal multi-hari Rabu & Sabtu (V44): config.py default DISTRIBUTE_WEEKDAY=wed,sat; distribute_loop.py += parse_weekdays (CSV → list int unik) + next_boundary terima list (skalar kompatibel, normalisasi); dashboard.py mirror _parse_weekdays + _recurrence_label SETIAP RABU & SABTU · 10:00 (& via |safe); compose + .env.example wed,sat; docs V37/V38 amend + V44 + T49; oracle test_distribute_loop (multi-hari) + test_dashboard (drift list/skalar + label); image web+dist rebuild & push|V44,V37,V38,I.env
T45|x|band umur vote V40: scan simpan semua umur (hapus _is_stale), config += VEX_MATURITY_DAYS/VEX_EXPIRED_DAYS, dashboard = BARU (bagian sendiri) + daftar utama (kadaluarsa menyatu di atas, sorot amber+badge+legenda, lalu VALID pager) + search bertag + stats/count/rank scoped, distribusi filter matang+segar (_eligible_cutoffs), env/.env.example + docs, oracle test_get_voters/test_dashboard/test_distribute|V40,V13,V11,V15,I.env
T46|x|BARU vs REVOTE (V41): db.py += tabel voter_first_seen + ensure_first_seen_schema/record_first_seen (INSERT OR IGNORE/IGNORE, PK owner, append-only); scan persist mencatat first_seen; dashboard _edge_rows/_stats/_search_rows klasifikasi first_seen > new_cut → BARU, ≤ → REVOTE (group enum += revote), bagian REVOTE sendiri di bawah BARU + tag CSS --mint; docs + oracle test_get_voters/test_dashboard/test_mariadb|V41,V7,V15,V40
T47|x|REVOTE = VALID langsung (V42): distribute.py run_distribution ensure_first_seen_schema + query LEFT JOIN voter_first_seen → last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE); dashboard _edge_rows → (new_rows, expired_rows) (REVOTE ⊖ BARU, masuk daftar utama), _db_rows/_count jendela sama + flag revote (elemen ke-8) + tag mint REVOTE di baris valid, _stats partisi n_new+n_revote+n_expired+n_valid == total, _search_rows group revote di jendela BARU & VALID; template ⊖ bagian REVOTE + legenda mint (?v=13); docs + oracle test_distribute/test_dashboard|V42,V40,V41,V15,I.db,I.web
T48|x|tag/badge display V43: config += VEX_REVOTE_TAG_DAYS (1) + VEX_WARN_DAYS (3); _freshness_cutoffs → 4-tuple (new_cut, warn_cut, exp_cut, hid_cut) + _revote_cut; _db_rows flag revote = last_vote > revote_cut AND fs ≤ new_cut + flag expiring (elemen ke-9) exp_cut < last_vote ≤ warn_cut; _stats 9-tuple (+n_expiring, n_revote = tag-1-hari); _search_rows tag revote HANYA bila last_vote > revote_cut + field expiring; template badge VOTE ULANG SEGERA (main + ?q=) + legenda mint/amber baru (?v=14), app.js warn-badge, CSS .warn-badge; env/.env.example + docs (V15/V42 amend, V43, T48); oracle test_dashboard|V43,V42,V40,V15,I.env,I.web
§B — Bug log
id|date|cause|fix
B1|2026-08-04|node balikin staked sbg string utk sebagian baris → TypeError int÷int|V8
B2|2026-08-05|ESCAPE '\' valid di SQLite tapi memutus literal string MySQL ('\' = string tak tertutup) → error 1064 di backend mysql|ganti karakter escape ke ! (netral di kedua backend), _escape_like esc !,%,_ (V17)
B3|2026-08-05|PyMySQL fetchall() → tuple, SQLite → list → asersi == [] gagal di mysql|db.query bungkus list() → tipe seragam (V17)
B4|2026-08-06|image dist ⊥ salin scan_loop.py, distribute_loop impor darinya → ModuleNotFoundError saat smoke container|V31
B5|2026-08-06|DISTRIBUTE_ENABLED pakai not in ('1','true','yes') → default jadi ON (kebalikan V35, kill-switch ⊥ berfungsi saat var ⊥ diset)|flip ke in (...) — default off (V35)
B6|2026-08-06|mirror _parse_weekday di dashboard memakai kunci nama hari id-ID (sabtu) tapi config default sat (singkatan EN) → ValueError 'sat'; drift-guard oracle tangkap|kunci parser = singkatan EN mon..sun (sama dgn distribute_loop.WEEKDAYS), V38 + drift-guard
B7|2026-08-06|claim service di swarm mati total — log [Error] Siklus klaim gagal: 0 tiap siklus: fetch_producer_last_claim parse respons get_table_rows sbg list (data[0]) padahal node balikin dict {rows:[...]} → KeyError: 0 (str 0); oracle test_claim mock fetch_producer_last_claim langsung → parse tak pernah diuji|parse data.get('rows') bentuk asli, bentuk tak dikenal → None (⊥ crash); oracle mock _post_json dgn bentuk respons asli (V39)
B8|2026-08-06|setelah jendela klaim lewat (poll 2 jam habis tanpa sukses) next_window tetap balikin last+24h masa lalu → poll_claim spin-timeout selamanya, klaim tak pernah bisa catch-up (deadlock) walau ≥24 jam sudah lewat|next_window = max(last+24h, now) — ≥24 jam → jendela = now, klaim dikejar segera (V32)
B9|2026-08-07|notif KLAIM REWARD SUKSES / Reward 0.0000 walau tx klaim TIDAK mendarat: signed.send() timeout + Hyperion sempat tak terjangkau → heuristik "last_claim_time dalam 24 jam" (klaim lama masih <24h) menganggap mendarat → reward diukur 0 (δ saldo ⊥ berubah) → fee skipped diam-diam + sukses-0|deteksi mendarat = Hyperion executed ATAU last_claim_time MAJU dari baseline (V45); reward ⊥ terukur → fee pending + notif cek manual (V33); next_window baca di-retry (⊥ clamp prematur)
B10|2026-08-07|reward klaim diukur dari selisih saldo liquid (setelah−sebelum): saat node baca saldo di belakang/fetch gagal, reward nyata (transfer bpay/vpay) ⊥ tercatat → fee ⊥ dikirim utk siklus itu (fee skipped permanen, V34 ⊥ retry skipped)|ukur reward dari isi tx klaim via Hyperion (bpay+vpay → BP) prioritas; selisih saldo jadi fallback; keduanya gagal → fee pending + notif (V33/V45)
B11|2026-08-07|notif [OK] klaim reward 0.0000 STILL terbit walau tx klaim tak mendarat — V45 hanya mengerasankan jalur exception, tapi signed.send() pyntelope TIDAK raise saat chain menolak (Net._request kembalikan body HTTP 500 sbg JSON): klaim sebelum jendela → HTTP 500 → send() normal → landed=True di jalur sukses → reward 0 → [OK]/KLAIM REWARD SUKSES palsu|landing dikonfirmasi di KEDUA jalur via _confirm_landed (Hyperion executed ATAU last_claim_time maju dari baseline); tak terkonfirmasi → retry senyap, ⊥ catat/notif (V46); next_window baca producer gagal → raise (⊥ clamp prematur)
B12|2026-08-07|derive_last_vote pakai 365.25 hari/tahun tapi Vexanium (stake2vote) mengkuantisasi bobot ke PEKAN UTUH (int64((now−2000)/(86400×7))/52.0) — drift ~33 hari ke depan utk vote 2026 (365.25 vs 364 hari/eksponen); jendela maturity 3/28/31 hari jadi miring ~sebulan|rekonstruksi = epoch + round(52×log2(ratio)) pekan (kelipatan 7 hari); oracle _fresh_weight + 2^26 + regresi baris nyata (..tg, 1.crownz) (V13)
B13|2026-08-07|claim service loop [Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat — retry tanpa henti ~7 jam sebelum jendela: step() memakai datetime.now() (TZ container Asia/Jakarta, UTC+7) sedangkan last_claim_time chain UTC-naive → next_window menganggap jendela 24 jam sudah buka ~7 jam lebih awal → poll tiap 60s → claimrewards ditolak chain (HTTP 500 sbg body, pyntelope ⊥ raise) → _confirm_landed ⊥ terkonfirmasi → retry; V46 bekerja benar (⊥ sukses-0) tapi tak pernah ada tx mendarat utk dicatat|window math pakai _utcnow() (UTC-naive) di step()/poll_claim/_try_claim_once — konsisten dgn last_claim_time chain (V49); oracle _utcnow tzinfo None + step before-window → sleep
B14|2026-08-07|setelah fix TZ (V49) klaim BENAR-BENAR mendarat (tx 0e61fcb5…, bpay 466.7587 + vpay 1215.4164 = 1682.1751 VEX) tapi app tetap retry-loop & ⊥ catat reward/fee: _confirm_landed menyerah saat verify_txid → False (executed: false) — Hyperion merespons false utk tx yang baru saja mendarat tapi belum terindeks (lag), padahal last_claim_time SUDAH maju; retry berikutnya baca last_before yg sudah maju → _claim_time_advanced tak pernah deteksi → retry permanen, fee 168.22 VEX utk siklus itu tak terkirim|_confirm_landed: verified False MAUPUN None → tetap cek _claim_time_advanced; hanya bila itu False → retry (V50); oracle send-normal+verify-False+last-maju → claimed
B15|2026-08-08|maturity 3-hari pemilih BARU ⊥ berfungsi utk vote Senin–Kamis: last_vote diturunkan dari bobot yang terkuantisasi PEKAN (stake2vote) → voter baru yg vote tengah pekan tampak 4–6 hari ⇒ lolos last_vote ≤ now−MATURITY di hari pertama (anti-gaming tembus); kejadian nyata: eosauthority vote 5 Agt (Rabu) → last_vote terderivasi 1 Agt (Sabtu)|maturity diukur dari first_seen_at (presisi detik) di dashboard + distribusi: BARU = first_seen baru/NULL; VALID = first_seen matang (V52); oracle test_distribute/test_dashboard seed first_seen matang
B16|2026-08-08|klaim mendarat di chain (tx 48c2066f…, bpay 463.9816 + vpay 1219.3825 = 1683.3641 VEX) tapi app tetep [Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat — retry tanpa henti & ⊖ notif/fee (sama utk klaim 7 Agt 0e61fcb5…): _try_claim_once baca last_before ULANG tiap attempt — attempt 1 mendarat tapi baca last_after basi (node RPC belum mencerminkan last_claim_time baru) → retry; attempt 2 last_before sudah = nilai maju → _claim_time_advanced(maju, maju) ⊖ pernah True → retry-loop permanen walau V50/V14 sudah menangani Hyperion-False|poll_claim baca baseline SEKALI per window & oper ke tiap _try_claim_once; _confirm_landed retry baca last_after dgn jeda singkat (V54); oracle stale-lalu-maju → claimed
B17|2026-08-08|notif Telegram KLAIM REWARD SUKSES / Reward 0.0000 / Fee BP 0.0000 → bpdbsjasprod (22:47 WIB, ~1 mnt stlh klaim mendarat 15:46:06.5 tx 48c2066f… reward asli 1683.3641) — _measure_reward balikin Decimal('0') BUKAN None: reward_from_tx → None (Hyperion belum indeks tx yg baru mendarat) lalu fallback selisih saldo baca after == before (node balik saldo lama) → max(δ,0)=0; guard if reward is None (V45) ⊖ pernah memicu (0 ⊖ None) → fee 0 skipped + notify_claim(0,0) sukses-0 palsu; fee 168.3364 VEX utk siklus itu tak terkirim (sama utk klaim 9 Agt 4b9ec0be… reward 1683.0126 yg jalan di image V54: delta-0 → sukses-0 → fee 168.3012 tak terkirim)|_measure_reward retry reward_from_tx 4× dgn SETTLE_SECONDS (30) beri waktu Hyperion mengejar indeks; fallback selisih saldo HANYA delta POSITIF (after > before); delta 0 / keduanya gagal → None → fee pending + alert KLAIM MENDARAT · REWARD TAK TERUKUR, ⊖ sukses-0 (V55); reward 0 asli dibuktikan dari isi tx; oracle B17-a/B17-b