Files
databisnisid/distribute.py
T

360 lines
15 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.
"""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())