Files
databisnisid/SPEC.md
T

65 KiB
Raw Blame History

SPEC — databisnisid voter fetcher (Python)

§G — Goal

Scan Vexanium voters table → persist databisnisid voters + weight in SQLite. Re-runnable scheduled. Python 3.12, venv ./venv. Profit-share: tiap hari 10:00 lokal → kirim saldo liquid VEX akun BP ke pemilih segar (pro-rata stake), catat tiap transfer (riwayat per-pemilih) + view riwayat dashboard. Auto-claim: tiap hari di jendela klaim (last_claim_time + 24 jam) → vexcore::claimrewards utk TARGET_BP (poll tiap menit sampai diterima chain) → kirim 10% reward ke fee wallet bpdbsjasprod; reward mengisi saldo yang dibagi distribute.

§C — Constraints

  • venv ./venv exists (py3.12, only pip) — install deps there, ⊥ global
  • lib: requests, flask, python-dotenv, gunicorn, pyntelope ! pip-installed; pymysql utk backend mysql (opt-in); others stdlib
  • config via env/.env (config.py, python-dotenv); default = konstanta lama
  • db: db.py abstraksi backend — sqlite (default, stdlib sqlite3 → DB_PATH, WAL → pembaca tak terblokir) atau mysql (MariaDB/MySQL via PyMySQL, VEX_DB_*); container uji via docker-compose.dev.yml
  • api: API_NODE (default https://v2.vexascan.com:2096, public, flaky → retry)
  • saldo liquid akun BP: API databisnis (HYPERION_API, default https://api.databisnis.id) — diambil dashboard, cache TTL DASH_LIQUID_TTL (default 60s), cooldown kegagalan
  • contract=scope=vexcore (≠ vexio, EOS convention ⊥) — ⊥ env-able
  • filter: ∃ voter where producers.length==1 & producers[0]==TARGET_BP
  • staked scaled ×10000 → store ÷10000
  • weight = last_vote_weight (string, high-precision decimal) — keep verbatim
  • staked ≥ MIN_STAKED_VEX (default 1000 VEX) ! required (÷10000 dulu)
  • last_vote (perkiraan waktu revote) diturunkan saat scan dari rasio last_vote_weight/staked_raw (V13); VEX_STALE_DAYS default 28
  • voter basi (revote > VEX_STALE_DAYS) atau last_vote tak diketahui → dibuang di normalize (⊥ disimpan); hanya pemilih segar yang masuk DB
  • output/comments: Indonesian
  • run harian → ganti snapshot (DROP+INSERT tiap skan; ⊥ history/append)
  • produksi stack: Docker Swarm multi-node (node DB server5.saltis.id) — image dari registry git.proit.id/proitlab/databisnisid-web & -scan & -dist (⊥ build: di stack), deploy docker stack deploy -c docker-compose.yml databisnisid; mariadb bind mount /data/db/mariadb/databisnisid/data pd node server5.saltis.id (placement constraint node.hostname == server5.saltis.id), web/scan/distribute di node mana pun via overlay appnet; scan berjalan pd jam SCAN_HOURS (default 0,8,16, waktu lokal TZ default Asia/Jakarta) + skan-awal SCAN_RUN_ON_START; distribute berjalan pd jam DISTRIBUTE_HOUR (default 10) via distribute_loop.py (image -dist), key aktif BP dibaca dari bind NFS /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro (⊥ interpolasi)
  • token VEX = kontrak vex.token (⊥ eosio.token — tak ada di chain; konstanta chain, ⊥ env-able), precision 4 desimal, issuer vexcore
  • chain_id = f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f (nodeos v1.2.2) — utk signing
  • saldo distribusi: chain get_currency_balance pd vex.token utk TARGET_BP (⊥ Hyperion; = SALDO LIQUID dashboard)
  • signing: pyntelope (Python; eospy→eospyo→pyntelope); key aktif BP VEX_BP_PRIVATE_KEY — ⊥ repo, ⊥ log; di stack dibaca dari /app/.env hasil bind NFS config.env (load_dotenv), di dev via env/.env lokal
  • distribusi: harian DISTRIBUTE_HOUR (default 10) lokal TZ → SELURUH saldo liquid pro-rata staked ke pemilih segar tersimpan (snapshot terkini = scan 08:00)
  • share = floor(balance × stake ÷ total_stake), 4 desimal; sisa (dust) TETAP di akun (⊥ drain penuh)
  • memo per run, sama utk semua transfer: DATABISNISID PROFIT SHARE YYYY-MM-DD
  • riwayat: tiap transfer dicatat per-pemilih (distribute_payments: pending→sent/failed) + ringkasan per run (distribute_runs); append ⊥ hapus
  • retry dalam hari: ulang hanya baris failed; ⊥ kirim ulang sent; sebelum resend → verifikasi txid on-chain (Hyperion get_transaction → executed)
  • ⊥ freezing/carryover: tiap hari hitung ulang segar dari snapshot tersimpan; kegagalan sisa → ikut run besok
  • pemilih kosong | saldo < 1 unit shareable → no-op senyap (⊥ catat)
  • kill-switch distribusi: DISTRIBUTE_ENABLED default FALSE — payout ⊥ jalan tanpa diset true (V35); --dry-run tetap boleh (read-only)
  • notifikasi Telegram dua kanal (V30/V36): bot token tunggal TELEGRAM_BOT_TOKEN; KOMUNITAS TELEGRAM_COMMUNITY_CHAT_IDS hanya brief mulai/selesai distribusi; INTERNAL TELEGRAM_INTERNAL_CHAT_IDS detail distribusi + semua kegagalan + klaim reward (⊥ komunitas); best-effort (⊥ crash); tanpa token/daftar → kanal senyap; di stack nilai dari NFS config.env (/app/.env)
  • dashboard read-only + view riwayat /history (runs + per-voter log)
  • CPU/NET akun BP besar (limit 41.5M µs, NET 3.26M words) → ratusan transfer/hari sepele; Vexanium tanpa fee
  • klaim reward BP: aksi vexcore::claimrewards (owner=TARGET_BP), sign pyntelope key aktif VEX_BP_PRIVATE_KEY; 1 klaim per 24 jam — jendela = last_claim_time (tabel producers) + 24 jam; klaim di luar jendela ditolak chain
  • reward diukur = selisih saldo liquid (get_currency_balance) setelah − sebelum klaim (settle delay); fee = floor(VEX_BP_FEE_PERCENT × reward), 4 desimal (ROUND_FLOOR), → VEX_BP_FEE_WALLET (default bpdbsjasprod), memo BP FEE YYYY-MM-DD; reward 0 → fee skipped
  • service claim stack (image -claim): claim_loop.py tidur sampai mendekati jendela (margin), lalu poll claimrewards tiap CLAIM_RETRY_SECONDS (default 60) sampai diterima chain; ⊥ spam 1440 tx gagal/hari
  • siklus klaim selesai ⊥ bila klaim tercatat di claim_runs DAN fee terkirim; fee pending/failed diulang tiap siklus + dilanjutkan saat restart (crash-safe, ⊥ fee hilang); setelah klaim sukses jendela dihitung ulang dari last_claim_time segar
  • ⊥ mutex dgn distribute: distribute beku saldo di awal run (V20) → klaim yang jatuh di tengah run didefer ke run berikutnya
  • klaim butuh VEX_BP_PRIVATE_KEY + HYPERION_API (verifikasi txid V22); Telegram notif klaim sukses/gagal (best-effort V30)

§I — Interfaces

api: POST https://v2.vexascan.com:2096/v1/chain/get_table_rows → body {json:true, code:vexcore, scope:vexcore, table:voters, lower_bound, limit:500} → rows[owner, proxy, producers[], staked, last_vote_weight, proxied_vote_weight, is_proxy], more, next_key db: db.py → table voters (owner PK, weight, staked, scanned_at, last_vote) via connect/query/queryone/replace_snapshot; sqlite = DROP+CREATE+INSERT (WAL), mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi; ganti tiap skan; weight disimpan (audit/asal last_vote) tapi ⊥ ditampilkan web cmd: ./venv/bin/python get_voters.py → stdout summary (id-ID) web: GET / (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 50/halaman, ORDER BY staked DESC, owner ASC; ⊥ mutation (read-only) web: GET / + ?q=<substring> → filter owner (server-side, no-JS fallback); pager bawa q web: GET /api/search?q= → JSON {query,count,cap,results:[{owner,staked,weight,rank,last_vote,total_reward}]}, rank global, cap 500 web: GET /api/voter/<owner> → JSON 200 {owner, is_valid_voter} (payout-eligible check, V57); selalu 200, ⊖ 404; DB-snapshot saja, ⊖ format-validation env: VEX_TARGET_BP, VEX_API_NODE, VEX_API_NODES (CSV pool RPC failover distribusi, V58), VEX_HYPERION_NODES (CSV pool Hyperion failover V60, default HYPERION_API + API_NODES dedup), VEX_DB_PATH, VEX_DB_BACKEND, VEX_DB_HOST, VEX_DB_PORT, VEX_DB_USER, VEX_DB_PASS, VEX_DB_NAME, VEX_MIN_STAKED_VEX, DASH_PAGE_SIZE, DASH_HOST, DASH_PORT, DASH_WORKERS, VEX_STALE_DAYS, HYPERION_API (nama lama DATABISNIS_API masih dibaca, backward-compat), DASH_LIQUID_TTL, SCAN_HOURS, SCAN_RUN_ON_START, TZ, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY, DISTRIBUTE_FIRST_RUN (dashboard banner V38; web service di stack menerima TZ + jadwal) — via config.py (.env) api: POST https://v2.vexascan.com:2096/v1/chain/get_currency_balance → body {code:vex.token, account, symbol:VEX} → ["X.XXXX VEX"] api: POST https://v2.vexascan.com:2096/v1/chain/get_currency_stats → {supply, max_supply, issuer} (cek precision) api: POST https://v2.vexascan.com:2096/v1/chain/push_transaction (signed via pyntelope) → txid api: POST https://v2.vexascan.com:2096/v1/chain/get_info → chain_id (utk signing) api: GET https://api.databisnis.id/v2/history/get_transaction?id=<txid> (Hyperion) → {executed, trx_id, lib} — guard resend db: table distribute_runs (run_id PK, run_date, balance_start, total_voters, total_staked, total_sent, status, created_at) via db.py; append db: table distribute_payments (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via db.py; append; ⊖ kolom memo (V23 hanya on-chain) cmd: ./venv/bin/python distribute.py → stdout id-ID (rencana + hasil); --dry-run → rencana tanpa sign web: GET /history → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile env: VEX_BP_PRIVATE_KEY (stack: bind NFS /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env → /app/.env dibaca load_dotenv, :ro; dev: env/.env lokal), DISTRIBUTE_HOUR (default 10), DISTRIBUTE_WEEKDAY (default sat), DISTRIBUTE_FIRST_RUN (YYYY-MM-DD, default baked 2026-08-17; env override), DISTRIBUTE_MAX_ATTEMPTS, DISTRIBUTE_ENABLED (default false), TELEGRAM_BOT_TOKEN, TELEGRAM_COMMUNITY_CHAT_IDS, TELEGRAM_INTERNAL_CHAT_IDS (CSV) — via config.py (.env) cmd: ./venv/bin/python telegram.py — modul notifikasi (⊥ CLI); TELEGRAM_BOT_TOKEN + daftar chat kosong → kanal no-op senyap; send_text(text, chat_ids=None) = komunitas, send_internal(text) = internal db: table claim_runs (claim_id PK, run_date, claim_txid, reward, fee_amount, fee_status[pending|sent|failed|skipped], fee_txid, claimed_at, fee_sent_at, created_at) via db.py; append; ⊖ kolom memo (memo fee on-chain saja, V23 pola) api: POST https://v2.vexascan.com:2096/v1/chain/get_table_rows → body {json:true, code:vexcore, scope:vexcore, table:producers, limit:1, lower_bound, upper_bound} → rows[owner, last_claim_time, unpaid_blocks] — sumber jendela klaim api: aksi vexcore::claimrewards (owner name) sign pyntelope → push_transaction → txid; verifikasi via Hyperion get_transaction (V22) sebelum resend cmd: ./venv/bin/python claim_loop.py → scheduler klaim harian (image databisnisid-claim, service claim); claim.py modul logika klaim (⊥ CLI) env: CLAIM_RETRY_SECONDS, VEX_BP_FEE_WALLET, VEX_BP_FEE_PERCENT — via config.py (.env); key/Telegram dari bind NFS config.env di stack (sama dgn distribute) cmd: ./venv/bin/python test_claim.py — oracle V32..V34 (mock chain+pyntelope, temp DB) cmd: ./venv/bin/python test_distribute_loop.py — oracle V37 (logika kalender mingguan + one-off, ⊥ jaringan/DB)

§V — Invariants

V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry) V2: page loop terminates: advance lower_bound → last owner; stop on more=false | empty rows V3: ∀ page after first → shift 1st row (dup guard) V4: skip owner="...........q" (uint64 0 sentinel) rows V5: persist ∃ row where producers length 1 & [0]==TARGET_BP V6: weight stored string verbatim, ⊥ float conversion V7: each scan replaces table; ∀ row → latest snapshot; ⊥ append/duplicate owner; V41: scan juga record_first_seen tiap owner ke tabel voter_first_seen (INSERT OR IGNORE/IGNORE, append-only, PK owner) — first_seen_at = kemunculan pertama, ⊥ overwrite; voters tetap snapshot murni V8: normalize coerces staked → float (node kadang memberi string) V9: normalize drops voters where staked < 1000 VEX (setelah ÷10000) V10: dashboard baca voters.db → rows sort staked DESC (terbesar dulu); ⊥ mutasi DB V11: search q → filter owner substring (LIKE, wildcard-escape, case-insensitive) di SEMUA band tampil (V40); urut+paging terjaga; ?q= (daftar datar ber-tag) & /api/search read-only; tiap hasil membawa group (baru|valid|kadaluarsa) + expired; rank = ordinal DALAM kelompok (⊥ rank global lintas band, V40) V12: produksi web disajikan gunicorn (dashboard:app, gunicorn.conf.py); DASH_WORKERS default 2; .env ikut termuat via import config V13: last_vote = epoch 2000-01-01 + round(52 × log2(last_vote_weight / (staked×10000))) PEKAN (Vexanium/EOSIO stake2vote: eksponen = int64((now − epoch)/(86400×7)) / 52.0 → bobot dikuantisasi pekan utuh; jadi tanggal kelipatan 7 hari, 00:00:00; regresi oracle baris nyata ..tg 1291 pekan → 2024-09-28, 1.crownz 1314 pekan → 2025-03-08); None bila bobot/stake nol; estimasi yang melewati now (unstake tanpa revote) diklamp ke now; voter dgn last_vote=None dibuang di normalize (bobot tak bisa diverifikasi → tanggal tak diketahui, ⊥ bisa diklasifikasi); V40: vote basi (> VEX_STALE_DAYS) TIDAK lagi dibuang di scan — SEMUA umur disimpan; banding/tampil/bayar menghitung umur saat query (⊥ peran scan) V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA; stats = SALDO LIQUID/TOTAL PEMILIH/STAKE TERTINGGI atas semua baris TAMPIL (V40: ⊖ yang tersembunyi > 31 hari); TOTAL VEX dihapus (V48: raw single-vote stake mudah disalahartikan); V40/V42/V43: BARU (PEMILIH BARU · BELUM MATANG, new saja) + daftar utama — kadaluarsa menyatu DI ATAS baris VALID (dipin, sorot amber + badge VOTE ULANG + legenda BARIS AMBER = VOTE LEWAT …), lalu VALID (pager) = matang-baru + REVOTE (tag mint REVOTE + legenda TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG, V43) + badge amber dashed VOTE ULANG SEGERA (V43) pada baris exp_cut < last_vote ≤ warn_cut + legenda TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH; REVOTE ⊖ lagi bagian sendiri (V42); /api/search hasil {owner,staked,weight,rank,last_vote,total_reward,group,expired,expiring} — group = baru|revote|valid|kadaluarsa (weight disimpan & di-API, ⊥ dirender); V47: catatan stats-note menyatakan TOTAL PEMILIH = akun suara tunggal ke BP stake ≥ MIN_STAKED_VEX; V51: nama akun (index + history pay-list + search live) jadi link https://vexascan.com/account/{owner} (.owner-link, target=_blank rel=noopener) — display-only V16: saldo liquid akun TARGET_BP dari GET {HYPERION_API}/v2/state/get_account (parse account.core_liquid_balance) → sel stat SALDO LIQUID; cache in-memory per worker TTL DASH_LIQUID_TTL; gagal fetch → nilai lama (atau — bila belum pernah sukses) + at ikut diset (cooldown, ⊥ pukulan berulang); read-only, ⊥ tulis DB V17: penyimpanan lewat db.py (backend VEX_DB_BACKEND = sqlite default | mysql); query berbagi sintaks pakai placeholder %s (diterjemahkan ? utk sqlite); LIKE escape pakai ESCAPE '!' — backslash memutus literal string MySQL (B2); db.query → list (fetchall sqlite=list / PyMySQL=tuple diseragamkan, B3); replace snapshot mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi (MVCC → pembaca dapat snapshot konsisten saat ganti harian, tak kena torn read); oracle utama (test_get_voters/test_dashboard) tetap sqlite hermetik; test_mariadb.py opt-in (skip exit 0 bila VEX_DB_BACKEND != mysql) V18: stack produksi = Docker Swarm (docker-compose.yml, docker stack deploy -c docker-compose.yml databisnisid): mariadb bind mount /data/db/mariadb/databisnisid/data pd node server5.saltis.id + placement constraint node.hostname == server5.saltis.id (data node-lokal → mariadb harus selalu di node itu; di swarm yg ⊥ punya node itu task-nya Pending, bukan bug); web (gunicorn, publik :5000) + scan (scan_loop.py) + distribute (distribute_loop.py, image databisnisid-dist) di node mana pun, terhubung via overlay appnet; image dari registry git.proit.id/proitlab/databisnisid-web & -scan & -dist (⊥ build:); scan berjalan pd jam SCAN_HOURS (default 0,8,16, lokal TZ default Asia/Jakarta via datetime.now()) + skan-awal SCAN_RUN_ON_START (setelah DB siap) → tabel voters langsung ada (dashboard ⊥ 500); distribute loop harian DISTRIBUTE_HOUR (default 10, lokal) — ⊥ distribusi-awal saat start (snapshot bisa basi); key VEX_BP_PRIVATE_KEY dari bind /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro dibaca config.py load_dotenv (⊥ interpolasi env compose); loop tak pernah keluar; stack deploy ⊥ env_file (env inline via ${VAR} interpolasi .env); image web set DASH_HOST=0.0.0.0 agar ingress menjangkau; healthcheck web = cek socket TCP (⊥ ketergantungan isi DB) V19: target transfer = kontrak vex.token (konstanta chain hardcoded; ⊥ diubah ke eosio.token) V20: saldo distribusi dari chain get_currency_balance pd vex.token; share = floor(balance×stake÷total_stake) 4 desimal; Σshare ≤ balance; sisa (dust) tetap di akun V21: ∀ transfer → dicatat di distribute_payments sbg pending SEBELUM kirim; status → sent|failed dr hasil tx; ⊥ hapus baris (append) V22: retry → hanya baris failed; baris sent ⊥ dikirim ulang; sebelum resend, verifikasi txid via GET {HYPERION_API}/v2/history/get_transaction → executed → tandai sent (⊥ duplikat) V23: memo per run = DATABISNISID PROFIT SHARE YYYY-MM-DD — sama utk seluruh transfer run, hanya on-chain (⊖ disimpan DB) V24: run harian DISTRIBUTE_HOUR (default 10) waktu lokal TZ; ⊥ beku/carryover — hitung ulang tiap hari dari snapshot tersimpan V25: pemilih kosong | saldo < 1 unit shareable → no-op senyap, ⊥ tulis DB V26: VEX_BP_PRIVATE_KEY ⊥ pernah di-log/print; --dry-run utk uji lokal (⊥ sign) V27: dashboard read-only; /history baca distribute_* via db.py; ⊥ mutasi V28: web list desktop → kolom AKUN/STAKE/REWARD/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE/REWARD/VOTE TERAKHIR center; tablet → RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; TXID = link explorer vexascan.com/transaction/<txid>; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML no-store + stylesheet versioned V29: kolom TOTAL REWARD (VEX) per pemilih = Σ amount di distribute_payments status=sent (semua run, sepanjang masa); voter tanpa transfer → 0,0000; failed/pending ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); /api/search menyertakan total_reward; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker) V30: notifikasi Telegram dua kanal (best-effort): notify_start saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), notify_finish setelah run tutup (status ok/partial), notify_failure saat exception di main (lalu re-raise); KOMUNITAS (TELEGRAM_COMMUNITY_CHAT_IDS) hanya brief mulai/selesai (tanggal + status, ⊥ detail); INTERNAL (TELEGRAM_INTERNAL_CHAT_IDS) detail (pemilih/total share/saldo/terkirim/gagal) + semua kegagalan; no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa TELEGRAM_BOT_TOKEN/daftar → kanal senyap; gagal kirim → log warning, ⊥ pernah crash distribusi V31: ∀ image stack (web/scan/dist/claim) → tiap modul .py yang diimpor modul yang di-COPY ikut disalin (⊥ impor intra-proyek yang ⊥ ada di image); oracle test_images.py memindai import tiap image → daftar COPY memuat semua impor intra-proyek V32: klaim reward BP via aksi vexcore::claimrewards (owner=TARGET_BP), sign pyntelope VEX_BP_PRIVATE_KEY; jendela klaim = last_claim_time (tabel producers) + 24 jam; claim_loop.py tidur sampai mendekati jendela lalu poll tiap CLAIM_RETRY_SECONDS (default 60) sampai diterima chain; ⊥ klaim di luar jendela (ditolak chain → retry); setelah sukses jendela dihitung ulang dari last_claim_time segar; RECOVERY: bila ≥24 jam sudah lewat sejak last → jendela = now (klaim dikejar segera, ⊥ spin-timeout utk jendela yang terlewat / container down); loop tak pernah keluar V39: respons chain get_table_rows SELALU dict {rows:[...]} (⊥ list); semua parser memakai data.get('rows'); oracle memakai bentuk respons asli (mock _post_json, ⊥ mock fungsi parse) agar bentuk tak dikenal/tak terduga tertangkap; get_currency_balance memang list string — jangan 'diperbaiki' V40: band umur vote (dashboard + distribusi, UTC-naive konsisten stempel scan): age = now − last_vote; BARU → bagian sendiri (paling atas), ⊖ reward — V52: BARU = akun baru (first_seen_at None/> now − MATURITY) dgn last_vote > now − STALE (maturity diukur dari first_seen_at, ⊖ last_vote terkuantisasi pekan); VALID last_vote > now−STALE DAN akun matang (first_seen_at ≤ now−MATURITY) → bagian utama + pager, satu-satunya penerima reward; KADALUARSA STALE < age ≤ STALE + VEX_EXPIRED_DAYS (31) → baris menyatu di daftar utama, DIPIN di atas baris VALID, sorot amber + badge VOTE ULANG + legenda; HIDDEN age > 31 → disimpan scan tapi ⊖ tampil; scan menyimpan semua umur (V13); stats/count/rank/search dibatasi band yang tampil (rank ordinal per band; jendela VALID utk pager); distribusi query last_vote > now−STALE AND first_seen_at ≤ now−MATURITY (V52) — pemilih BARU (first_seen baru/NULL) ⊥ dibayar siklus itu (anti-gaming); akun yang sudah terlihat ≥ VEX_MATURITY_DAYS lalu (REVOTE) dibayar langsung, ⊖ menunggu matang V41: pembagian pemilih belum-matang menjadi PEMILIH BARU (new) vs REVOTE (pemilih lama yang revote/perpanjang): first_seen_at (tabel voter_first_seen, V7) dibandingkan dgn new_cut = now − MATURITY — first_seen > new_cut (atau NULL/DB lama) → BARU; first_seen ≤ new_cut → REVOTE; klasifikasi tampilan/search (V15); cold-start: skan pertama mencatat semua owner sbg first-seen-now → semua BARU tampak new satu siklus, klasifikasi benar mulai siklus revote berikutnya V42: REVOTE dianggap VALID LANGSUNG (dashboard + distribusi, V41): pemilih dgn first_seen_at ≤ now − MATURITY ⊖ perlu masa matang — memenuhi syarat reward begitu last_vote > now − STALE; hanya pemilih BARU (first_seen NULL atau > now − MATURITY) yang harus lewat masa matang (V52: dari first_seen_at, bukan last_vote); jendela VALID dashboard (_db_rows/_count/rank) = jendela eligibilitas distribusi = last_vote > now−STALE AND first_seen_at ≤ now−MATURITY — konsisten; baris REVOTE menyatu di daftar utama dgn tag mint REVOTE (⊥ bagian sendiri, V15); _stats partisi n_new + n_valid + n_expired == total_voters (n_revote yang dirender = subset tag 1-hari); distribusi LEFT JOIN voter_first_seen (ensure_first_seen_schema); oracle test_distribute (rev7 umur-1hr first_seen-60hr → dibayar; new1 umur-1hr → ⊖) + test_dashboard (revoter1 di #rows bertag) V43: tag & badge display (display-only, ⊥ ubah eligibilitas/payout V40/V42): tag mint REVOTE HANYA utk pemilih lama yang revote dalam VEX_REVOTE_TAG_DAYS (default 1) terakhir (last_vote > revote_cut = now − VEX_REVOTE_TAG_DAYS AND first_seen ≤ now − MATURITY) — setelah itu tampil VALID polos (tetap dibayar langsung); badge amber VOTE ULANG SEGERA (outline dashed, elemen ke-9) utk baris VALID dgn expired_cut < last_vote ≤ warn_cut = now − (VEX_STALE_DAYS − VEX_WARN_DAYS) (default 3 → umur 25..28 hr, overlay band VALID, ⊖ pin/tinting); _freshness_cutoffs → 4-tuple (new_cut, warn_cut, exp_cut, hid_cut); _search_rows tag revote HANYA bila last_vote > revote_cut (revoter lebih lama → valid), + field expiring (main list, ?q=, /api/search); legenda mint TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG · N AKUN + legenda amber TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH; _stats 9-tuple (+n_expiring, n_revote = tag-1-hari utk legenda); oracle test_dashboard (revoter1 _iso(0) tetap bertag; expire1 _iso(26) staked-1290 → valid + expiring; TOTAL 129; config defaults) V33: reward klaim diukur PRIORITAS dari isi tx via Hyperion get_transaction — jumlah transfer vex.bpay + vex.vpay → TARGET_BP (reward_from_tx; ⊥ selisih saldo yang flaky/stale saat node baca saldo di belakang); fallback selisih saldo (setelah − sebelum, settle delay, retry baca) bila Hyperion tak terjangkau; reward ⊥ terukur keduanya → baris dicatat fee pending + notif internal KLAIM MENDARAT · REWARD TAK TERUKUR (⊥ sukses-0 menyesatkan, V45); fee = floor(BP_FEE_PERCENT × reward) 4 desimal → VEX_BP_FEE_WALLET (default bpdbsjasprod), memo BP FEE YYYY-MM-DD, verifikasi txid Hyperion sebelum resend (pola V22); reward ≤ 0 → fee skipped; Σfee ≤ reward V34: siklus klaim selesai ⊥ bila klaim tercatat (claim_runs) DAN fee terkirim; fee pending/failed diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi V45: anti false-success klaim: klaim dianggap "mendarat" ⊥ cukup karena last_claim_time dalam 24 jam terakhir (heuristik lama — klaim yang DITOLAK sebelum jendela bisa salah dianggap sukses bila ada klaim lama <24 jam); deteksi = Hyperion executed ATAU last_claim_time benar-benar MAJU dari nilai sebelum kirim (_claim_time_advanced, after > before; baseline tak terbaca → ⊥ dianggap maju, retry); next_window baca last_claim_time di-retry (node flaky ⊥ langsung dianggap "belum pernah klaim" → clamp now prematur → percobaan ditolak); reward tak terukur → fee pending + notif cek manual, ⊥ skipped diam-diam (V33); oracle test_claim (last maju vs tak maju, reward_from_tx parse bpay+vpay, unmeasured → pending+notif) V46: ⊥ pernah percaya signed.send() semata utk deteksi mendarat — pyntelope TIDAK raise saat chain menolak (Net._request mengembalikan body HTTP 500 sbg JSON, ⊥ exception), jadi "sukses kirim" bisa berarti DITOLAK (mis. claimrewards sebelum jendela 24 jam) → false success reward 0. Landing dikonfirmasi di KEDUA jalur (send sukses maupun exception) lewat _confirm_landed = Hyperion executed ATAU last_claim_time maju dari baseline; tak terkonfirmasi → retry senyap (⊥ catat claim_runs, ⊥ notif sukses) — klaim sah yang belum terindeks Hyperion → retry 60s berikutnya terverifikasi (self-healing dalam 2 jam poll). next_window: semua pembacaan producer gagal → raise (loop catch-and-sleep), ⊥ clamp now prematur; now hanya utk pembacaan sukses tanpa klaim sebelumnya (BP baru). Oracle test_claim: send-normal+verify-False+last-maju → claimed (V50); send-normal+verify-False+last-tak-maju → retry + ⊥ baris + ⊥ notif; baca-producer-gagal → raise V49: math jendela klaim memakai _utcnow() (UTC-naive, datetime.now(timezone.utc).replace(tzinfo=None)) — konsisten dgn last_claim_time chain (UTC) & get_voters/dashboard; step()/poll_claim/_try_claim_once memakai _utcnow() (⊥ datetime.now() lokal yang bergeser +TZ di container → jendela maju ~7 jam → poll prematur → ditolak chain → retry tanpa henti, B13); oracle test_claim (_utcnow tzinfo None; step before window → sleep) V50: _confirm_landed ⊥ langsung menyerah saat Hyperion executed: false (verify_txid → False): tx yang BARU SAJA mendarat bisa belum terindeks Hyperion (lag, response executed: false utk id yg belum diindeks) — menganggap False sbg "tak mendarat" membuat klaim sukses masuk retry-loop permanen (B14). Landing = Hyperion executed ATAU last_claim_time maju dari baseline; utk verified False MAUPUN None tetap cek _claim_time_advanced; hanya bila itu juga False → retry (klaim ditolak ⊥ memajukan last_claim_time). Oracle test_claim: send-normal+verify-False+last-maju → claimed + reward tercatat (V50); send-normal+verify-False+last-tak-maju → retry + ⊥ baris (V46) V52: maturity pemilih baru diukur dari first_seen_at (stempel scan presisi detik, tabel voter_first_seen) BUKAN last_vote — last_vote terkuantisasi PEKAN oleh stake2vote (B12) sehingga voter baru yang vote Senin–Kamis tampak 4–6 hari ⇒ lolos maturity 3-hari tanpa menunggu (B15). Aturan (dashboard + distribusi): BARU = last_vote > now−STALE DAN (first_seen_at NULL atau > now−MATURITY); VALID = last_vote > now−STALE DAN first_seen_at ≤ now−MATURITY (akun matang, ⊖ perlu vote tua). _edge_rows/_db_rows/_count/_stats/_search_rows + distribusi memakai ambang first_seen_at; _stats partisi n_new + n_valid + n_expired == total_voters; BARU band kolom MATURITY PERIOD = countdown VALID DALAM N HARI (first_seen + MATURITY, filter countdown; ⊖ TOTAL REWARD yang selalu 0 utk pemilih baru); oracle test_distribute (make_voters_db seed first_seen matang default, make_first_seen REPLACE utk BARU/REVOTE) + test_dashboard (acct### seed first_seen matang; band check MATURITY PERIOD) | V52,V40,V42,V41 V53: kartu promo VEXWALLET di index (display-only): section .news tag BY DATABISNISID, judul VEXWALLET · ANDROID, link logo static/idrs.png (.promo-logo) + label UNDUH DI PLAY STORE → https://play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet&hl=id (target=_blank rel=noopener); oracle test_dashboard (logo idrs.png + link + tag + label ada di /); history ⊖ berubah V54: baseline landing klaim STABIL per window (B16): poll_claim baca last_claim_time SEKALI di awal window dan mengoper ke SETIAP _try_claim_once(last_before) (⊥ tiap attempt baca ulang — baseline ikut maju setelah klaim mendarat, membuat _claim_time_advanced tak pernah True → retry-loop permanen walau klaim sukses); _confirm_landed baca last_after dgn retry singkat (node RPC bisa lag mencerminkan last_claim_time baru); attempt 1 yg baca basi → retry, attempt 2 (baseline orisinil vs last_after maju) → claimed. Oracle test_claim: stale-lalu-maju → poll_claim claimed + reward tercatat | V54,V50,V46 V55: reward klaim TAK PERNAH tercatat/notif sbg 0.0000 sukses bila tak terukur (B17): _measure_reward (1) RETRY reward_from_tx 4× dgn jeda SETTLE_SECONDS (default 30) — tx klaim baru mendarat bisa belum terindeks Hyperion sesaat → None palsu; (2) fallback selisih saldo 3× HANYA mengakui delta POSITIF (after > before) sbg terukur — delta 0 ambigu (node balik saldo lama / saldo belum ter-update) → tetap None; keduanya gagal → fee pending + alert KLAIM MENDARAT · REWARD TAK TERUKUR (jalur V45), ⊖ KLAIM REWARD SUKSES 0.0000 menyesatkan. Reward 0 ASLI (tx executed tanpa transfer bpay/vpay) tetap terbaca via isi tx (reward_from_tx → Decimal('0')) → fee skipped (V33). Oracle test_claim: V33 reward-0 via isi tx (bukan delta 0); B17-a reward_from_tx None + delta 0 → claimed + fee pending + alert ⊖ sukses-0; B17-b reward_from_tx None lalu berhasil → reward asli tercatat | V55,V45,V33 V56: baseline SALDO klaim STABIL per window (B18): poll_claim baca before_balance (saldo liquid) SEKALI di awal window & oper ke SETIAP _try_claim_once(last_before, before_balance) — ⊖ baca ulang per attempt: klaim yang mendarat di attempt 1 membuat before attempt 2 (ditolak already-claimed) SUDAH termasuk reward → delta 0 → reward tak terukur → alert palsu (kasus 10 Agt: tx bfa9ab02 mendarat 15:46:18 reward 1679.3365, app ukur tx ditolak b0a8b2f9 + before post-credit → alert KLAIM MENDARAT · REWARD TAK TERUKUR). Baseline stabil → delta fallback mengukur reward benar walau tx tercatat = attempt ditolak. Bila None (pemanggil langsung) → baca sendiri sbg fallback. Oracle test_claim: B18 attempt-1-mendarat (confirm basi) → retry, attempt-2-ditolak (last maju) → landed + reward 1679.3365/fee 167.9336 terukur, ⊖ alert | V56,V54,V55 V57: endpoint GET /api/voter/<owner> (web) → 200 {owner, is_valid_voter} — is_valid_voter = akun memenuhi SYARAT REWARD saat ini (jendela payout distribusi V40/V52), BUKAN sekadar ada di tabel voters (BARU/KADALUARSA/tersembunyi/akun tak dikenal → false). Single source kebenaran: db.eligible_voters(owner=None) (db.py) memakai SQL jendela payout yg SAMA — last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY (cutoff UTC-naive) — dipakai BAIK distribute.py:run_distribution (tanpa owner → semua baris (owner,staked)) MAUPUN endpoint (dengan owner → []|one row) → ⊖ duplikasi SQL (drift, gaya V38); endpoint: is_valid_voter = len(db.eligible_voters(owner)) > 0; DB-snapshot only (kesegaran = skan harian, V10), selalu 200, ⊖ format-validation nama akun. Oracle test_dashboard (V57): true utk acct###/revoter1/expire1; false utk newacct1 (BARU)/zzzold (kadaluarsa)/zzhide (tersembunyi)/asing; drift-guard endpoint==db + pool distribusi==db V58: hardening distribusi vs node RPC mati: config += API_NODES (CSV VEX_API_NODES, default = VEX_API_NODE + https://api.databisnis.id — node mainnet terverifikasi, host sama dgn HYPERION_API tapi melayani juga /v1/chain). distribute.py: (1) _post_json mencoba SEMUA node per putaran (max_retries putaran penuh, jeda 2s antar putaran) → semua gagal → RuntimeError (bukan None); (2) build_signed_transfer coba tiap node utk link()+sign() (net di-bind ke node itu → broadcast send() ikut node sama) → semua gagal → raise; (3) fetch_balance_with_retry(dry_run) — real-run retry backoff BALANCE_RETRY_DELAYS=[30,60,90,120,150,180]s (~10.5 mnt) sebelum menyerah ke jadwal berikutnya; dry-run SATU attempt (gagal cepat, preview ⊥ tertahan). Net-effect: satu node mati → payout tetap jalan via cadangan; SEMUA node mati di real-run → jendela retry 10 mnt (bukan langsung abort) → lalu notify_failure + jadwal berikutnya; failed payments tetap rejoin run berikutnya (V22). Scan/claim ⊥ berubah (tetap API_NODE tunggal; fee klaim via dist.build_signed_transfer mewarisi failover gratis). Oracle test_distribute: V58 failover saldo (node mati → cadangan), semua-node-mati → raise, backoff retry-sampai-sukses + dry-run-satu-attempt, build_signed_transfer coba kedua node (mock pyntelope Net) | V58,V20,V22,V26 V60: failover pool Hyperion utk SEMUA pembacaan /v2/*: config += 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(url, params, max_retries=3, timeout=10) — GET mirror _post_json V58: coba SETIAP node per putaran, max_retries putaran penuh, jeda 2s; SEMUA gagal → None (⊥ raise — pemanggil butuh None utk jalur tak-terukur/hold). verify_txid pakai _get_json → None kini berarti SEMUA host gagal SEMUA putaran (bukan satu host hiccup) → hold V59 jarang palsu. claim.reward_from_tx pakai dist._get_json (tiap attempt settle V55 jadi pool-robust). dashboard._get_liquid_vex loop HYPERION_NODES sendiri (web ⊥ impor distribute, V31) — host mati → cadangan, SALDO LIQUID tetap live. Net-effect: blip Hyperion sesaat ⊖ lagi menahan payout / ⊖ gagal ukur reward / ⊖ kosongkan saldo dashboard. Oracle: test_distribute V60 verify_txid failover (host mati → cadangan executed; semua mati → None), test_claim V60 reward_from_tx failover, test_dashboard V60 saldo dari cadangan + config default HYPERION_NODES | V60,V22,V45,V16 V59: kirim per-pemilih ⊖ pernah menyesatkan: (1) TAHAN yang sungguhan saat verify_txid → None (Hyperion tak terjangkau): break + tandai failed di ATTEMPT PERTAMA (bukan hanya di attempt terakhir — B19: kode lama print ditahan tapi lanjut retry → re-sign dgn tapos/expiration baru → txid BARU → re-broadcast berpotensi ganda walau tx pertama mendarat; oracle lama lolos karena set DISTRIBUTE_MAX_ATTEMPTS=1); (2) deteksi penolakan chain dari body HTTP-500: pyntelope send() ⊥ raise pada 500 (pola V46/B11), respons {"code":500,"error":...} datang sbg dict — _send_rejected(resp) mengenali bentuk error → raise RuntimeError → jalur verify/retry, ⊖ tandai sent utk tx yang ⊥ pernah mendarat (B19 juga: tanpa deteksi, penolakan duplicate/insufficient-balance dicatat sent dan ⊥ pernah dikejar). Oracle test_distribute: V59 hold-atau-retry di attempt 1 (3 attempt default, build dipanggil sekali per pemilih), V59 rejection-500 → failed + partial (⊖ sent) | V59,V22,V21 V61: red-team send-path hardening (audit V58/V59/V60, B20–B23): (1) _send_rejected(resp) = WHITELIST, bukan blacklist: sukses HANYA bila resp dict ber-transaction_id DAN receipt executed/tanpa-status; body HTTP-500 apa pun (ber-error ATAU bentuk proxy lain) → dianggap DITOLAK → jalur verify/retry, ⊖ pernah sent utk tx yang ⊥ mendarat (F3/B22; B19-b hanya mengejar bentuk {"code":500,"error":...}, bentuk lain lolos sbg sukses); (2) _verify_settled(txid, retries=3, delay=2): retry verify_txid beri Hyperion waktu mengejar indeks (pola B14), True → mendarat; None (pool mati SEMUA) → False langsung (⊥ tidur sia-sia); False konsisten → False — jalur exception send-loop sekarang hold (failed, break) pada True→sent / None|False→hold, ⊖ RESEND apa pun hasil verify selain True — tx yang executed:false (lag indeks) ⊖ boleh re-sign dgn tapos baru → txid baru → ganda (B21, perluasan V59-b: V59 hold hanya saat verify_txid → None, False FALLTHROUGH ke resend); (3) _send_fee (claim fee) pakai _send_rejected(resp) — penolakan chain fee (duplicate/insufficient-balance, V46/B11) → failed + retry siklus berikutnya, ⊖ sent untuk fee yang ⊖ pernah mendarat (F1/B20); (4) SEMUA respons /v2/* di-guard isinstance(data, dict) — _get_json, verify_txid, reward_from_tx, dashboard._get_liquid_vex → bentuk non-dict (200-an list/string dari proxy) → skip/skip-node/None, ⊖ AttributeError di dalam except yang membatalkan run (F5/B23). Oracle: test_distribute V61 (verify-False → hold attempt-1 dgn MAX_ATTEMPTS=3 default, _send_rejected whitelist 5 bentuk, _get_json/verify_txid non-dict → None), test_claim V61 (fee rejection-500 → failed ⊖ sent), test_dashboard V60 (override VEX_HYPERION_NODES). F4 (_get_json first-200-wins: primary basi menaungi cadangan — hari ini kedua host = backend vexascan.com sama, efek nol) & F7 (hold latency ~64 s/pembayaran saat SEMUA Hyperion mati; jalur claim chain node tunggal) — DITERIMA, ⊖ diubah | V61,V60,V59,V56,V46 V35: kill-switch distribusi DISTRIBUTE_ENABLED default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; --dry-run tetap menampilkan rencana (read-only); nilai true/1/yes → normal V36: notifikasi klaim reward (sukses notify_claim & gagal notify_claim_failure) → kanal INTERNAL saja, ⊥ pernah ke KOMUNITAS (V30); fee gagal ⊥ spam — satu notif per klaim (saat transisi ke failed) V37: jadwal distribusi MINGGUAN (⊥ harian): distribute_loop menunggu DISTRIBUTE_WEEKDAY — SATU hari (sat) atau CSV beberapa hari (wed,sat), nama mon..sun atau 0-6 (V44: multi-hari, event terdekat di antara hari-hari terdaftar) — jam DISTRIBUTE_HOUR (lokal TZ); plus satu one-off DISTRIBUTE_FIRST_RUN (YYYY-MM-DD; default baked 2026-08-17) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila DISTRIBUTE_ENABLED=false (V35) — jadwal tetap maju, run jadi no-op; distribute_loop.main baca jadwal via config (⊥ os.getenv sendiri) agar konsisten dgn dashboard V38 V38: dashboard index menampilkan banner BERITA jadwal distribusi berikutnya (DISTRIBUSI BERIKUTNYA + tanggal id-ID SENIN · 17 AGUSTUS 2026 · 10:00 WIB); baris berulang SETIAP RABU & SABTU · 10:00 (V44: hari di-join & sesuai CSV DISTRIBUTE_WEEKDAY) HANYA utk putaran mingguan — ⊥ tampil saat event berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak konsisten; _is_first_run_next); setelah launch lewat → baris muncul lagi; logika boundary MIRROR distribute_loop.next_boundary (⊥ impor lintas-image, V31; drift dijaga oracle test_dashboard); TZ lokal utk konsistensi dgn jadwal; stack: web service juga menerima DISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN/TZ (interpolasi .env) V44: jadwal distribusi MULTI-HARI (Rabu & Sabtu): DISTRIBUTE_WEEKDAY menerima CSV (wed,sat default; nilai tunggal tetap valid) → parse_weekdays (list int unik terurut, mirror distribute_loop/dashboard); next_boundary(now, hour, weekdays, first_run) pilih event terdekat di antara hari-hari terdaftar, hormati gating first_run (hari sebelum launch dilewati); dashboard _recurrence_label = SETIAP {hari & hari} · HH:00; default baked wed,sat (config/compose/.env.example); oracle test_distribute_loop (multi-hari) + test_dashboard (drift-guard list + skalar)

§T — Tasks

id|status|task|cites T1|x|setup: pip install requests, requirements.txt, project files| T2|x|scan loop: retry + pagination|V1,V2,V3,I.api T3|x|filter + normalize (owner, staked÷10000, weight ≥1000)|V4,V5,V6,V8,V9 T4|x|sqlite schema + replace-snapshot w/ scanned_at|V7,I.db T5|x|CLI entry + Indonesian summary stdout|I.cmd T6|x|dashboard: Flask read-only, paged spec-list sort staked DESC|V10,I.web,I.db T7|x|dashboard: styling per DESIGN.md (canvas hitam, typografi, hairline)|I.web T8|x|dashboard: test oracle + smoke|V10,I.web T9|x|env config: config.py + .env/.env.example + dotenv; get_voters & dashboard pakai config|I.env T10|x|search: ?q= server filter + /api/search JSON (substring, escape, rank global)|V11,I.web T11|x|live JS filter (app.js) + responsive (DESIGN.md breakpoints, list→kartu mobile)|V11,I.web T12|x|dashboard oracle + search/API/config tests|V11,V10 T13|x|produksi web: gunicorn (gunicorn.conf.py, DASH_WORKERS) + WAL di persist|V12,I.web,I.env T14|x|stale-vote: last_vote di scan (rasio bobot/stake); basi/unverifiable dibuang di normalize (⊥ disimpan)|V13,I.db T15|x|hapus bobot suara + UI basi di web (kolom/stats/penanda/filter ?stale=1); oracle diperbarui|V15,I.web T16|x|saldo liquid akun di dashboard: API databisnis + cache TTL + cooldown; sel SALDO LIQUID|V16,I.web,I.env T17|x|backend shared db.py (sqlite|mysql, %s→?, ESCAPE '!'), config VEX_DB_*, refactor fetcher+dashboard, test_mariadb.py opt-in, runbook docker-compose|V17,I.db,I.env T18|x|stack swarm: Dockerfile web + Dockerfile.scan, scan_loop.py (SCAN_HOURS 0,8,16 lokal, skan-awal), docker-compose.yml stack (mariadb+web+scan), docker-compose.dev.yml utk uji lokal|V18,I.web,I.env T19|x|db.py: tabel distribute_runs + distribute_payments (sqlite/mysql, %s, append) + helper (record_run, record_payment, update_payment_status, list_payments)|V21,V22,I.db T20|x|config.py: env VEX_BP_PRIVATE_KEY, DISTRIBUTE_HOUR, DISTRIBUTE_MAX_ATTEMPTS + konstanta chain (vex.token, chain_id); requirements.txt += pyntelope|V19,I.env T21|x|distribute.py: fetch_balance (chain get_currency_balance) + compute_shares (floor 4-des, sisa di akun)|V20,I.api T22|x|distribute.py: run_distribution — pending → sign (pyntelope) → push_transaction → sent/failed; memo per run; retry loop (backoff, dalam hari)|V21,V22,V23,V24,I.api T23|x|distribute.py: guard resend — verifikasi txid via Hyperion get_transaction sebelum kirim ulang|V22,V23,I.api T24|x|distribute.py: --dry-run (rencana ⊥ sign) + no-op saat pemilih kosong/saldo kecil|V25,V26,I.cmd T25|x|scheduler: service distribute di stack swarm (Dockerfile.dist + distribute_loop.py, image databisnisid-dist, loop tunggu DISTRIBUTE_HOUR lokal, ⊥ distribusi-awal; key VEX_BP_PRIVATE_KEY dari bind NFS config.env:/app/.env:ro)|V18,V24,I.env T26|x|dashboard: route /history (runs + per-voter log) + template, gaya DESIGN.md, read-only|V27,I.web T27|x|tests: test_distribute.py (mock chain+pyntelope, temp DB: math share, memo, no-op, status/retry, guard duplikat) + extend test_dashboard.py (/history)|V20,V21,V22,V25,V26,V27,I.db T28|x|hygiene: .gitignore += .oc/; stage penghapusan README.md (stub kosong)| T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; /history 6-kolom + kartu mobile berlabel; CSS cache-bust + HTML no-store|V28,I.web T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE center|V28,I.web T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web T32|x|dashboard list: kolom TOTAL REWARD (VEX) = Σ sent per pemilih (LEFT JOIN subquery, status=sent); desktop 5 kolom rata, tablet 5 kolom, kartu mobile + label; TXID history jadi link explorer vexascan|V15,V28,V29,I.web T33|x|notifikasi Telegram: telegram.py (send_text best-effort + notify_start/finish/failure), hook di distribute.py (mulai/selesai/gagal; ⊥ dry-run/no-op), config TELEGRAM_BOT_TOKEN+TELEGRAM_CHAT_IDS, Dockerfile.dist += telegram.py, oracle|V30,I.env T34|x|image mandiri: distribute_loop self-contained (duplikasi helper scan_loop, ⊥ impor lintas-image) + oracle test_images.py (import intra-proyek ⊆ daftar COPY tiap image)|V31 T35|x|klaim config: CLAIM_RETRY_SECONDS, VEX_BP_FEE_WALLET, VEX_BP_FEE_PERCENT di config.py + db.py tabel claim_runs (ensure/record_claim/update_claim_fee/pending_claim_fee, %s dua backend)|V32,V34,I.db,I.env T36|x|claim.py: fetch last_claim_time (tabel producers) + next_window + build_claim_action (pyntelope vexcore::claimrewards) + ukur reward (delta saldo) + hitung fee floor|V32,V33,I.api T37|x|claim.py: poll_claim (retry tiap CLAIM_RETRY_SECONDS sampai sukses/deadline) + _send_fee (retry-until-sent, verifikasi txid V22) + step state machine (resume fee → jadwal → poll)|V33,V34 T38|x|claim_loop.py self-contained (duplikasi next_boundary/_wait_db) + Dockerfile.claim (image databisnisid-claim) + service claim di docker-compose.yml (env DB/TZ/CLaim + bind NFS config.env)|V32,V34,I.env T39|x|telegram.py: notify_claim (reward+fee) + notify_claim_failure + hook di claim.py (sukses klaim / fee gagal pertama; best-effort)|V30,V34 T40|x|test_claim.py oracle (mock chain+pyntelope, temp DB: jendela, math fee, siklus penuh, retry fee, resume, reward 0) + test_images.py += Dockerfile.claim & modul claim/claim_loop|V31,V32,V33,V34,I.db T41|x|kill-switch distribusi: DISTRIBUTE_ENABLED (config default false) + gate run_distribution (non-dry-run no-op: exit 0, ⊥ sign/tulis/notif; dry-run tetap) + env compose ${DISTRIBUTE_ENABLED:-false} + oracle (default off, disabled no-op, disabled+dry-run)|V35,I.env T42|x|Telegram dua kanal: rename TELEGRAM_CHAT_IDS → TELEGRAM_COMMUNITY_CHAT_IDS + tambah TELEGRAM_INTERNAL_CHAT_IDS (config), telegram.py send_text(chat_ids=None)=komunitas + send_internal; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env T43|x|jadwal mingguan: distribute_loop.py next_boundary = {one-off DISTRIBUTE_FIRST_RUN @ jam (jika depan)} ∪ {mingguan DISTRIBUTE_WEEKDAY @ jam ≥ tanggal launch; Sabtu sebelum launch dilewati} + parser hari/nama + config.py (DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN) + env compose + .env.example + oracle test_distribute_loop.py|V37,I.env T44|x|dashboard banner BERITA jadwal distribusi berikutnya: dashboard.py mirror _next_boundary (⊥ impor lintas-image, V31) + _next_schedule/_fmt_schedule (id-ID) + _recurrence_label (baris berulang hanya utk mingguan, ⊥ saat first-run, _is_first_run_next); section .news di index.html + CSS; env compose web += TZ/DISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN; oracle V38 (banner + drift-guard vs distribute_loop)|V38,V37,I.env T49|x|jadwal multi-hari Rabu & Sabtu (V44): config.py default DISTRIBUTE_WEEKDAY=wed,sat; distribute_loop.py += parse_weekdays (CSV → list int unik) + next_boundary terima list (skalar kompatibel, normalisasi); dashboard.py mirror _parse_weekdays + _recurrence_label SETIAP RABU & SABTU · 10:00 (& via |safe); compose + .env.example wed,sat; docs V37/V38 amend + V44 + T49; oracle test_distribute_loop (multi-hari) + test_dashboard (drift list/skalar + label); image web+dist rebuild & push|V44,V37,V38,I.env T45|x|band umur vote V40: scan simpan semua umur (hapus _is_stale), config += VEX_MATURITY_DAYS/VEX_EXPIRED_DAYS, dashboard = BARU (bagian sendiri) + daftar utama (kadaluarsa menyatu di atas, sorot amber+badge+legenda, lalu VALID pager) + search bertag + stats/count/rank scoped, distribusi filter matang+segar (_eligible_cutoffs), env/.env.example + docs, oracle test_get_voters/test_dashboard/test_distribute|V40,V13,V11,V15,I.env T46|x|BARU vs REVOTE (V41): db.py += tabel voter_first_seen + ensure_first_seen_schema/record_first_seen (INSERT OR IGNORE/IGNORE, PK owner, append-only); scan persist mencatat first_seen; dashboard _edge_rows/_stats/_search_rows klasifikasi first_seen > new_cut → BARU, ≤ → REVOTE (group enum += revote), bagian REVOTE sendiri di bawah BARU + tag CSS --mint; docs + oracle test_get_voters/test_dashboard/test_mariadb|V41,V7,V15,V40 T47|x|REVOTE = VALID langsung (V42): distribute.py run_distribution ensure_first_seen_schema + query LEFT JOIN voter_first_seen → last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE); dashboard _edge_rows → (new_rows, expired_rows) (REVOTE ⊖ BARU, masuk daftar utama), _db_rows/_count jendela sama + flag revote (elemen ke-8) + tag mint REVOTE di baris valid, _stats partisi n_new+n_revote+n_expired+n_valid == total, _search_rows group revote di jendela BARU & VALID; template ⊖ bagian REVOTE + legenda mint (?v=13); docs + oracle test_distribute/test_dashboard|V42,V40,V41,V15,I.db,I.web T48|x|tag/badge display V43: config += VEX_REVOTE_TAG_DAYS (1) + VEX_WARN_DAYS (3); _freshness_cutoffs → 4-tuple (new_cut, warn_cut, exp_cut, hid_cut) + _revote_cut; _db_rows flag revote = last_vote > revote_cut AND fs ≤ new_cut + flag expiring (elemen ke-9) exp_cut < last_vote ≤ warn_cut; _stats 9-tuple (+n_expiring, n_revote = tag-1-hari); _search_rows tag revote HANYA bila last_vote > revote_cut + field expiring; template badge VOTE ULANG SEGERA (main + ?q=) + legenda mint/amber baru (?v=14), app.js warn-badge, CSS .warn-badge; env/.env.example + docs (V15/V42 amend, V43, T48); oracle test_dashboard|V43,V42,V40,V15,I.env,I.web T50|x|endpoint cek akun payout-eligible V57: db.py += eligible_voters(owner=None) (single-source SQL jendela reward, cutoff UTC-naive) — distribute.py run_distribution refactor pakai db.eligible_voters() (hapus _eligible_cutoffs), dashboard += GET /api/voter/<owner> → {owner, is_valid_voter} (len(db.eligible_voters(owner))>0); docs §I/§V/V57 + T50; oracle test_dashboard (V57 true/false + drift endpoint==db pool) + test_distribute (mock eligible_voters utk no-op)|V57,V40,V52,V31 T51|x|hardening distribusi vs node RPC mati V58: config.py += API_NODES (CSV VEX_API_NODES, default VEX_API_NODE + https://api.databisnis.id); distribute.py — _post_json failover semua node per putaran (semua mati → RuntimeError), build_signed_transfer coba tiap node utk link+sign (net ter-bind → send ikut node sama), fetch_balance_with_retry(dry_run) backoff BALANCE_RETRY_DELAYS ~10 mnt real-run / satu attempt dry-run; docs §I/§V/V58 + T51 + AGENTS; oracle test_distribute (failover saldo, semua-mati raise, backoff retry + dry-run cepat, build coba kedua node via mock pyntelope Net)|V58,V20,V22,V26 T52|x|send-per-pemilih ⊖ menyesatkan V59: distribute.py — (1) verify_txid None → break + failed di attempt 1 (hold sungguhan, ⊖ resend; B19-a), (2) _send_rejected(resp) deteksi body HTTP-500 (duplicate/insufficient-balance) → raise → jalur verify/retry, ⊖ sent palsu (B19-b); docs §V/V59 + §B/B19 + T52 + AGENTS; oracle test_distribute (V59 hold attempt-1 dgn 3 attempt default + rejection-500 → failed/partial)|V59,V22,V21 T53|x|failover pool Hyperion V60: config.py += HYPERION_NODES (CSV VEX_HYPERION_NODES, default HYPERION_API + API_NODES dedup); distribute.py _get_json(url, params, max_retries=3, timeout=10) GET mirror _post_json — coba semua HYPERION_NODES per putaran, semua gagal → None; verify_txid + claim.reward_from_tx (via dist._get_json) + dashboard._get_liquid_vex (loop lokal, web ⊥ impor distribute V31) pindah ke pool; docs §I/§V/V60 + T53 + AGENTS; oracle test_distribute (failover verify_txid + semua-mati None), test_claim (reward_from_tx failover), test_dashboard (saldo cadangan + config default)|V60,V22,V45,V16 T54|x|red-team send-path hardening V61 (B20–B23): distribute.py — _send_rejected jadi WHITELIST (sukses = dict + transaction_id + receipt executed/tanpa-status), _verify_settled(txid) retry verify utk lag indeks, jalur exception send-loop hold pada None|False (⊖ resend, perluasan V59-b), guard isinstance(data, dict) di _get_json+verify_txid; claim.py _send_fee + _send_rejected (rejection-500 → failed ⊖ sent) + guard non-dict di reward_from_tx; dashboard._get_liquid_vex guard non-dict; docs §V/V61 + §B/B20-B23 + T54 + AGENTS; oracle test_distribute (verify-False hold attempt-1 MAX_ATTEMPTS=3, whitelist 5 bentuk, non-dict → None), test_claim (fee rejection → failed), test_dashboard (override VEX_HYPERION_NODES); F4/F7 diterima (dokumentasi)|V61,V60,V59,V56,V46

§B — Bug log

id|date|cause|fix B1|2026-08-04|node balikin staked sbg string utk sebagian baris → TypeError int÷int|V8 B2|2026-08-05|ESCAPE '\' valid di SQLite tapi memutus literal string MySQL ('\' = string tak tertutup) → error 1064 di backend mysql|ganti karakter escape ke ! (netral di kedua backend), _escape_like esc !,%,_ (V17) B3|2026-08-05|PyMySQL fetchall() → tuple, SQLite → list → asersi == [] gagal di mysql|db.query bungkus list() → tipe seragam (V17) B4|2026-08-06|image dist ⊥ salin scan_loop.py, distribute_loop impor darinya → ModuleNotFoundError saat smoke container|V31 B5|2026-08-06|DISTRIBUTE_ENABLED pakai not in ('1','true','yes') → default jadi ON (kebalikan V35, kill-switch ⊥ berfungsi saat var ⊥ diset)|flip ke in (...) — default off (V35) B6|2026-08-06|mirror _parse_weekday di dashboard memakai kunci nama hari id-ID (sabtu) tapi config default sat (singkatan EN) → ValueError 'sat'; drift-guard oracle tangkap|kunci parser = singkatan EN mon..sun (sama dgn distribute_loop.WEEKDAYS), V38 + drift-guard B7|2026-08-06|claim service di swarm mati total — log [Error] Siklus klaim gagal: 0 tiap siklus: fetch_producer_last_claim parse respons get_table_rows sbg list (data[0]) padahal node balikin dict {rows:[...]} → KeyError: 0 (str 0); oracle test_claim mock fetch_producer_last_claim langsung → parse tak pernah diuji|parse data.get('rows') bentuk asli, bentuk tak dikenal → None (⊥ crash); oracle mock _post_json dgn bentuk respons asli (V39) B8|2026-08-06|setelah jendela klaim lewat (poll 2 jam habis tanpa sukses) next_window tetap balikin last+24h masa lalu → poll_claim spin-timeout selamanya, klaim tak pernah bisa catch-up (deadlock) walau ≥24 jam sudah lewat|next_window = max(last+24h, now) — ≥24 jam → jendela = now, klaim dikejar segera (V32) B9|2026-08-07|notif KLAIM REWARD SUKSES / Reward 0.0000 walau tx klaim TIDAK mendarat: signed.send() timeout + Hyperion sempat tak terjangkau → heuristik "last_claim_time dalam 24 jam" (klaim lama masih <24h) menganggap mendarat → reward diukur 0 (δ saldo ⊥ berubah) → fee skipped diam-diam + sukses-0|deteksi mendarat = Hyperion executed ATAU last_claim_time MAJU dari baseline (V45); reward ⊥ terukur → fee pending + notif cek manual (V33); next_window baca di-retry (⊥ clamp prematur) B10|2026-08-07|reward klaim diukur dari selisih saldo liquid (setelah−sebelum): saat node baca saldo di belakang/fetch gagal, reward nyata (transfer bpay/vpay) ⊥ tercatat → fee ⊥ dikirim utk siklus itu (fee skipped permanen, V34 ⊥ retry skipped)|ukur reward dari isi tx klaim via Hyperion (bpay+vpay → BP) prioritas; selisih saldo jadi fallback; keduanya gagal → fee pending + notif (V33/V45) B11|2026-08-07|notif [OK] klaim reward 0.0000 STILL terbit walau tx klaim tak mendarat — V45 hanya mengerasankan jalur exception, tapi signed.send() pyntelope TIDAK raise saat chain menolak (Net._request kembalikan body HTTP 500 sbg JSON): klaim sebelum jendela → HTTP 500 → send() normal → landed=True di jalur sukses → reward 0 → [OK]/KLAIM REWARD SUKSES palsu|landing dikonfirmasi di KEDUA jalur via _confirm_landed (Hyperion executed ATAU last_claim_time maju dari baseline); tak terkonfirmasi → retry senyap, ⊥ catat/notif (V46); next_window baca producer gagal → raise (⊥ clamp prematur) B12|2026-08-07|derive_last_vote pakai 365.25 hari/tahun tapi Vexanium (stake2vote) mengkuantisasi bobot ke PEKAN UTUH (int64((now−2000)/(86400×7))/52.0) — drift ~33 hari ke depan utk vote 2026 (365.25 vs 364 hari/eksponen); jendela maturity 3/28/31 hari jadi miring ~sebulan|rekonstruksi = epoch + round(52×log2(ratio)) pekan (kelipatan 7 hari); oracle _fresh_weight + 2^26 + regresi baris nyata (..tg, 1.crownz) (V13) B13|2026-08-07|claim service loop [Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat — retry tanpa henti ~7 jam sebelum jendela: step() memakai datetime.now() (TZ container Asia/Jakarta, UTC+7) sedangkan last_claim_time chain UTC-naive → next_window menganggap jendela 24 jam sudah buka ~7 jam lebih awal → poll tiap 60s → claimrewards ditolak chain (HTTP 500 sbg body, pyntelope ⊥ raise) → _confirm_landed ⊥ terkonfirmasi → retry; V46 bekerja benar (⊥ sukses-0) tapi tak pernah ada tx mendarat utk dicatat|window math pakai _utcnow() (UTC-naive) di step()/poll_claim/_try_claim_once — konsisten dgn last_claim_time chain (V49); oracle _utcnow tzinfo None + step before-window → sleep B14|2026-08-07|setelah fix TZ (V49) klaim BENAR-BENAR mendarat (tx 0e61fcb5…, bpay 466.7587 + vpay 1215.4164 = 1682.1751 VEX) tapi app tetap retry-loop & ⊥ catat reward/fee: _confirm_landed menyerah saat verify_txid → False (executed: false) — Hyperion merespons false utk tx yang baru saja mendarat tapi belum terindeks (lag), padahal last_claim_time SUDAH maju; retry berikutnya baca last_before yg sudah maju → _claim_time_advanced tak pernah deteksi → retry permanen, fee 168.22 VEX utk siklus itu tak terkirim|_confirm_landed: verified False MAUPUN None → tetap cek _claim_time_advanced; hanya bila itu False → retry (V50); oracle send-normal+verify-False+last-maju → claimed B15|2026-08-08|maturity 3-hari pemilih BARU ⊥ berfungsi utk vote Senin–Kamis: last_vote diturunkan dari bobot yang terkuantisasi PEKAN (stake2vote) → voter baru yg vote tengah pekan tampak 4–6 hari ⇒ lolos last_vote ≤ now−MATURITY di hari pertama (anti-gaming tembus); kejadian nyata: eosauthority vote 5 Agt (Rabu) → last_vote terderivasi 1 Agt (Sabtu)|maturity diukur dari first_seen_at (presisi detik) di dashboard + distribusi: BARU = first_seen baru/NULL; VALID = first_seen matang (V52); oracle test_distribute/test_dashboard seed first_seen matang B16|2026-08-08|klaim mendarat di chain (tx 48c2066f…, bpay 463.9816 + vpay 1219.3825 = 1683.3641 VEX) tapi app tetep [Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat — retry tanpa henti & ⊖ notif/fee (sama utk klaim 7 Agt 0e61fcb5…): _try_claim_once baca last_before ULANG tiap attempt — attempt 1 mendarat tapi baca last_after basi (node RPC belum mencerminkan last_claim_time baru) → retry; attempt 2 last_before sudah = nilai maju → _claim_time_advanced(maju, maju) ⊖ pernah True → retry-loop permanen walau V50/V14 sudah menangani Hyperion-False|poll_claim baca baseline SEKALI per window & oper ke tiap _try_claim_once; _confirm_landed retry baca last_after dgn jeda singkat (V54); oracle stale-lalu-maju → claimed B17|2026-08-08|notif Telegram KLAIM REWARD SUKSES / Reward 0.0000 / Fee BP 0.0000 → bpdbsjasprod (22:47 WIB, ~1 mnt stlh klaim mendarat 15:46:06.5 tx 48c2066f… reward asli 1683.3641) — _measure_reward balikin Decimal('0') BUKAN None: reward_from_tx → None (Hyperion belum indeks tx yg baru mendarat) lalu fallback selisih saldo baca after == before (node balik saldo lama) → max(δ,0)=0; guard if reward is None (V45) ⊖ pernah memicu (0 ⊖ None) → fee 0 skipped + notify_claim(0,0) sukses-0 palsu; fee 168.3364 VEX utk siklus itu tak terkirim (sama utk klaim 9 Agt 4b9ec0be… reward 1683.0126 yg jalan di image V54: delta-0 → sukses-0 → fee 168.3012 tak terkirim)|_measure_reward retry reward_from_tx 4× dgn SETTLE_SECONDS (30) beri waktu Hyperion mengejar indeks; fallback selisih saldo HANYA delta POSITIF (after > before); delta 0 / keduanya gagal → None → fee pending + alert KLAIM MENDARAT · REWARD TAK TERUKUR, ⊖ sukses-0 (V55); reward 0 asli dibuktikan dari isi tx; oracle B17-a/B17-b B18|2026-08-10|notif KLAIM MENDARAT · REWARD TAK TERUKUR (txid b0a8b2f9…) padahal klaim mendarat & reward 1679.3365 nyata (tx bfa9ab02… 15:46:18, bpay 460.3928+vpay 1218.9437): attempt 1 mendarat tapi _confirm_landed baca basi (Hyperion False + last_claim_time stale) → retry; attempt 2 re-sign b0a8b2f9 ditolak chain (already claimed, HTTP 500 ⊥ raise) tapi last_claim_time SUDAH maju → dianggap mendarat; _measure_reward(b0a8b2f9, before) gagal — before dibaca-ULANG per attempt jadi SUDAH termasuk reward → reward_from_tx tx ditolak None + delta 0 → alert; fee 167.9336 tak terkirim (DB claim_runs: 46 baris reward 0 — 44 phantom 7 Agt + 9 Agt + 10 Agt; 8 Agt ⊖ baris sama sekali)|poll_claim baca before_balance (saldo) SEKALI per window & oper ke tiap _try_claim_once — delta fallback mengukur reward benar walau tx tercatat = attempt ditolak (V56); oracle B18 attempt-1-mendarat → claimed + reward asli terukur B19|2026-08-13|dua celah di jalur kirim distribusi: (1) verify_txid → None (Hyperion down) cuma print ditahan (⊥ resend) lalu FALLTHROUGH ke retry — attempt berikutnya re-sign dgn tapos/expiration BARU → txid BERBEDA → re-broadcast → ganda kalau tx pertama mendarat; oracle lama lolos karena set DISTRIBUTE_MAX_ATTEMPTS=1 (hold kebetulan = attempt terakhir); (2) pyntelope send() ⊥ raise pada HTTP 500 (pola V46/B11) — penolakan chain (duplicate, insufficient-balance) datang sbg body dict dan respons di-ABA (distribute.py:230) → payment dicatat sent padahal ⊥ mendarat, ⊖ pernah dikejar (lost reward)|(1) landed is None → break + tandai failed DI ATTEMPT PERTAMA (hold sungguhan, ⊖ resend); (2) _send_rejected(resp) deteksi body error 500 → raise → jalur verify/retry, ⊖ sent palsu (V59); oracle hold-attempt-1 (3 attempt default, build sekali per pemilih) + rejection-500 → failed B20|2026-08-13|fee klaim (claim.py _send_fee) ⊖ punya guard _send_rejected — signed.send() balikin body HTTP-500 (penolakan duplicate/insufficient-balance, V46/B11) sbg dict dan return-nya di-ABA → fee dicatat sent walau chain TOLAK → fee 10% hilang, ⊖ pernah dikejar (V34 resume hanya mengejar status failed/pending, yg sudah sent ⊖ pernah diulang)|_send_fee tangkap resp = signed.send() + _send_rejected(resp) → raise → jalur verify/failed → failed + retry siklus berikutnya (V61, F1); oracle test_claim V61 fee rejection-500 → failed ⊖ sent B21|2026-08-13|V59 hanya menahan saat verify_txid → None (Hyperion tak terjangkau) — False (executed:false, lag indeks V50/B14) FALLTHROUGH ke jalur retry → re-sign tapos BARU → txid BARU → broadcast ulang → GANDA kalau tx pertama sebenarnya mendarat; risiko nyata setiap Hyperion tertinggal indeks sesaat (pola yang sudah dibuktikan di klaim B14)|jalur exception send-loop: _verify_settled(txid) (retry 3× dgn delay utk lag indeks), True → sent+break; None (pool mati semua) ATAU False konsisten → hold failed di attempt 1, ⊖ RESEND (V61, F2); oracle test_distribute V61 verify-False → hold attempt-1 dgn MAX_ATTEMPTS=3 default B22|2026-08-13|_send_rejected BLACKLIST (deteksi error di body) — body 500 bentuk lain (proxy tanpa error, {"code":500,"message":...}) atau 200 soft_fail/delayed receipt dianggap SUKSES → dicatat sent walau tx gagal/ditolak|whitelist: sukses HANYA dict ber-transaction_id + receipt executed/tanpa-status; sisanya → tolak (V61, F3); oracle _send_rejected 5 bentuk (sukses nodeos, 500 ber-error, 500 tanpa error, soft_fail, non-dict) B23|2026-08-13|respons /v2/* bentuk non-dict (200-an [...]/"..." dari proxy salah) → AttributeError: 'list' object has no attribute 'get' DII DALAM except RequestException → run distribusi BEBENTI total; sama di verify_txid/reward_from_tx/dashboard._get_liquid_vex|guard isinstance(data, dict) di semua pembaca /v2/* → non-dict → skip/skip-node/None (V61, F5); oracle _get_json/verify_txid non-dict → None