"""Uji verifikasi distribusi profit-share (oracle §V19..V27). `./venv/bin/python test_distribute.py` harus exit 0. Memakai DB sementara + mock jaringan/pyntelope — ⊥ transfer nyata. Verifikasi: matematika porsi (V20), memo per run (V23), urutan pending→sent/failed (V21), guard anti-duplikat (V22), no-op (V25), dry-run & ⊥ key (V26). """ import io import os import sqlite3 import sys import tempfile from contextlib import redirect_stdout from datetime import datetime, timedelta from decimal import Decimal import db import distribute as dist import telegram def check(tag, cond): if not cond: raise AssertionError(f'GAGAL: {tag}') print(f'ok: {tag}') class FakeSigned: """Objek SignedTransaction tiruan: id deterministik + send terkontrol.""" def __init__(self, txid, fails=0): self._txid = txid self._fails = fails def id(self): return self._txid def send(self): if self._fails > 0: self._fails -= 1 raise RuntimeError('broadcast timeout (mock)') return {'processed': True} def make_voters_db(path, rows): conn = sqlite3.connect(path) conn.execute('DROP TABLE IF EXISTS voters') conn.execute('CREATE TABLE voters (' 'owner TEXT PRIMARY KEY, weight TEXT NOT NULL, ' 'staked REAL NOT NULL, scanned_at TEXT NOT NULL, ' 'last_vote TEXT)') conn.executemany( 'INSERT INTO voters (owner, weight, staked, scanned_at, last_vote) ' 'VALUES (?, ?, ?, ?, ?)', rows, ) # V42: distribusi LEFT JOIN voter_first_seen — tabel harus ada. conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen (' 'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)') conn.commit() conn.close() def make_first_seen(path, rows): """V42: isi tabel voter_first_seen (owner, first_seen_at).""" conn = sqlite3.connect(path) conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen (' 'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)') conn.executemany('INSERT OR IGNORE INTO voter_first_seen ' '(owner, first_seen_at) VALUES (?, ?)', rows) conn.commit() conn.close() def fake_post(data): return type('R', (), {'raise_for_status': lambda self: None, 'json': lambda self: data})() def main(): tmp = tempfile.mkdtemp() db.DB_PATH = os.path.join(tmp, 'test.db') _orig_query = db.query # V40: stempel last_vote SEJAK SEKARANG (relatif) agar umur jatuh di band # VALID (3..28 hari) — stempel hardcode berumur <3 hari justru dibuang. fresh = (datetime.now() - timedelta(days=5)).isoformat(timespec='seconds') # Oracle: Telegram ⊥ boleh mengirim nyata — nonaktifkan dulu, dipakai lagi §V30 _tg_token = telegram.TELEGRAM_BOT_TOKEN _tg_comm = telegram.TELEGRAM_COMMUNITY_CHAT_IDS _tg_int = telegram.TELEGRAM_INTERNAL_CHAT_IDS _tg_post = telegram.requests.post telegram.TELEGRAM_BOT_TOKEN = '' telegram.TELEGRAM_COMMUNITY_CHAT_IDS = [] telegram.TELEGRAM_INTERNAL_CHAT_IDS = [] # V35: kill-switch default OFF (payout ⊥ jalan tanpa diset true) check('V35 default config off', dist.DISTRIBUTE_ENABLED is False) dist.DISTRIBUTE_ENABLED = True # run-nyata di bawah pakai enabled # ————— V20: fetch_balance — parse chain get_currency_balance ————— dist.requests.post = lambda url, json=None, timeout=30: fake_post( ['52186.0417 VEX']) check('V20 fetch_balance parse', dist.fetch_balance() == Decimal('52186.0417')) dist.requests.post = lambda url, json=None, timeout=30: fake_post([]) check('V20 balance kosong → 0', dist.fetch_balance() == Decimal('0')) # ————— V20: compute_shares — floor 4 desimal, sisa tetap di akun ————— shares = dist.compute_shares(Decimal('100.0000'), [('a', 300.0), ('b', 700.0)]) check('V20 porsi proporsional stake', shares == [('a', Decimal('30.0000')), ('b', Decimal('70.0000'))]) rem = dist.compute_shares(Decimal('0.0003'), [('a', 1.0), ('b', 1.0)]) check('V20 Σshare ≤ balance (sisa dibuang)', sum(s for _, s in rem) == Decimal('0.0002')) check('V20 sisa tetap di akun', rem == [('a', Decimal('0.0001')), ('b', Decimal('0.0001'))]) z = dist.compute_shares(Decimal('0.0002'), [('a', 1.0), ('b', 10000.0)]) check('V20 porsi nol dibuang (a)', len(z) == 1 and z[0][0] == 'b') check('V20 total_stake nol → []', dist.compute_shares(Decimal('1'), [('x', 0.0)]) == []) # ————— V26: dry-run — rencana tanpa tanda tangan / tanpa tulis DB ————— make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00', fresh)]) dist.fetch_balance = lambda: Decimal('100.0000') dist.BP_PRIVATE_KEY = '' buf = io.StringIO() with redirect_stdout(buf): rc = dist.run_distribution(dry_run=True) out = buf.getvalue() check('V26 dry-run exit 0', rc == 0) check('V26 dry-run menampilkan rencana', 'RENCANA' in out and 'aaa1' in out) conn = sqlite3.connect(db.DB_PATH) tbls = [r[0] for r in conn.execute( "SELECT name FROM sqlite_master WHERE type='table'").fetchall()] conn.close() check('V26 dry-run ⊥ menulis DB', 'distribute_runs' not in tbls) # ————— V25: no-op — pemilih kosong / saldo kecil ————— dist.fetch_balance = lambda: Decimal('100.0000') db.query = lambda sql, params=(): [] buf = io.StringIO() with redirect_stdout(buf): rc = dist.run_distribution(dry_run=False) check('V25 pemilih kosong → no-op', rc == 0 and 'no-op' in buf.getvalue()) make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00', fresh)]) dist.fetch_balance = lambda: Decimal('0.0000') db.query = _orig_query buf = io.StringIO() with redirect_stdout(buf): rc = dist.run_distribution(dry_run=False) check('V25 saldo kecil → no-op', rc == 0 and 'no-op' in buf.getvalue()) # ————— V26: run nyata tanpa key → RuntimeError ————— dist.fetch_balance = lambda: Decimal('100.0000') dist.BP_PRIVATE_KEY = '' try: dist.run_distribution(dry_run=False) raise AssertionError('V26 gagal: tanpa key harusnya RuntimeError') except RuntimeError: check('V26 tanpa VEX_BP_PRIVATE_KEY → RuntimeError', True) # ————— V21/V23/V24: run nyata — pending→sent, memo per run ————— make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00', fresh), ('bbb2', '1.0', 700.0, '2026-08-05T00:00:00', fresh)]) dist.fetch_balance = lambda: Decimal('100.0000') dist.BP_PRIVATE_KEY = '5Ktest' dist.DISTRIBUTE_MAX_ATTEMPTS = 3 calls = {'runs': [], 'pays': [], 'updates': [], 'run_updates': []} def fake_record_run(run_date, bal, voters, stake, sent, status, created): calls['runs'].append((run_date, status)) return 7 def fake_record_payment(run_id, owner, amount, status, created, txid=None, error=None): calls['pays'].append((owner, amount, status)) return len(calls['pays']) def fake_update(pid, status, txid=None, error=None, updated_at=None): calls['updates'].append((pid, status, txid)) def fake_run_update(run_id, status, total_sent): calls['run_updates'].append((run_id, status, total_sent)) db.record_run = fake_record_run db.record_payment = fake_record_payment db.update_payment_status = fake_update db.update_run_status = fake_run_update signed = [] def fake_sign(to, amount, memo): signed.append(memo) return FakeSigned('tx' + to) dist.build_signed_transfer = fake_sign buf = io.StringIO() with redirect_stdout(buf): rc = dist.run_distribution(dry_run=False) check('V21 run nyata exit 0', rc == 0) check('V21 run tercatat (run_date)', calls['runs'][0][0] == datetime.now().strftime('%Y-%m-%d')) check('V21 2 payment pending tercatat', len([p for p in calls['pays'] if p[2] == 'pending']) == 2) today = datetime.now().strftime('%Y-%m-%d') check('V23 memo on-chain per run', all(m == f'DATABISNISID PROFIT SHARE {today}' for m in signed)) check('V21 semua sent + txid', all(u[1] == 'sent' and u[2].startswith('tx') for u in calls['updates'])) check('V24 status run ok + total_sent', calls['run_updates'] == [(7, 'ok', 100.0)]) # ————— V22: guard anti-duplikat — timeout lalu terverifikasi mendarat ————— dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txL' + to, fails=1) dist.verify_txid = lambda txid: True # tx sebenarnya mendarat calls['updates'] = [] calls['run_updates'] = [] with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=False) check('V22 tx timeout tapi mendarat → sent tanpa resend', all(u[1] == 'sent' for u in calls['updates'])) check('V22 terverifikasi dengan txid lama', all(u[2].startswith('txL') for u in calls['updates'])) check('V22 run status ok', calls['run_updates'][0][1] == 'ok') # ————— V22: verifikasi gagal (None) → tahan, jangan resend ————— dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txH' + to, fails=1) dist.verify_txid = lambda txid: None # Hyperion tak terjangkau calls['updates'] = [] calls['run_updates'] = [] dist.DISTRIBUTE_MAX_ATTEMPTS = 1 # sekali coba → ditahan with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=False) check('V22 verifikasi None → ditahan (failed)', all(u[1] == 'failed' for u in calls['updates'])) check('V22 run status partial', calls['run_updates'][0][1] == 'partial') # ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op ————— make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00', fresh)]) db.query = _orig_query dist.fetch_balance = lambda: Decimal('100.0000') _orig_record_run = db.record_run _orig_build = dist.build_signed_transfer dist.DISTRIBUTE_ENABLED = False calls['runs'] = [] calls['signs'] = [] db.record_run = lambda *a, **k: calls['runs'].append(a) or 99 dist.build_signed_transfer = (lambda to, amount, memo: calls['signs'].append(memo) or FakeSigned('txoff')) buf = io.StringIO() with redirect_stdout(buf): rc = dist.run_distribution(dry_run=False) out = buf.getvalue() check('V35 disabled → exit 0', rc == 0) check('V35 disabled → pesan dinonaktifkan', 'dinonaktifkan' in out) check('V35 disabled → ⊥ tulis DB', calls['runs'] == []) check('V35 disabled → ⊥ tanda tangan', calls['signs'] == []) buf = io.StringIO() with redirect_stdout(buf): rc = dist.run_distribution(dry_run=True) check('V35 disabled + dry-run → rencana tetap', rc == 0 and 'RENCANA' in buf.getvalue()) dist.DISTRIBUTE_ENABLED = True db.record_run = _orig_record_run dist.build_signed_transfer = _orig_build # ————— V40/V42: reward utk vote matang+segar; REVOTE langsung VALID ————— def _iso(days_ago): return (datetime.now() - timedelta(days=days_ago)).isoformat(timespec='seconds') make_voters_db(db.DB_PATH, [ ('new1', '1.0', 1200.0, fresh, _iso(1)), # BARU — belum matang ('mid5', '1.0', 1200.0, fresh, _iso(5)), # VALID — matang+segar ('rev7', '1.0', 1200.0, fresh, _iso(1)), # REVOTE — pemilih lama revote ('old9', '1.0', 1200.0, fresh, _iso(40)), # basi — lewat 28 hari ]) # V42: first_seen — new1 baru (umur 1 hr), rev7 lama (first_seen 60 hr lalu). make_first_seen(db.DB_PATH, [('new1', _iso(1)), ('rev7', _iso(60))]) db.query = _orig_query dist.fetch_balance = lambda: Decimal('300.0000') dist.BP_PRIVATE_KEY = '5Ktest' dist.DISTRIBUTE_MAX_ATTEMPTS = 3 signed_to = [] dist.build_signed_transfer = (lambda to, amount, memo: signed_to.append(to) or FakeSigned('txV' + to)) calls['runs'] = [] db.record_run = lambda *a, **k: calls['runs'].append(a) or 7 with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=False) check('V42 REVOTE (umur 1 hr, first_seen lama) LANGSUNG dibayar', 'rev7' in signed_to) check('V40 pemilih baru (umur 1 hr) ⊥ dibayar', 'new1' not in signed_to) check('V40 basi (umur 40 hr) ⊥ dibayar', 'old9' not in signed_to) check('V40 matang+segar (umur 5 hr) dibayar', 'mid5' in signed_to) check('V40/V42 tepat 2 terkirim (mid5 + rev7)', sorted(signed_to) == ['mid5', 'rev7']) db.record_run = _orig_record_run dist.build_signed_transfer = _orig_build # ————— V30: notifikasi Telegram — dua kanal, komunitas brief/internal detail ————— tg = {'posts': []} def tg_post(url, json=None, timeout=None): tg['posts'].append((url, json['chat_id'], json['text'])) return fake_post({}) telegram.TELEGRAM_BOT_TOKEN = '123:TEST' telegram.TELEGRAM_COMMUNITY_CHAT_IDS = ['-1001'] telegram.TELEGRAM_INTERNAL_CHAT_IDS = ['-1002'] telegram.requests.post = tg_post # send_text tanpa token → no-op senyap (⊥ post) telegram.TELEGRAM_BOT_TOKEN = '' telegram.send_text('tidak boleh terkirim') telegram.send_internal('tidak boleh terkirim') check('V30 ⊥ token → no-op senyap', tg['posts'] == []) telegram.TELEGRAM_BOT_TOKEN = '123:TEST' # dry-run → ⊥ notif Telegram make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00', fresh), ('bbb2', '1.0', 700.0, '2026-08-05T00:00:00', fresh)]) db.query = _orig_query dist.fetch_balance = lambda: Decimal('100.0000') dist.BP_PRIVATE_KEY = '5Ktest' dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txT' + to) dist.DISTRIBUTE_MAX_ATTEMPTS = 3 dist.verify_txid = lambda txid: True calls['runs'] = [] with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=True) check('V30 dry-run ⊥ notif', tg['posts'] == []) # run nyata → komunitas 2 brief + internal 2 detail calls['updates'] = [] calls['run_updates'] = [] with redirect_stdout(io.StringIO()): rc = dist.run_distribution(dry_run=False) check('V30 run nyata exit 0', rc == 0) comm = [p for p in tg['posts'] if p[1] == '-1001'] intern = [p for p in tg['posts'] if p[1] == '-1002'] check('V30 komunitas 2 notif brief', len(comm) == 2) check('V30 komunitas brief MULAI', 'DISTRIBUSI MULAI' in comm[0][2] and 'Pemilih' not in comm[0][2]) check('V30 komunitas brief SELESAI + status', 'DISTRIBUSI SELESAI' in comm[1][2] and 'Status: ok' in comm[1][2] and 'Terkirim' not in comm[1][2]) check('V30 internal 2 notif detail', len(intern) == 2) check('V30 internal detail MULAI', 'DISTRIBUSI MULAI' in intern[0][2] and 'Pemilih: 2' in intern[0][2]) check('V30 internal detail SELESAI', '2/2 pemilih' in intern[1][2] and 'Gagal: 0' in intern[1][2]) # no-op (pemilih kosong) → ⊥ notif baru db.query = lambda sql, params=(): [] with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=False) check('V30 no-op ⊥ notif', len(tg['posts']) == 4) db.query = _orig_query # failure (⊥ key) → notif gagal ke INTERNAL saja (ops detail) dist.BP_PRIVATE_KEY = '' try: dist.main() raise AssertionError('V30 gagal: main harusnya re-raise') except RuntimeError: check('V30 failure → re-raise RuntimeError', True) new_posts = tg['posts'][4:] check('V30 gagal → internal saja', len(new_posts) == 1 and new_posts[0][1] == '-1002' and 'DISTRIBUSI GAGAL' in new_posts[0][2]) dist.BP_PRIVATE_KEY = '5Ktest' telegram.TELEGRAM_BOT_TOKEN = _tg_token telegram.TELEGRAM_COMMUNITY_CHAT_IDS = _tg_comm telegram.TELEGRAM_INTERNAL_CHAT_IDS = _tg_int telegram.requests.post = _tg_post print('\nSEMUA UJI PASS') if __name__ == '__main__': sys.exit(main())