"""Scan tabel voters Vexanium, simpan pemilih BP databisnisid + bobot suara ke SQLite. Sejak V40 SEMUA pemilih BP dengan last_vote terverifikasi disimpan — termasuk yang basi (> VEX_STALE_DAYS). Banding/kadaluarsa bukan urusan scan: tampilan (dashboard) dan pembayaran (distribute) menghitung umur vote saat itu juga. Hanya pemilih dengan last_vote tak terverifikasi (bobot nol/kosong) yang dibuang. Berjalan imparsial (repeatable) dan siap dijadwalkan (cron). """ import math import sys import time from datetime import datetime, timedelta, timezone import requests import db from config import API_NODE, MIN_STAKED_VEX, TARGET_BP # Kontrak & scope sistem Vexanium = vexcore (BUKAN vexio sesuai konvensi EOS) CODE = 'vexcore' SCOPE = 'vexcore' TABLE = 'voters' # Ukuran halaman saat paginasi LIMIT = 500 # Ambang-batas nama akun "0" (kodifikasi uint64 0) pada tabel voters ZERO_OWNER = '...........q' # Skala stake (×10000) dan epoch bobot suara Vexanium (1 Jan 2000, # block_timestamp_epoch). Eksponen bobot = PEKAN utuh sejak epoch ÷ 52. VEX_SCALE = 10000 WEIGHT_EPOCH = datetime(2000, 1, 1) def derive_last_vote(weight, staked_vex): """V13: perkiraan waktu revote terakhir dari rasio bobot/stake. Vexanium (sistem EOSIO): `last_vote_weight = staked_raw × 2^(pekan/52)` — eksponen = `int64((now − 2000-01-01) / (86400×7)) / 52.0`, jadi bobot dikuantisasi ke PEKAN UTUH sejak epoch 2000 (stake2vote). Maka `t_pekan = round(52 × log2(last_vote_weight / staked_raw))` → tanggal revote = epoch + t_pekan pekan (kelipatan 7 hari, 00:00:00). Kembalikan None bila tak bisa dihitung (bobot/stake nol atau tak valid). """ staked_raw = staked_vex * VEX_SCALE if staked_raw <= 0: return None try: ratio = float(weight) / staked_raw except (TypeError, ValueError): return None if ratio <= 0: return None t_weeks = round(52 * math.log(ratio) / math.log(2)) return (WEIGHT_EPOCH + timedelta(weeks=t_weeks)).isoformat(timespec='seconds') def fetch_with_retry(payload, max_retries=3, timeout=30): """POST ke RPC dengan retry (node publik sering timeout). V1: ulang ≥3 kali pada timeout/error HTTP, jeda 2 detik antar percobaan. """ url = f'{API_NODE}/v1/chain/get_table_rows' for attempt in range(max_retries): try: resp = requests.post(url, json=payload, timeout=timeout) resp.raise_for_status() return resp.json() except requests.RequestException: if attempt == max_retries - 1: raise print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... ' f'({attempt + 1}/{max_retries})') time.sleep(2) def scan_voters(): """Iterasi seluruh tabel voters (paginasi), kembalikan daftar mentah. V2: majukan lower_bound ke owner terakhir; berhenti saat more=false | baris kosong. V3: pada halaman kelanjutan, buang baris pertama (induk pamungkas duplikat). """ rows_all = [] lower_bound = '' has_more = True while has_more: payload = { 'json': True, 'code': CODE, 'scope': SCOPE, 'table': TABLE, 'lower_bound': lower_bound, 'limit': LIMIT, } data = fetch_with_retry(payload) rows = data.get('rows') or [] if not rows: break if lower_bound != '': rows = rows[1:] rows_all.extend(rows) has_more = data.get('more', False) if has_more: lower_bound = rows_all[-1]['owner'] return rows_all def normalize(row, now=None): """V4/V5/V6/V13: saring ke baris yang relevan, bidang dinormalisasi. V40: pemilih dengan last_vote basi TIDAK lagi dibuang di sini — semua umur vote disimpan (tampil/bayar menghitung umur saat query). Yang tetap dibuang: sentinel "0", bukan BP target, stake < minimum, dan last_vote yang tak bisa diverifikasi (bobot nol/kosong → tanggal tak diketahui, tak bisa diklasifikasi). Estimasi last_vote yang melewati `now` (unstake tanpa revote → rasio bobot/stake membesar) diklamp ke `now`. """ if now is None: now = datetime.now(timezone.utc).replace(tzinfo=None) owner = row['owner'] if owner == ZERO_OWNER: # V4: lewati sentinel nama akun 0 return None if not (row.get('producers') == [TARGET_BP]): # V5 return None staked_vex = float(row.get('staked', 0)) / 10000 # V8/V9: bisa string, ÷10000 if staked_vex < MIN_STAKED_VEX: return None last_vote = derive_last_vote(row.get('last_vote_weight', ''), staked_vex) if last_vote is not None and last_vote > now.isoformat(timespec='seconds'): last_vote = now.isoformat(timespec='seconds') # V13: heuristik ⊥ boleh di masa depan if last_vote is None: # V13: tanggal tak diketahui → tak dapat diklasifikasi return None return { 'owner': owner, 'weight': row.get('last_vote_weight', ''), # V6: bobot verbatim (string) 'staked': staked_vex, 'last_vote': last_vote, } def persist(rows): """V7: ganti snapshot — tiap skan ⊥ history/append. Delegasi ke `db.replace_snapshot` (backend sqlite/mysql): sqlite DROP+CREATE dalam WAL → pembaca (dashboard multi-worker) tak terblokir; mysql DELETE+INSERT dalam satu transaksi (MVCC snapshot). Lalu `db.record_first_seen` (V41): catat kemunculan pertama tiap owner (idempoten) utk membedakan pemilih BARU vs REVOTE di dashboard. """ scanned_at = datetime.now(timezone.utc).replace(tzinfo=None).isoformat( timespec='seconds') db.replace_snapshot(rows, scanned_at) db.record_first_seen(rows, scanned_at) return scanned_at def summarize(rows, scanned_at): """I.cmd: ringkasan stdout dalam bahasa Indonesia, format id-ID.""" print('\n=== HASIL AKHIR ===') print(f'Total pemilih untuk BP [{TARGET_BP}]: {len(rows)}') print(f'Waktu pemindaian: {scanned_at}') print('\nDaftar Pemilih:') if not rows: print('(Tidak ada pemilih ditemukan untuk BP ini)') return for index, row in enumerate(rows, start=1): print(f"{index}. {row['owner']} - Stake: " f"{row['staked']:,.4f} VEX - Bobot: {row['weight']}") def main(): """Titik masuk CLI.""" print(f'Mulai memindai voters untuk BP: "{TARGET_BP}"...') raw = scan_voters() now = datetime.now(timezone.utc).replace(tzinfo=None) filtered = [] for row in raw: item = normalize(row, now=now) # satu stempel waktu untuk seluruh skan if item is not None: filtered.append(item) scanned_at = persist(filtered) summarize(filtered, scanned_at) return 0 if __name__ == '__main__': sys.exit(main())