"""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, timedelta, timezone from decimal import Decimal, ROUND_FLOOR, InvalidOperation import requests import db import telegram from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API, DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP, TOKEN_CONTRACT, VEX_MATURITY_DAYS, VEX_STALE_DAYS, VEX_SYMBOL) VEX_PREC = Decimal('0.0001') def _eligible_cutoffs(): """V40/V42: jendela reward = vote matang s/d segar: `now − STALE < last_vote ≤ now − MATURITY` (UTC-naive, konsisten dgn stempel `last_vote` dari scan). Pemilih baru (< VEX_MATURITY_DAYS) & basi (> VEX_STALE_DAYS) ⊥ dibayar. V42: pemilih yang SUDAH PERNAH terlihat (REVOTE, `first_seen ≤ now − MATURITY`) dianggap VALID langsung — ⊥ tunggu masa matang; hanya pemilih baru (first_seen tak ada / < MATURITY lalu) yang harus matang dulu.""" now = datetime.now(timezone.utc).replace(tzinfo=None) stale_cutoff = (now - timedelta(days=VEX_STALE_DAYS)).isoformat(timespec='seconds') mature_cutoff = (now - timedelta(days=VEX_MATURITY_DAYS)).isoformat(timespec='seconds') return stale_cutoff, mature_cutoff 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 = requests.post(url, json=payload, timeout=timeout) resp.raise_for_status() return resp.json() except requests.RequestException: 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_balance(): """V20: saldo liquid VEX akun `TARGET_BP` dari chain get_currency_balance. Kembalikan Decimal (`0` bila kosong). Gagal total → None. """ data = _post_json(f'{API_NODE}/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 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 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). """ try: resp = requests.get(f'{DATABISNIS_API}/v2/history/get_transaction', params={'id': txid}, timeout=10) resp.raise_for_status() return bool(resp.json().get('executed')) except requests.RequestException: return 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()` (jaringan, sesuai pola retry di tingkat panggil). """ from pyntelope import Action, Authorization, Data, Net, Transaction, types net = Net(host=API_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) 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() stale_cutoff, mature_cutoff = _eligible_cutoffs() voters = db.query( 'SELECT v.owner, v.staked FROM voters v ' 'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner ' 'WHERE v.last_vote > %s ' ' AND (v.last_vote <= %s ' ' OR (fs.first_seen_at IS NOT NULL ' ' AND fs.first_seen_at <= %s))', [stale_cutoff, mature_cutoff, mature_cutoff]) if not voters: print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True) return 0 balance = fetch_balance() 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 signed.send() 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 setelah broadcast → tx mungkin mendarat. Verifikasi # sebelum resend agar ⊥ ganda (V22). Bila Hyperion tak bisa # dipastikan → tahan (tandai failed, jangan resend). 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) 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())