"""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, 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). Reward diukur = selisih saldo liquid (setelah − sebelum klaim, settle delay); 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 from decimal import Decimal, ROUND_FLOOR 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) VEX_PREC = Decimal('0.0001') SETTLE_SECONDS = 10 # tunggu finalitas sebelum baca saldo setelah klaim POLL_SPAN_SECONDS = 7200 # batas poll sekali jendela (2 jam) 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.""" last = parse_claim_time(fetch_producer_last_claim()) 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 _try_claim_once(): """Satu percobaan klaim: klaim → ukur reward → catat `claim_runs`. Return `claimed` (baris tercatat, fee pending/skipped) | `retry`. Klaim yang sudah mendarat walau timeout (verifikasi txid / `last_claim_time` maju) tetap diukur selisih saldo — reward tak hilang (V34). """ before = dist.fetch_balance() if before is None: return 'retry' txid = None landed = False try: signed = build_claim_action() txid = signed.id() signed.send() landed = True except Exception as exc: if txid is not None: verified = dist.verify_txid(txid) if verified is True: landed = True elif verified is None: # tak bisa dipastikan — klaim dlm 24 jam terakhir? → mendarat last = parse_claim_time(fetch_producer_last_claim()) if last is not None and datetime.now() - last < timedelta(hours=24): landed = True if not landed: print(f'[Peringatan] klaim gagal: {exc}', flush=True) return 'retry' time.sleep(SETTLE_SECONDS) after = dist.fetch_balance() if after is None: print('[Peringatan] saldo setelah klaim tak terbaca — reward tak ' 'diukur (cek manual)', flush=True) after = before reward = max(after - before, Decimal('0')) fee = (reward * BP_FEE_PERCENT).quantize(VEX_PREC, rounding=ROUND_FLOOR) fee_status = 'pending' if fee > 0 else 'skipped' now = datetime.now() now_iso = now.isoformat(timespec='seconds') db.record_claim(now.strftime('%Y-%m-%d'), txid, float(reward), float(fee), fee_status, now_iso) txid_label = txid[:16] + '...' if txid else '?' 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). """ while datetime.now() < deadline: status = _try_claim_once() 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 = datetime.now() 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)