377 lines
16 KiB
Python
377 lines
16 KiB
Python
"""Klaim reward BP + transfer fee harian: `vexcore::claimrewards` utk TARGET_BP.
|
||
|
||
Setiap siklus (V32..V34): tentukan jendela klaim = `last_claim_time` (tabel
|
||
`producers`) + 24 jam (math UTC-naive via `_utcnow`, konsisten dgn stempel
|
||
chain; V49/B13), tidur sampai mendekatinya, lalu poll `claimrewards`
|
||
tiap `CLAIM_RETRY_SECONDS` sampai diterima chain. Jendela yang sudah lewat
|
||
(≥24 jam sejak last) → klaim dikejar segera (recovery, ⊥ spin-timeout
|
||
selamanya). Landing dikonfirmasi via Hyperion `executed` ATAU `last_claim_time`
|
||
maju dari baseline — `send()` pyntelope TIDAK raise saat chain menolak (HTTP
|
||
500 dikembalikan sbg body), jadi ⊥ pernah percaya send() semata (V46/B11).
|
||
Reward diukur PRIORITAS dari isi tx klaim via Hyperion (jumlah transfer
|
||
`vex.bpay`/`vex.vpay` → TARGET_BP; V45) — ⊥ selisih saldo yang flaky/stale;
|
||
selisih saldo jadi fallback; keduanya gagal → fee `pending` + notif cek
|
||
manual. Fee = floor(`VEX_BP_FEE_PERCENT` × reward) 4 desimal →
|
||
`VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD` (V33).
|
||
|
||
Siklus selesai ⊥ bila klaim tercatat di `claim_runs` DAN fee terkirim; fee
|
||
`pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (crash-safe,
|
||
V34). ⊥ mutex dgn distribute.py — distribute beku saldo di awal run (V20),
|
||
klaim yang jatuh di tengah run didefer ke run berikutnya.
|
||
|
||
Modul logika (⊥ CLI) — `claim_loop.py` yang menjalankan loop tak-berhenti.
|
||
"""
|
||
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
|
||
|
||
import db
|
||
import telegram
|
||
import distribute as dist
|
||
from config import (API_NODE, BP_FEE_PERCENT, BP_FEE_WALLET, BP_PRIVATE_KEY,
|
||
CLAIM_RETRY_SECONDS, DATABISNIS_API, TARGET_BP,
|
||
TOKEN_CONTRACT)
|
||
|
||
VEX_PREC = Decimal('0.0001')
|
||
SETTLE_SECONDS = 30 # tunggu finalitas sebelum baca saldo setelah klaim
|
||
POLL_SPAN_SECONDS = 7200 # batas poll sekali jendela (2 jam)
|
||
|
||
|
||
def _utcnow():
|
||
"""UTC-naive `datetime.now()` — konsisten dgn `last_claim_time` chain
|
||
(Vexanium mengembalikan UTC) & `get_voters`/`dashboard` (V49).
|
||
|
||
Memakai waktu lokal (`datetime.now()`, TZ container Asia/Jakarta) untuk
|
||
math jendela 24 jam membuat jendela maju ~7 jam lebih awal → poll ditolak
|
||
chain → retry tanpa henti (B13)."""
|
||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||
|
||
|
||
def _post_json(url, payload, max_retries=3, timeout=30):
|
||
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
|
||
for attempt in range(max_retries):
|
||
try:
|
||
resp = dist.requests.post(url, json=payload, timeout=timeout)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
except Exception:
|
||
if attempt == max_retries - 1:
|
||
raise
|
||
print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... '
|
||
f'({attempt + 1}/{max_retries})', flush=True)
|
||
time.sleep(2)
|
||
|
||
|
||
def fetch_producer_last_claim():
|
||
"""`last_claim_time` baris producers utk TARGET_BP dari chain (| None).
|
||
|
||
`get_table_rows` mengembalikan dict `{"rows":[...]}` (bukan list) — parse
|
||
bentuk asli; bentuk tak dikenal → None (⊥ crash → siklus tak mati)."""
|
||
data = _post_json(f'{API_NODE}/v1/chain/get_table_rows',
|
||
{'json': True, 'code': 'vexcore', 'scope': 'vexcore',
|
||
'table': 'producers', 'limit': 1,
|
||
'lower_bound': TARGET_BP, 'upper_bound': TARGET_BP})
|
||
rows = (data or {}).get('rows') or []
|
||
return (rows[0] or {}).get('last_claim_time') if rows else None
|
||
|
||
|
||
def parse_claim_time(raw):
|
||
"""ISO `last_claim_time` chain → datetime; format tak dikenal → None."""
|
||
if not raw:
|
||
return None
|
||
for fmt in ('%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S'):
|
||
try:
|
||
return datetime.strptime(raw, fmt)
|
||
except ValueError:
|
||
continue
|
||
return None
|
||
|
||
|
||
def next_window(now):
|
||
"""Jendela klaim berikutnya = `last_claim_time` + 24 jam; ⊥ last → now.
|
||
|
||
Recovery: bila ≥24 jam sudah lewat sejak klaim terakhir, jendela = `now`
|
||
(klaim dibolehkan) — jendela yang terlewat (container down / poll habis)
|
||
⊥ jadi spin-timeout selamanya; klaim dikejar segera, lalu jendela dihitung
|
||
ulang dari last_claim_time yang baru.
|
||
|
||
Pembacaan `last_claim_time` node flaky di-retry: bila SEMUA pembacaan
|
||
gagal/kosong → raise (loop catch-and-sleep, ⊥ clamp ke now prematur yang
|
||
memicu percobaan klaim sebelum jendela → ditolak chain). `now` hanya utk
|
||
pembacaan yang BENAR-BENAR berhasil tanpa klaim sebelumnya (BP baru).
|
||
`now` & `last_claim_time` sama-sama UTC-naive (V49/B13)."""
|
||
raw = None
|
||
for _ in range(3):
|
||
try:
|
||
raw = fetch_producer_last_claim()
|
||
if raw:
|
||
break
|
||
except Exception:
|
||
raw = None
|
||
time.sleep(2)
|
||
if not raw:
|
||
raise RuntimeError('tak bisa membaca last_claim_time producer')
|
||
last = parse_claim_time(raw)
|
||
if last is None:
|
||
return now
|
||
window = last + timedelta(hours=24)
|
||
return window if now < window else now
|
||
|
||
|
||
def build_claim_action():
|
||
"""Bangun + tanda tangani aksi `vexcore::claimrewards` via pyntelope.
|
||
|
||
Kembalikan objek SignedTransaction (⊥ broadcast). ABI + TAPOS dari node
|
||
saat `link()` — pola `distribute.build_signed_transfer`.
|
||
"""
|
||
from pyntelope import Action, Authorization, Data, Net, Transaction, types
|
||
|
||
trx = Transaction(actions=[
|
||
Action(
|
||
account='vexcore',
|
||
name='claimrewards',
|
||
authorization=[Authorization(actor=TARGET_BP, permission='active')],
|
||
data=[Data(name='owner', value=types.Name(TARGET_BP))],
|
||
)
|
||
])
|
||
linked = trx.link(net=Net(host=API_NODE))
|
||
return linked.sign(key=BP_PRIVATE_KEY)
|
||
|
||
|
||
def _claim_time_advanced(before_raw, after_raw):
|
||
"""True bila `last_claim_time` benar-benar MAJU dari nilai sebelum kirim.
|
||
|
||
Heuristik pengganti "dalam 24 jam terakhir" (yang memicu false-success:
|
||
klaim lama yang masih <24 jam membuat percobaan yang ditolak dianggap
|
||
mendarat). Hanya pergerakan nyata (`after > before`) yang membuktikan tx
|
||
diterima chain; baseline tak terbaca → ⊥ dianggap maju (aman, retry)."""
|
||
before = parse_claim_time(before_raw)
|
||
after = parse_claim_time(after_raw)
|
||
return before is not None and after is not None and after > before
|
||
|
||
|
||
def reward_from_tx(txid):
|
||
"""Reward BP dari isi tx klaim via Hyperion: jumlah transfer `vex.bpay`/
|
||
`vex.vpay` → TARGET_BP. → Decimal | None (Hyperion tak terjangkau / tx tak
|
||
ada / bentuk tak dikenal). Prioritas pengukuran (V45) — ⊥ selisih saldo
|
||
yang flaky/stale. Tx yang tak `executed` → None."""
|
||
if not txid:
|
||
return None
|
||
try:
|
||
resp = dist.requests.get(f'{DATABISNIS_API}/v2/history/get_transaction',
|
||
params={'id': txid}, timeout=10)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
except Exception:
|
||
return None
|
||
if not (data or {}).get('executed'):
|
||
return None
|
||
total = Decimal('0')
|
||
for act in (data.get('actions') or []):
|
||
a = act.get('act') or {}
|
||
if a.get('account') != TOKEN_CONTRACT or a.get('name') != 'transfer':
|
||
continue
|
||
d = a.get('data') or {}
|
||
if (d.get('to') == TARGET_BP
|
||
and d.get('from') in ('vex.bpay', 'vex.vpay')):
|
||
try:
|
||
total += Decimal(str(d.get('quantity', '')).split()[0])
|
||
except (ValueError, IndexError, InvalidOperation):
|
||
continue
|
||
return total
|
||
|
||
|
||
def _measure_reward(txid, before):
|
||
"""Reward klaim: prioritas isi tx (V45) → fallback selisih saldo (V55).
|
||
|
||
Return `Decimal` | None bila keduanya gagal (reward tak terukur).
|
||
V55/B17: `reward_from_tx` di-RETRY dgn jeda settle — tx klaim baru mendarat
|
||
bisa belum terindeks Hyperion sesaat (None palsu) → beri waktu mengejar.
|
||
Fallback selisih saldo HANYA mengakui delta POSITIF (after > before) sbg
|
||
terukur; delta 0 ambigu (node balik saldo lama / saldo belum ter-update) →
|
||
tetap None → jalur tak-terukur (fee pending + alert), ⊖ sukses-0 palsu.
|
||
Reward nol asli (tx executed tanpa bpay/vpay) tetap terbaca via isi tx."""
|
||
for _ in range(4):
|
||
reward = reward_from_tx(txid)
|
||
if reward is not None:
|
||
return reward
|
||
time.sleep(SETTLE_SECONDS)
|
||
for _ in range(3):
|
||
time.sleep(SETTLE_SECONDS)
|
||
after = dist.fetch_balance()
|
||
if after is not None and after > before:
|
||
return max(after - before, Decimal('0'))
|
||
return None
|
||
|
||
|
||
def _confirm_landed(txid, last_before):
|
||
"""Konfirmasi tx klaim benar-benar mendarat. → True | False (⊥ None).
|
||
|
||
`send()` pyntelope TIDAK raise saat chain menolak (HTTP 500 dikembalikan
|
||
sbg body) — jadi "sukses kirim" ⊥ cukup (V46/B11). Mendarat hanya bila
|
||
Hyperion `executed` ATAU `last_claim_time` MAJU dari baseline sebelum kirim.
|
||
|
||
Hyperion `False` (bukan hanya None) ⊥ langsung dianggap tak mendarat:
|
||
Hyperion bisa merespons `executed: false` utk tx yang BARU SAJA mendarat
|
||
tapi belum terindeks (lag) — padahal klaim sukses. Jadi utk verified False
|
||
maupun None, tetap cek `_claim_time_advanced`; hanya bila itu pun False →
|
||
retry senyap (⊥ catat baris / sukses-0). Klaim yang ditolak ⊥ memajukan
|
||
`last_claim_time` → tetap retry (V50/B14).
|
||
|
||
V54/B16: baca `last_claim_time` sesudah kirim di-RETRY beberapa kali dgn
|
||
jeda singkat — node RPC bisa terlambat mencerminkan `last_claim_time` yang
|
||
baru (lag sesaat); baca stale sekali ⊥ langsung dianggap tak mendarat."""
|
||
verified = dist.verify_txid(txid)
|
||
if verified is True:
|
||
return True
|
||
last_after = None
|
||
for _ in range(3):
|
||
try:
|
||
last_after = fetch_producer_last_claim()
|
||
except Exception:
|
||
last_after = None
|
||
if last_after is not None:
|
||
break
|
||
time.sleep(2)
|
||
return _claim_time_advanced(last_before, last_after)
|
||
|
||
|
||
def _try_claim_once(last_before=None):
|
||
"""Satu percobaan klaim: klaim → konfirmasi mendarat → ukur reward →
|
||
catat `claim_runs`.
|
||
|
||
Return `claimed` (baris tercatat, fee pending/skipped) | `retry`.
|
||
Landing dikonfirmasi di KEDUA jalur (send sukses maupun exception) — `send()`
|
||
pyntelope ⊥ raise saat chain menolak (V46/B11); tak terkonfirmasi → retry
|
||
senyap. Reward diukur dari isi tx (transfer bpay/vpay) — prioritas (V45);
|
||
selisih saldo fallback; keduanya gagal → fee `pending` + notif cek manual
|
||
(⊥ sukses-0 yang menyesatkan).
|
||
|
||
V54/B16: `last_before` (baseline `last_claim_time` sebelum kirim) dipakai
|
||
TETAP dari awal window (`poll_claim` menghitung sekali) — retry berikutnya
|
||
membandingkan baseline ORISINIL thd `last_after` yang sudah maju, jadi klaim
|
||
yang mendarat tapi baca-pertama-nya basi tetap terdeteksi (⊥ baseline ikut
|
||
maju tiap attempt → retry-loop permanen). Bila None (pemanggil langsung),
|
||
baca sendiri sbg fallback.
|
||
"""
|
||
before = dist.fetch_balance()
|
||
if before is None:
|
||
return 'retry'
|
||
if last_before is None:
|
||
try:
|
||
last_before = fetch_producer_last_claim()
|
||
except Exception:
|
||
last_before = None
|
||
txid = None
|
||
landed = False
|
||
try:
|
||
signed = build_claim_action()
|
||
txid = signed.id()
|
||
signed.send()
|
||
landed = _confirm_landed(txid, last_before)
|
||
except Exception as exc:
|
||
if txid is not None:
|
||
landed = _confirm_landed(txid, last_before)
|
||
if not landed:
|
||
print(f'[Peringatan] klaim gagal: {exc}', flush=True)
|
||
return 'retry'
|
||
if not landed:
|
||
print('[Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat '
|
||
'— retry (⊥ catat sukses)', flush=True)
|
||
return 'retry'
|
||
reward = _measure_reward(txid, before)
|
||
now = _utcnow()
|
||
now_iso = now.isoformat(timespec='seconds')
|
||
txid_label = txid[:16] + '...' if txid else '?'
|
||
if reward is None:
|
||
# klaim mendarat tapi reward tak terukur → fee pending utk cek manual
|
||
db.record_claim(now.strftime('%Y-%m-%d'), txid, 0.0, 0.0, 'pending',
|
||
now_iso)
|
||
print(f'[Peringatan] klaim mendarat (txid {txid_label}) tapi reward '
|
||
f'tak terukur — fee pending utk cek manual', flush=True)
|
||
telegram.notify_claim_unmeasured(txid_label)
|
||
return 'claimed'
|
||
fee = (reward * BP_FEE_PERCENT).quantize(VEX_PREC, rounding=ROUND_FLOOR)
|
||
fee_status = 'pending' if fee > 0 else 'skipped'
|
||
db.record_claim(now.strftime('%Y-%m-%d'), txid, float(reward), float(fee),
|
||
fee_status, now_iso)
|
||
print(f'[OK] klaim reward {reward} VEX (txid {txid_label}) | '
|
||
f'fee {fee} VEX → {BP_FEE_WALLET}', flush=True)
|
||
telegram.notify_claim(reward, fee, BP_FEE_WALLET)
|
||
return 'claimed'
|
||
|
||
|
||
def poll_claim(deadline):
|
||
"""Poll `claimrewards` tiap `CLAIM_RETRY_SECONDS` sampai sukses / deadline.
|
||
|
||
Return `claimed` | `timeout` (deadline lewat — jadwal dihitung ulang).
|
||
V54/B16: baseline `last_claim_time` dihitung SEKALI di awal window dan
|
||
dipakai utk SEMUA attempt — klaim yang mendarat tapi baca-pertama-nya basi
|
||
terdeteksi di attempt berikutnya (baseline orisinil vs `last_after` maju),
|
||
⊥ baseline ikut maju tiap attempt → retry-loop permanen.
|
||
"""
|
||
try:
|
||
last_before = fetch_producer_last_claim()
|
||
except Exception:
|
||
last_before = None
|
||
while _utcnow() < deadline:
|
||
status = _try_claim_once(last_before)
|
||
if status == 'claimed':
|
||
return 'claimed'
|
||
time.sleep(CLAIM_RETRY_SECONDS)
|
||
print(f'[Peringatan] jendela klaim habis tanpa sukses '
|
||
f'({deadline.isoformat(timespec="seconds")})', flush=True)
|
||
return 'timeout'
|
||
|
||
|
||
def _send_fee(claim):
|
||
"""Kirim fee satu baris `claim_runs` (retry-until-sent, verifikasi V22).
|
||
|
||
`claim` = baris dari `db.pending_claim_fee()`. Return True bila terkirim
|
||
atau fee 0 (skipped); False → diulang siklus berikutnya.
|
||
"""
|
||
claim_id, run_date = claim[0], claim[1]
|
||
fee = Decimal(str(claim[4]))
|
||
if fee <= 0:
|
||
db.update_claim_fee(claim_id, 'skipped')
|
||
return True
|
||
first_fail = claim[5] != 'failed' # notif gagal ⊥ sekali per klaim
|
||
memo = f'BP FEE {run_date}'
|
||
txid = None
|
||
try:
|
||
signed = dist.build_signed_transfer(BP_FEE_WALLET, fee, memo)
|
||
txid = signed.id()
|
||
signed.send()
|
||
except Exception as exc:
|
||
if txid is not None and dist.verify_txid(txid) is True:
|
||
pass # mendarat walau timeout — lanjut tandai sent
|
||
else:
|
||
db.update_claim_fee(claim_id, 'failed', fee_txid=txid)
|
||
print(f'[GAGAL] fee {fee} VEX → {BP_FEE_WALLET}: {exc}', flush=True)
|
||
if first_fail:
|
||
telegram.notify_claim_failure(exc)
|
||
return False
|
||
db.update_claim_fee(claim_id, 'sent', fee_txid=txid)
|
||
print(f'[OK] fee {fee} VEX → {BP_FEE_WALLET} ({txid[:16]}...)', flush=True)
|
||
return True
|
||
|
||
|
||
def step():
|
||
"""Satu iterasi loop klaim. Return aksi utk uji: `fee_sent` | `fee_failed`
|
||
| `sleep` | `claimed` | `timeout`."""
|
||
claim = db.pending_claim_fee()
|
||
if claim is not None:
|
||
return 'fee_sent' if _send_fee(claim) else 'fee_failed'
|
||
now = _utcnow()
|
||
window = next_window(now)
|
||
margin = timedelta(seconds=CLAIM_RETRY_SECONDS)
|
||
if now < window - margin:
|
||
wait = (window - margin - now).total_seconds()
|
||
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
|
||
f'sampai {window.isoformat(timespec="seconds")}', flush=True)
|
||
time.sleep(max(wait, 0))
|
||
return 'sleep'
|
||
deadline = window + timedelta(seconds=POLL_SPAN_SECONDS)
|
||
return poll_claim(deadline)
|