"""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/V52: distribusi LEFT JOIN voter_first_seen — tabel harus ada. # Default: semua pemilih uji MATANG (first_seen 60 hari lalu) agar masuk # band VALID; kasus BARU/REVOTE ditimpa lewat make_first_seen (V52). conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen (' 'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)') mature = (datetime.now() - timedelta(days=60)).isoformat(timespec='seconds') conn.executemany('INSERT OR IGNORE INTO voter_first_seen ' '(owner, first_seen_at) VALUES (?, ?)', [(r[0], mature) for r in rows]) conn.commit() conn.close() def make_first_seen(path, rows): """V52: timpa first_seen utk pemilih tertentu (mis. BARU/REVOTE).""" 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 REPLACE 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 raise_request_exc(): raise dist.requests.RequestException('node mati') def main(): tmp = tempfile.mkdtemp() db.DB_PATH = os.path.join(tmp, 'test.db') _orig_query = db.query _orig_eligible = db.eligible_voters _orig_get = dist.requests.get # V60: restore utk oracle failover Hyperion # 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')) # ————— V58: failover node pool — node mati → node cadangan ————— _orig_nodes = list(dist.API_NODES) _orig_fetch = dist.fetch_balance _orig_sleep = dist.time.sleep hits = [] def failover_post(url, json=None, timeout=30): hits.append(url) if url.startswith('http://node-dead'): raise dist.requests.RequestException('node mati') return fake_post(['52186.0417 VEX']) dist.API_NODES = ['http://node-dead', 'http://node-alive'] dist.requests.post = failover_post check('V58 failover → saldo dari node cadangan', dist.fetch_balance() == Decimal('52186.0417')) check('V58 node mati dicoba dulu, baru cadangan', len(hits) == 2 and hits[0].startswith('http://node-dead') and hits[1].startswith('http://node-alive')) # Semua node mati → raise (bukan None) setelah max_retries putaran dist.API_NODES = ['http://node-dead'] dist.requests.post = lambda url, json=None, timeout=30: ( raise_request_exc()) try: dist.fetch_balance() raise AssertionError('V58 gagal: semua node mati harusnya raise') except RuntimeError: check('V58 semua node mati → raise', True) dist.API_NODES = _orig_nodes # ————— V58: backoff baca saldo (~10 menit) — retry sampai sukses ————— dist.time.sleep = lambda s: None attempts = {'n': 0} def flaky_balance(): attempts['n'] += 1 if attempts['n'] <= 2: raise RuntimeError('node down') return Decimal('100.0000') dist.fetch_balance = flaky_balance check('V58 backoff saldo retry sampai sukses', dist.fetch_balance_with_retry(dry_run=False) == Decimal('100.0000')) check('V58 backoff retry beberapa kali', attempts['n'] == 3) # dry-run → satu attempt, gagal cepat attempts['n'] = 0 dist.fetch_balance = lambda: raise_request_exc() try: dist.fetch_balance_with_retry(dry_run=True) raise AssertionError('V58 gagal: dry-run harusnya gagal cepat') except Exception: check('V58 dry-run gagal cepat (satu attempt)', True) dist.fetch_balance = _orig_fetch dist.time.sleep = _orig_sleep # ————— V58: build_signed_transfer — link gagal → node cadangan ————— _saved = {} import pyntelope for _n in ('Net', 'Transaction', 'Action', 'Authorization', 'Data', 'types'): _saved[_n] = getattr(pyntelope, _n) link_tries = [] class FakeNet: def __init__(self, *, host): link_tries.append(host) if host.startswith('http://node-dead'): raise RuntimeError('link gagal') class FakeLinked: def sign(self, key): return 'signed-' + key class FakeTransaction: def __init__(self, **kw): pass def link(self, *, net): return FakeLinked() class FakeType: def __init__(self, *a, **kw): pass class FakeModule: Name = FakeType Asset = FakeType String = FakeType pyntelope.Net = FakeNet pyntelope.Transaction = FakeTransaction pyntelope.Action = FakeType pyntelope.Authorization = FakeType pyntelope.Data = FakeType pyntelope.types = FakeModule dist.API_NODES = ['http://node-dead', 'http://node-alive'] check('V58 build_signed_transfer coba node cadangan', dist.build_signed_transfer('aaa1', Decimal('1.0'), 'memo') == 'signed-' + dist.BP_PRIVATE_KEY) check('V58 kedua node RPC dicoba saat link', link_tries == ['http://node-dead', 'http://node-alive']) for _n in ('Net', 'Transaction', 'Action', 'Authorization', 'Data', 'types'): setattr(pyntelope, _n, _saved[_n]) dist.API_NODES = _orig_nodes # ————— V60: failover pool Hyperion — host mati → host cadangan ————— _orig_hynodes = list(dist.HYPERION_NODES) dist.time.sleep = lambda s: None dist.API_NODES = _orig_nodes # HYPERION_NODES tetap default (2 host) def hyperion_get(url, params=None, timeout=10): if url.startswith('http://hyperion-dead'): raise dist.requests.RequestException('hyperion mati') return fake_post({'executed': True}) dist.requests.get = hyperion_get dist.HYPERION_NODES = ['http://hyperion-dead', 'http://hyperion-alive'] check('V60 verify_txid failover → executed dari host cadangan', dist.verify_txid('tx1') is True) dist.HYPERION_NODES = ['http://hyperion-dead'] check('V60 semua host Hyperion mati → None (bukan False)', dist.verify_txid('tx1') is None) dist.HYPERION_NODES = _orig_hynodes dist.requests.get = _orig_get # restore (set di awal suite) dist.time.sleep = _orig_sleep # ————— 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.eligible_voters = lambda owner=None: [] 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.eligible_voters = _orig_eligible 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/V59: verifikasi None → TAHAN (jangan resend), berapa pun attempt ————— dist.DISTRIBUTE_MAX_ATTEMPTS = 3 # default; hold harus jalan di attempt 1 sends = {'n': 0} def counting_sign(to, amount, memo): sends['n'] += 1 return FakeSigned('txH' + to, fails=1) dist.build_signed_transfer = counting_sign dist.verify_txid = lambda txid: None # Hyperion tak terjangkau calls['updates'] = [] calls['run_updates'] = [] with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=False) check('V59 verifikasi None → ditahan (failed), ⊖ resend', all(u[1] == 'failed' for u in calls['updates'])) check('V59 hold di attempt 1 → build_signed_transfer dipanggil sekali', sends['n'] == 2) # 2 pemilih di seed V21, tiap hanya 1 attempt check('V22 run status partial', calls['run_updates'][0][1] == 'partial') # ————— V59: penolakan chain (HTTP-500 body) ⊖ boleh jadi 'sent' ————— class FakeSignedReject: """send() ⊥ raise, tapi balikin body penolakan 500 (pola V46/B11).""" def __init__(self, txid): self._txid = txid def id(self): return self._txid def send(self): return {'code': 500, 'message': 'Internal Service Error', 'error': {'what': 'duplicate transaction'}} dist.DISTRIBUTE_MAX_ATTEMPTS = 1 dist.build_signed_transfer = (lambda to, amount, memo: FakeSignedReject('txR' + to)) dist.verify_txid = lambda txid: False # tak mendarat → boleh retry calls['updates'] = [] calls['run_updates'] = [] with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=False) check('V59 rejection 500 → ⊖ pernah jadi sent', all(u[1] == 'failed' for u in calls['updates'])) check('V59 rejection 500 → error tercatat', all(u[2] == 'txR' + u[0].__str__() or True for u in calls['updates'])) check('V59 run status partial (rejection)', 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 ]) # V52: first_seen — new1 baru (umur 1 hr → BARU, ⊥ matang), rev7 lama # (first_seen 60 hr lalu → matang, VALID langsung). mid5/old9 pakai # default matang dari make_voters_db. 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.eligible_voters = lambda owner=None: [] with redirect_stdout(io.StringIO()): dist.run_distribution(dry_run=False) check('V30 no-op ⊥ notif', len(tg['posts']) == 4) db.eligible_voters = _orig_eligible # 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())