"""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() return resp.json() 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 data is None: return None return bool(data.get('executed')) def _send_rejected(resp): """V59: deteksi penolakan chain dari respons `send()`. pyntelope ⊥ raise pada HTTP 500 — chain rejection datang sbg body JSON (pola V46/B11 di klaim): `{"code":500,"message":...,"error":{...}}` vs sukses `{"transaction_id":..., "processed":...}`. → True bila bentuk penolakan (ada `error`), False bila sukses/bentuk tak dikenal. """ return isinstance(resp, dict) and resp.get('error') is not None 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). Bila Hyperion # tak bisa dipastikan → TAHAN (tandai failed, jangan resend) — # V59: hold sungguhan (⊥ break hanya di attempt terakhir). if txid is not None: landed = verify_txid(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 if landed is None: 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())