360 lines
15 KiB
Python
360 lines
15 KiB
Python
"""Distribusi profit-share harian: kirim saldo liquid VEX akun BP ke pemilih.
|
||
|
||
Setiap hari jam `DISTRIBUTE_HOUR` (lokal, TZ), `distribute.py` membaca pemilih
|
||
segar tersimpan (snapshot terkini dari scan 08:00), menghitung porsi
|
||
proporsional stake (V20), lalu mengirim transfer `vex.token::transfer`
|
||
bertanda tangan (pyntelope, key aktif BP dari secret/env
|
||
`VEX_BP_PRIVATE_KEY`). Setiap transfer dicatat di `distribute_payments`
|
||
sebagai `pending` SEBELUM kirim (V21). Retry hanya baris `failed`; sebelum
|
||
resend, txid diverifikasi di Hyperion agar transfer yang sempat mendarat tak
|
||
terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan
|
||
(V26). Tidak ada freezing/carryover (V24): tiap run menghitung ulang segar.
|
||
"""
|
||
|
||
import sys
|
||
import time
|
||
from datetime import datetime
|
||
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
|
||
|
||
import requests
|
||
|
||
import db
|
||
import telegram
|
||
from config import (API_NODES, BP_PRIVATE_KEY, HYPERION_NODES,
|
||
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP,
|
||
TOKEN_CONTRACT, VEX_SYMBOL)
|
||
|
||
VEX_PREC = Decimal('0.0001')
|
||
|
||
# V58: jendela retry saldo sebelum menyerah ke jadwal berikutnya (~10 menit).
|
||
# Dipakai real-run saja; dry-run gagal cepat (single attempt).
|
||
BALANCE_RETRY_DELAYS = [30, 60, 90, 120, 150, 180]
|
||
|
||
|
||
def _post_json(url, payload, max_retries=3, timeout=30):
|
||
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`.
|
||
|
||
V58: mencoba SEMUA node di `API_NODES` per putaran — gagal di node satu
|
||
lanjut node berikutnya; baru menyerah setelah `max_retries` putaran penuh
|
||
tanpa satu pun node berhasil.
|
||
"""
|
||
for attempt in range(max_retries):
|
||
for node in API_NODES:
|
||
try:
|
||
resp = requests.post(f'{node}{url}', json=payload, timeout=timeout)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
except requests.RequestException:
|
||
continue
|
||
if attempt == max_retries - 1:
|
||
raise RuntimeError(
|
||
f'Gagal menghubungi semua node RPC {API_NODES} '
|
||
f'({max_retries}×)')
|
||
print(f'[Peringatan] Semua node gagal, mencoba lagi... '
|
||
f'({attempt + 1}/{max_retries})', flush=True)
|
||
time.sleep(2)
|
||
|
||
|
||
def fetch_balance():
|
||
"""V20: saldo liquid VEX akun `TARGET_BP` dari chain get_currency_balance.
|
||
|
||
V58: jalan lewat `_post_json` failover node pool — bila semua node gagal
|
||
→ raise (bukan None). Kembalikan Decimal (`0` bila kosong).
|
||
"""
|
||
data = _post_json('/v1/chain/get_currency_balance',
|
||
{'code': TOKEN_CONTRACT, 'account': TARGET_BP,
|
||
'symbol': VEX_SYMBOL})
|
||
raw = (data or [''])[0] or ''
|
||
try:
|
||
return Decimal(raw.split()[0])
|
||
except (IndexError, ValueError, InvalidOperation):
|
||
return Decimal('0')
|
||
|
||
|
||
def fetch_balance_with_retry(dry_run=False):
|
||
"""V58: fetch saldo dgn retry backoff (~10 menit) di real-run.
|
||
|
||
Bila SEMUA node di `API_NODES` gagal (via `_post_json`), tidur
|
||
`BALANCE_RETRY_DELAYS[i]` lalu coba lagi — jendela total ~10 menit sebelum
|
||
menyerah (raise → main → notify_failure → jadwal berikutnya). Dry-run:
|
||
satu attempt saja, gagal cepat (preview tak boleh tertahan lama).
|
||
"""
|
||
delays = [] if dry_run else BALANCE_RETRY_DELAYS
|
||
for i, delay in enumerate(delays + [0]):
|
||
try:
|
||
return fetch_balance()
|
||
except Exception:
|
||
if i == len(delays):
|
||
raise
|
||
print(f'[Peringatan] Semua node gagal saat baca saldo — coba '
|
||
f'lagi dalam {delay}s ({i + 1}/{len(delays)})', flush=True)
|
||
time.sleep(delay)
|
||
|
||
|
||
def compute_shares(balance, voters):
|
||
"""V20: porsi per pemilih = floor(balance × stake ÷ total_stake), 4 desimal.
|
||
|
||
`voters` = iterable (owner, staked). Σshare ≤ balance; sisa (dust) tetap
|
||
di akun (⊥ drain penuh). Porsi nol tidak dikembalikan.
|
||
"""
|
||
total = sum((Decimal(str(s)) for _, s in voters), Decimal('0'))
|
||
if total <= 0:
|
||
return []
|
||
shares = []
|
||
for owner, staked in voters:
|
||
share = (balance * Decimal(str(staked)) / total).quantize(
|
||
VEX_PREC, rounding=ROUND_FLOOR)
|
||
if share > 0:
|
||
shares.append((owner, share))
|
||
return shares
|
||
|
||
|
||
def _get_json(url, params=None, max_retries=3, timeout=10):
|
||
"""GET JSON dengan retry + failover pool Hyperion (V60).
|
||
|
||
`url` = path di belakang host (mis. `/v2/history/get_transaction`) — coba
|
||
SETIAP `HYPERION_NODES` per putaran (semua mainnet terverifikasi layani
|
||
`/v2`), `max_retries` putaran penuh, jeda 2s antar putaran. → dict bila
|
||
sukses, None bila SEMUA host gagal SEMUA putaran (⊥ raise — pemanggil
|
||
butuh None utk jalan tak-terukur / hold, pola `verify_txid`).
|
||
"""
|
||
for attempt in range(max_retries):
|
||
for node in HYPERION_NODES:
|
||
try:
|
||
resp = requests.get(f'{node}{url}', params=params, timeout=timeout)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
if isinstance(data, dict):
|
||
return data
|
||
# V61: 200 tapi bentuk tak dikenal (list/string/proxy salah)
|
||
# ⊖ dianggap sukses — lewati host ini, coba cadangan.
|
||
continue
|
||
except requests.RequestException:
|
||
continue
|
||
if attempt < max_retries - 1:
|
||
print(f'[Peringatan] Semua node Hyperion gagal, mencoba lagi... '
|
||
f'({attempt + 1}/{max_retries})', flush=True)
|
||
time.sleep(2)
|
||
return None
|
||
|
||
|
||
def verify_txid(txid):
|
||
"""V22: cek tx benar-benar mendarat via Hyperion `get_transaction`.
|
||
|
||
→ True bila `executed`, False bila tak ditemukan/diproses, None bila
|
||
Hyperion TAK TERJANGKAU (⊥ kirim ulang bila tak bisa dipastikan).
|
||
V60: None kini berarti SEMUA `HYPERION_NODES` gagal semua putaran
|
||
(bukan satu host hiccup) — hold V59 jadi jarang palsu.
|
||
"""
|
||
data = _get_json('/v2/history/get_transaction', params={'id': txid})
|
||
if not isinstance(data, dict): # V61: bentuk tak dikenal → ⊖ asumsi apapun
|
||
return None
|
||
return bool(data.get('executed'))
|
||
|
||
|
||
def _verify_settled(txid, retries=3, delay=2):
|
||
"""V61: verifikasi txid dgn settle-retry singkat — tx yang BARU mendarat
|
||
bisa belum terindeks Hyperion (`executed: false` sesaat, pola V50/B14 di
|
||
klaim). Baca `verify_txid` beberapa kali sebelum menyimpulkan tak-mendarat.
|
||
|
||
None (SEMUA host Hyperion mati) → langsung False (⊖ buang waktu sleep utk
|
||
pool yang mati — pemanggil TAHAN). → True bila pernah `executed`; False
|
||
bila tak bisa dikonfirmasi (pemanggil TAHAN, ⊖ resend). """
|
||
for _ in range(retries):
|
||
landed = verify_txid(txid)
|
||
if landed is True:
|
||
return True
|
||
if landed is None:
|
||
return False
|
||
time.sleep(delay)
|
||
return False
|
||
|
||
|
||
def _send_rejected(resp):
|
||
"""V61: deteksi penolakan chain dari respons `send()` — WHITELIST.
|
||
|
||
pyntelope ⊥ raise pada HTTP 500 — chain rejection datang sbg body JSON
|
||
(pola V46/B11). Sukses HANYA bila respons berbentuk dict YANG MEMILIKI
|
||
`transaction_id` (nodeos selalu menyertakan pada push yang diterima);
|
||
bentuk penolakan (`{"code":500,"error":...}`), bentuk tak dikenal, maupun
|
||
`soft_fail`/`delayed` (200 tapi receipt ⊥ `executed`) → dianggap TOLAK.
|
||
→ True bila harus diperlakukan sbg gagal (⊥ tandai `sent` utk tx yang
|
||
belum tentu mendarat); False bila terbukti sukses sah.
|
||
"""
|
||
if not isinstance(resp, dict) or not resp.get('transaction_id'):
|
||
return True
|
||
receipt = ((resp.get('processed') or {}).get('receipt') or {}).get('status')
|
||
if receipt is not None and receipt != 'executed':
|
||
return True
|
||
return False
|
||
|
||
|
||
def build_signed_transfer(to, amount, memo):
|
||
"""Bangun + tanda tangani tx `vex.token::transfer` via pyntelope.
|
||
|
||
Kembalikan objek SignedTransaction (⊥ broadcast). `amount` = Decimal.
|
||
Mengambil ABI + TAPOS dari node saat `link()` — V58: coba SETIAP node di
|
||
`API_NODES` (net di-bind ke node itu, jadi broadcast `send()` ikut ke node
|
||
yang sama); baru raise bila semua node gagal.
|
||
"""
|
||
from pyntelope import Action, Authorization, Data, Net, Transaction, types
|
||
|
||
last_err = None
|
||
for node in API_NODES:
|
||
try:
|
||
net = Net(host=node)
|
||
trx = Transaction(actions=[
|
||
Action(
|
||
account=TOKEN_CONTRACT,
|
||
name='transfer',
|
||
authorization=[Authorization(actor=TARGET_BP,
|
||
permission='active')],
|
||
data=[
|
||
Data(name='from', value=types.Name(TARGET_BP)),
|
||
Data(name='to', value=types.Name(to)),
|
||
Data(name='quantity',
|
||
value=types.Asset(f'{amount:.4f} {VEX_SYMBOL}')),
|
||
Data(name='memo', value=types.String(memo)),
|
||
],
|
||
)
|
||
])
|
||
linked = trx.link(net=net)
|
||
return linked.sign(key=BP_PRIVATE_KEY)
|
||
except Exception as exc: # node mati/link gagal → coba node cadangan
|
||
last_err = exc
|
||
raise RuntimeError(
|
||
f'Gagal membangun transfer {to} di semua node RPC {API_NODES}: '
|
||
f'{last_err}')
|
||
|
||
|
||
def run_distribution(dry_run=False):
|
||
"""V21..V26: satu run distribusi harian.
|
||
|
||
Urutan: baca pemilih tersimpan → saldo → porsi → catat run + payment →
|
||
kirim tiap transfer (retry dalam hari, guard anti-duplikat) → tutup run.
|
||
Dry-run berhenti sebelum menulis DB / tanda tangan.
|
||
"""
|
||
if not dry_run and not DISTRIBUTE_ENABLED:
|
||
print('Distribusi dinonaktifkan (DISTRIBUTE_ENABLED=false) — no-op.',
|
||
flush=True)
|
||
return 0
|
||
db.ensure_first_seen_schema()
|
||
voters = db.eligible_voters()
|
||
if not voters:
|
||
print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True)
|
||
return 0
|
||
balance = fetch_balance_with_retry(dry_run=dry_run)
|
||
if balance is None or balance < VEX_PREC:
|
||
print('Saldo liquid kosong / di bawah 0.0001 VEX — no-op.', flush=True)
|
||
return 0
|
||
shares = compute_shares(balance, voters)
|
||
if not shares:
|
||
print('Tak ada porsi positif — no-op.', flush=True)
|
||
return 0
|
||
|
||
run_date = datetime.now().strftime('%Y-%m-%d')
|
||
memo = f'DATABISNISID PROFIT SHARE {run_date}'
|
||
|
||
if dry_run:
|
||
print('=== RENCANA DISTRIBUSI (dry-run, ⊥ tanda tangan) ===')
|
||
print(f'Tanggal: {run_date} | Saldo: {balance} VEX | Memo: {memo}')
|
||
for owner, share in shares:
|
||
print(f'{owner}: {share} VEX')
|
||
print(f'TOTAL: {sum(share for _, share in shares)} VEX '
|
||
f'({len(shares)} pemilih)')
|
||
return 0
|
||
|
||
if not BP_PRIVATE_KEY:
|
||
raise RuntimeError(
|
||
'VEX_BP_PRIVATE_KEY kosong — set secret dulu sebelum run nyata '
|
||
'(⊥ distribusi tanpa key)')
|
||
|
||
total_share = sum(share for _, share in shares)
|
||
telegram.notify_start(run_date, len(shares), total_share, balance)
|
||
|
||
created_at = datetime.now().isoformat(timespec='seconds')
|
||
total_stake = sum(float(s) for _, s in voters)
|
||
run_id = db.record_run(run_date, float(balance), len(voters), total_stake,
|
||
0.0, 'ok', created_at)
|
||
pending = []
|
||
for owner, share in shares:
|
||
pid = db.record_payment(run_id, owner, float(share), 'pending',
|
||
created_at)
|
||
pending.append((pid, owner, share))
|
||
|
||
print(f'Mulai distribusi: {len(pending)} transfer, memo "{memo}"',
|
||
flush=True)
|
||
failed = []
|
||
sent_amount = 0.0
|
||
for pid, owner, share in pending:
|
||
txid = None
|
||
for attempt in range(1, DISTRIBUTE_MAX_ATTEMPTS + 1):
|
||
try:
|
||
signed = build_signed_transfer(owner, share, memo)
|
||
txid = signed.id() # deterministik, sebelum broadcast
|
||
resp = signed.send()
|
||
# V59: pyntelope ⊥ raise pada HTTP 500 — penolakan chain datang
|
||
# sbg body JSON (pola V46/B11). Deteksi → perlakukan sbg gagal,
|
||
# ⊖ tandai 'sent' untuk tx yang ⊥ pernah mendarat.
|
||
if _send_rejected(resp):
|
||
raise RuntimeError(
|
||
f'chain menolak transfer: '
|
||
f"{resp.get('error', {}).get('what') or resp}")
|
||
db.update_payment_status(pid, 'sent', txid=txid)
|
||
sent_amount += float(share)
|
||
print(f'[OK] {owner}: {share} VEX ({txid[:16]}...)',
|
||
flush=True)
|
||
break
|
||
except Exception as exc:
|
||
# Timeout/penolakan setelah broadcast → tx mungkin mendarat.
|
||
# Verifikasi SEBELUM resend agar ⊥ ganda (V22). V61: HANYA
|
||
# `executed` yang boleh dilanjutkan; False (Hyperion bilang tak
|
||
# mendarat — bisa lag indeks V50/B14) MAUPUN None (Hyperion
|
||
# down) → TAHAN (tandai failed, ⊖ resend): resend membawa
|
||
# tapos/expiration baru → txid baru → ganda.
|
||
if txid is not None:
|
||
landed = _verify_settled(txid)
|
||
if landed is True:
|
||
db.update_payment_status(pid, 'sent', txid=txid)
|
||
sent_amount += float(share)
|
||
print(f'[OK] {owner}: {share} VEX '
|
||
f'(terverifikasi, {txid[:16]}...)', flush=True)
|
||
break
|
||
print(f'[Peringatan] Verifikasi txid tak tersedia '
|
||
f'untuk {owner} — ditahan (⊥ resend)',
|
||
flush=True)
|
||
db.update_payment_status(pid, 'failed', txid=txid,
|
||
error=str(exc))
|
||
failed.append((owner, exc))
|
||
print(f'[DITAHAN] {owner}: {share} VEX — {exc}',
|
||
flush=True)
|
||
break
|
||
if attempt == DISTRIBUTE_MAX_ATTEMPTS:
|
||
db.update_payment_status(pid, 'failed', txid=txid,
|
||
error=str(exc))
|
||
failed.append((owner, exc))
|
||
print(f'[GAGAL] {owner}: {share} VEX — {exc}', flush=True)
|
||
break
|
||
time.sleep(2)
|
||
|
||
status = 'ok' if not failed else 'partial'
|
||
db.update_run_status(run_id, status, round(sent_amount, 4))
|
||
telegram.notify_finish(status, len(pending) - len(failed), len(pending),
|
||
total_share, failed, run_date)
|
||
print(f'SELESAI: {len(pending) - len(failed)}/{len(pending)} terkirim '
|
||
f'(status run: {status})', flush=True)
|
||
return 0
|
||
|
||
|
||
def main():
|
||
dry_run = '--dry-run' in sys.argv
|
||
try:
|
||
return run_distribution(dry_run=dry_run)
|
||
except Exception as exc:
|
||
telegram.notify_failure(exc)
|
||
raise
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|