Files
databisnisid/distribute.py
T

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