# 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 `./venv` exists (py3.12, only pip) — install deps there, ⊥ global - lib: `requests`, `flask`, `python-dotenv`, `gunicorn`, `pyntelope` ! pip-installed; `pymysql` utk backend mysql (opt-in); others stdlib - config via env/`.env` (`config.py`, python-dotenv); default = konstanta lama - db: `db.py` abstraksi backend — `sqlite` (default, stdlib `sqlite3` → `DB_PATH`, WAL → pembaca tak terblokir) atau `mysql` (MariaDB/MySQL via PyMySQL, `VEX_DB_*`); container uji via `docker-compose.dev.yml` - 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 - 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 rasio `last_vote_weight/staked_raw` (V13); `VEX_STALE_DAYS` default 28 - voter basi (revote > `VEX_STALE_DAYS`) atau `last_vote` tak diketahui → dibuang di `normalize` (⊥ 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 registry `git.proit.id/proitlab/databisnisid-web` & `-scan` & `-dist` (⊥ `build:` di stack), deploy `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`), web/scan/distribute di node mana pun via overlay `appnet`; scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, waktu lokal `TZ` default Asia/Jakarta) + skan-awal `SCAN_RUN_ON_START`; distribute berjalan pd jam `DISTRIBUTE_HOUR` (default 10) via `distribute_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, issuer `vexcore` - chain_id = `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f` (nodeos v1.2.2) — utk signing - saldo distribusi: chain `get_currency_balance` pd `vex.token` utk `TARGET_BP` (⊥ Hyperion; = SALDO LIQUID dashboard) - signing: `pyntelope` (Python; eospy→eospyo→pyntelope); key aktif BP `VEX_BP_PRIVATE_KEY` — ⊥ repo, ⊥ log; di stack dibaca dari `/app/.env` hasil bind NFS `config.env` (load_dotenv), di dev via env/`.env` lokal - distribusi: harian `DISTRIBUTE_HOUR` (default 10) lokal `TZ` → SELURUH saldo liquid pro-rata `staked` ke 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 ulang `sent`; sebelum resend → verifikasi txid on-chain (Hyperion `get_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_ENABLED` default FALSE — payout ⊥ jalan tanpa diset `true` (V35); `--dry-run` tetap boleh (read-only) - notifikasi Telegram dua kanal (V30/V36): bot token tunggal `TELEGRAM_BOT_TOKEN`; KOMUNITAS `TELEGRAM_COMMUNITY_CHAT_IDS` hanya brief mulai/selesai distribusi; INTERNAL `TELEGRAM_INTERNAL_CHAT_IDS` detail distribusi + semua kegagalan + klaim reward (⊥ komunitas); best-effort (⊥ crash); tanpa token/daftar → kanal senyap; di stack nilai dari NFS `config.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 aktif `VEX_BP_PRIVATE_KEY`; 1 klaim per 24 jam — jendela = `last_claim_time` (tabel `producers`) + 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` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD`; reward 0 → fee skipped - service `claim` stack (image `-claim`): `claim_loop.py` tidur sampai mendekati jendela (margin), lalu poll `claimrewards` tiap `CLAIM_RETRY_SECONDS` (default 60) sampai diterima chain; ⊥ spam 1440 tx gagal/hari - siklus klaim selesai ⊥ bila klaim tercatat di `claim_runs` DAN fee terkirim; fee pending/failed diulang tiap siklus + dilanjutkan saat restart (crash-safe, ⊥ fee hilang); setelah klaim sukses jendela dihitung ulang dari `last_claim_time` segar - ⊥ 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=` → 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=` (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` = `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` 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 = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI) atas semua baris TAMPIL (V40: ⊖ yang tersembunyi > 31 hari); 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) 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/`; ⊖ 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 `age < VEX_MATURITY_DAYS` (default 3) → bagian sendiri (paling atas), ⊖ reward; VALID `VEX_MATURITY_DAYS ≤ age ≤ VEX_STALE_DAYS` (28) → 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 (last_vote ≤ now−MATURITY OR REVOTE)` (V42) — pemilih baru (< `VEX_MATURITY_DAYS`) ⊥ dibayar siklus itu (anti-gaming, kena lagi siklus berikutnya); REVOTE (sudah pernah terlihat) 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 (`last_vote ≤ now − MATURITY`); jendela VALID dashboard (`_db_rows`/`_count`/rank) = jendela eligibilitas distribusi = `last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE)` — konsisten; baris REVOTE menyatu di daftar utama dgn tag mint `REVOTE` (⊥ bagian sendiri, V15); `_stats` partisi `n_new + n_revote_window + n_expired + n_valid == total_voters` (`n_revote_window` = REVOTE jendela VALID; `n_revote` yang dirender = subset tag); 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 = max(saldo liquid setelah − sebelum, 0) saat klaim (settle delay ≥ 10s); 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 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` (nama `mon`..`sun` atau 0-6; default `sat`) 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 SABTU · 10:00` 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) ## §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 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)