Files
databisnisid/claim.py
T

377 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)