36 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 = 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/<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 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)