Files
databisnisid/distribute.py
T
proitlab 69704fa23d failover pool Hyperion utk semua pembacaan /v2/* (V60)
verify_txid (distribute) ⊖ lagi single-host single-shot — blip Hyperion
sesaat dulu menahan payout (V59 hold) / gagal ukur reward klaim / kosongkan
SALDO LIQUID dashboard.

config.py: HYPERION_NODES (CSV VEX_HYPERION_NODES, default HYPERION_API +
API_NODES dedup, utama di depan — kedua host mainnet terverifikasi layani
/v2, probe 2026-08-13) ; distribute._get_json GET mirror _post_json V58
(3 putaran, 2s, coba semua host per putaran, semua gagal → None) dipakai
verify_txid — None kini berarti SEMUA host Hyperion gagal, hold V59 jarang
palsu ; claim.reward_from_tx via dist._get_json (tiap attempt settle V55
pool-robust) ; dashboard._get_liquid_vex loop HYPERION_NODES lokal (web ⊖
impor distribute, V31), SALDO LIQUID tetap live saat host mati

oracle: test_distribute V60 failover verify_txid + semua-mati None ;
test_claim V60 reward_from_tx failover ; test_dashboard V60 saldo cadangan +
config default HYPERION_NODES ; docs SPEC §V/V60 + §I + T53 + AGENTS +
.env.example

image web + dist + claim rebuild + push ; live smoke pool /v2/health
2026-08-13 21:30:31 +07:00

328 lines
13 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()
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())