"""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, 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. V60: jalan lewat `dist._get_json` — failover pool `HYPERION_NODES` (bukan satu host).""" if not txid: return None data = dist._get_json('/v2/history/get_transaction', params={'id': txid}) if not isinstance(data, dict): # V61: None/daftar/bentuk tak dikenal return None if not data.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, before_balance=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. V56/B18: `before_balance` (saldo liquid SEBELUM ada attempt) juga dibawa TETAP dari awal window — klaim yang mendarat di attempt 1 lalu attempt 2 ditolak (already claimed) membuat `before` baca-ulang per attempt SUDAH termasuk reward → delta 0 → reward tak terukur → alert palsu (kasus 10 Agt: tx bfa9ab02 mendarat, app ukur tx ditolak b0a8b2f9 + before post-credit). Baseline stabil → delta fallback mengukur reward dengan benar. Bila None (pemanggil langsung), baca sendiri sbg fallback. """ before = (before_balance if before_balance is not None else 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. V56/B18: saldo liquid sebelum klaim (`before_balance`) JUGA dibaca SEKALI di awal window dan dipakai utk SEMUA attempt — kalau klaim mendarat di attempt 1, attempt 2 yang ditolak (already claimed) memakai baseline yang BELUM termasuk reward → delta fallback mengukur reward benar (⊥ before baca-ulang per attempt yang sudah post-credit → delta 0 → alert palsu). """ try: last_before = fetch_producer_last_claim() except Exception: last_before = None try: before_balance = dist.fetch_balance() except Exception: before_balance = None while _utcnow() < deadline: status = _try_claim_once(last_before, before_balance) 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() resp = signed.send() # V61: pyntelope ⊥ raise pada HTTP 500 — penolakan chain (duplicate / # insufficient-balance) datang sbg body JSON (pola V46/B11). Deteksi → # perlakukan sbg gagal, ⊖ tandai 'sent' utk fee yang ⊥ pernah mendarat. if dist._send_rejected(resp): reason = (resp.get('error', {}).get('what') if isinstance(resp, dict) else resp) raise RuntimeError(f'chain menolak fee: {reason}') 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)