193 lines
6.7 KiB
Python
193 lines
6.7 KiB
Python
"""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().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()) |