"""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 import telegram from config import (API_NODE, MIN_STAKED_VEX, TARGET_BP, VEX_EXPIRED_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS) # 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 _notify_warn(now): """V66: kirim notifikasi VOTE ULANG SEGERA ke komunitas. Query pemilih dalam jendela `now − STALE < last_vote ≤ now − (STALE − WARN)`, hitung urgensi berdasarkan sisa hari (1=⚠️ >2h, 2=🔴 ≤2h, 3=🚨 ≤1h), filter yang sudah diberi tahu pada level ≥ urgensi saat ini, kirim via Telegram, catat ke `voter_notify`. Bersihkan baris stale. """ stale_cutoff = (now - timedelta(days=VEX_STALE_DAYS)).isoformat(timespec='seconds') warn_cutoff = (now - timedelta(days=VEX_STALE_DAYS - VEX_WARN_DAYS)).isoformat( timespec='seconds') rows = db.query( 'SELECT v.owner, v.staked, v.last_vote FROM voters v ' 'WHERE v.last_vote > %s AND v.last_vote <= %s', (stale_cutoff, warn_cutoff)) if not rows: db.cleanup_notify((now - timedelta(days=VEX_EXPIRED_DAYS)).isoformat( timespec='seconds')) return to_notify = [] for owner, staked, last_vote in rows: vote_dt = datetime.fromisoformat(last_vote) days_left = VEX_STALE_DAYS - (now - vote_dt).days if days_left <= 1: urgency = 3 elif days_left <= 2: urgency = 2 else: urgency = 1 prev = db.get_notify_urgency(owner) if prev is not None and prev >= urgency: continue to_notify.append((owner, f'{staked:,.4f}', urgency)) if to_notify and telegram.notify_warn(to_notify): for owner, _, urgency in to_notify: db.record_notify(owner, urgency, now.isoformat(timespec='seconds')) db.cleanup_notify((now - timedelta(days=VEX_EXPIRED_DAYS)).isoformat( timespec='seconds')) 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) _notify_warn(now) summarize(filtered, scanned_at) return 0 if __name__ == '__main__': sys.exit(main())