258 lines
10 KiB
Python
258 lines
10 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, 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())
|