222 lines
8.6 KiB
Python
222 lines
8.6 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, 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)
|