Compare commits

..
47 Commits
Author SHA1 Message Date
proitlab c262b4677c fix(B27): gate warn notify on real Telegram delivery (V66a) + scan config.env bind
VOTE ULANG SEGERA never reached community channel though dashboard badge showed:
- scan service lacked NFS config.env bind -> empty TELEGRAM_* -> send_text silent no-op
- _notify_warn still record_notify'd -> fake dedup rows blocked future sends

send_text/notify_warn now return delivery bool; record_notify only on True.
scan service mounts config.env:/app/.env:ro.
2026-09-02 12:39:24 +07:00
proitlab 8ad7df00f0 feat: crypto mining isometric cube favicon + icons
- static/favicon.svg: isometric 3D cube, white on black, DESIGN.md surface tones
- static/favicon-16.png, favicon-32.png, icon-192.png, icon-512.png, favicon.ico
- index.html + history.html: <head> links for SVG, PNG, ICO, apple-touch-icon
2026-08-28 12:13:28 +07:00
proitlab 1e93437a90 feat: VOTE ULANG SEGERA Telegram notification + emoticon on all notifications
- telegram.py: emoticons on all existing notif (🚀✅⚠️🔴💰🔍) + new notify_warn for expiring voters
- db.py: voter_notify table (owner/urgency/notified_at) for state-change dedup
- get_voters.py: _notify_warn() after persist() — urgency escalation ⚠️/🔴/🚨 by days remaining
- Dockerfile.scan: add telegram.py to COPY
- test_get_voters.py: 15 new V66 assertions (schema, record, cleanup, integration, escalation)
- test_distribute.py: updated notification format assertions
- SPEC.md: V66 + B26
2026-08-27 16:12:53 +07:00
proitlab eaefad6c1d chore: rename DAFTAR PEMILIH → SIMPLE MINING in title and heading 2026-08-22 18:04:42 +07:00
proitlab 9e092ebdbc fix: UTC-naive timestamps in distribute/db audit trail (V65)
distribute.py created_at, db.py updated_at/claim_fee now use
datetime.now(timezone.utc).replace(tzinfo=None) to match V62 scan stamps
and eliminate WIB (UTC+7) drift in payment/claim records.
2026-08-22 17:52:45 +07:00
proitlab 5c1c7572d3 T56: jeda antar kirim distribusi (V63) — DISTRIBUTE_SEND_DELAY (default 30s) ANTAR pemilih di send-loop, N pemilih → N−1 tidur (⊥ setelah terakhir), real-run saja (dry-run ⊖ tidur); beri node/Hyperion waktu mengejar indeks + meratakan beban broadcast 2026-08-19 10:58:46 +07:00
proitlab efd0e9374b backprop §B.25 + §V.64: oracle banner BERITA hermetik — route index pakai helper _now() injectable (⊖ datetime.now() inline) utk _next_schedule/_recurrence_label, oracle mock dashboard._now ke tanggal tetap sebelum/sesudah launch (setelah tanggal first-run baked 2026-08-17 lewat di kalender nyata, asersi baris-berulang-TAK-tampil gagal walau _next_schedule di-mock; test lulus hanya kalau kalender < tanggal launch) 2026-08-19 10:58:43 +07:00
proitlab 56ff265d11 stempel scan UTC-naive utk scanned_at/first_seen_at (V62/B24) — pemilih BARU ⊖ tertahan 7 jam 2026-08-17 00:55:53 +07:00
proitlab d6b5927307 red-team send-path hardening: hold pada verify False/None, _send_rejected whitelist, fee guard, non-dict skip (V61/B20-B23) 2026-08-13 23:12:51 +07:00
proitlab 69704fa23d failover pool Hyperion utk semua pembacaan /v2/* (V60)
verify_txid (distribute) ⊖ lagi single-host single-shot — blip Hyperion
sesaat dulu menahan payout (V59 hold) / gagal ukur reward klaim / kosongkan
SALDO LIQUID dashboard.

config.py: 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 GET mirror _post_json V58
(3 putaran, 2s, coba semua host per putaran, semua gagal → None) dipakai
verify_txid — None kini berarti SEMUA host Hyperion gagal, hold V59 jarang
palsu ; claim.reward_from_tx via dist._get_json (tiap attempt settle V55
pool-robust) ; dashboard._get_liquid_vex loop HYPERION_NODES lokal (web ⊖
impor distribute, V31), SALDO LIQUID tetap live saat host mati

oracle: test_distribute V60 failover verify_txid + semua-mati None ;
test_claim V60 reward_from_tx failover ; test_dashboard V60 saldo cadangan +
config default HYPERION_NODES ; docs SPEC §V/V60 + §I + T53 + AGENTS +
.env.example

image web + dist + claim rebuild + push ; live smoke pool /v2/health
2026-08-13 21:30:31 +07:00
proitlab 663770f51b rename DATABISNIS_API → HYPERION_API (nama menyesatkan: host itu Hyperion vexascan.com, ⊖ API databisnis sendiri; rename bersih simbol + env)
config.py: HYPERION_API = getenv('HYPERION_API') or getenv('DATABISNIS_API') or
default — env lama masih dibaca utk backward-compat deploy (NFS config.env ⊖
perlu diubah) ; konsumen (dashboard _get_liquid_vex, distribute verify_txid,
claim reward_from_tx) rename simbol ; .env.example + docker-compose.yml
(HYPERION_API: ${HYPERION_API:-...} x3 service) ; oracle test_dashboard:
default + fallback env lama (importlib.reload) ; docs SPEC + AGENTS

image web + dist + claim rebuild + push
2026-08-13 21:22:33 +07:00
proitlab fb16eeafaa distribusi hardened vs node mati + send ⊖ pernah menyesatkan (V58/V59)
V58 failover node pool: config += API_NODES (CSV VEX_API_NODES, default
v2.vexascan.com:2096 + https://api.databisnis.id, mainnet terverifikasi) ;
distribute _post_json coba 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 backoff [30..180]s ~10 mnt real-run /
dry-run satu attempt ; scan/claim tetap API_NODE tunggal, fee klaim mewarisi
failover gratis ; oracle failover saldo + semua-mati raise + backoff + mock Net

V59 send aman per pemilih (B19): (1) verify_txid → None (Hyperion down) =
TAHAN sungguhan — break + failed di attempt 1, ⊖ resend dgn tapos/expiration
baru → txid baru → double-send (kode lama cuma print ditahan lalu fallthrough
ke retry, oracle lolos karena set MAX_ATTEMPTS=1) ; (2) _send_rejected(resp)
deteksi body HTTP-500 pyntelope ⊖ raise (pola V46/B11, duplicate/
insufficient-balance) → raise → jalur verify/retry, ⊖ pernah sent utk tx yang
⊥ mendarat ; oracle hold-attempt-1 (3 attempt default, build sekali per pemilih)
+ rejection-500 → failed ; docs §V58/V59 + §B/B19 + T51/T52 + AGENTS

image dist + claim rebuild + push (claim impor distribute)
2026-08-13 20:52:32 +07:00
proitlab 8e80653df9 klaim baseline saldo stabil (V56/B18) + endpoint cek akun payout-eligible /api/voter (V57)
V56/B18: pre-claim liquid balance dibaca SEKALI per window (poll_claim) dan dibawa ke tiap _try_claim_once — klaim yang mendarat di attempt 1 membuat before attempt 2 (ditolak already-claimed) sudah termasuk reward → delta 0 → alert palsu KLAIM MENDARAT · REWARD TAK TERUKUR (kasus 10 Agt: bfa9ab02 reward 1679.3365 vs tx ditolak b0a8b2f9) ; oracle B18 attempt-1-mendarat → claimed + reward asli terukur

V57: db.py single source eligible_voters(owner=None) — SQL jendela payout V40/V52 (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 (refactor, hapus _eligible_cutoffs) MAUPUN endpoint baru GET /api/voter/<owner> → 200 {owner, is_valid_voter} (DB-snapshot, selalu 200, ⊖ format-validation); BARU/kadaluarsa/hidden/asing → false ; docs §I/§V/V57 + T50 + AGENTS ; oracle test_dashboard (V57 true/false + drift endpoint==db pool + pool distribusi==db) + test_distribute (mock eligible_voters utk no-op, distribusi luas)
2026-08-12 22:28:42 +07:00
proitlab ee4682da00 klaim: reward tak terukur ⊖ pernah jadi sukses-0 (V55/B17): _measure_reward retry reward_from_tx 4x dgn SETTLE_SECONDS=30 (tx baru mendarat belum terindeks Hyperion → None palsu) lalu fallback selisih saldo HANYA delta positif (after > before) — delta 0 ambigu (node balik saldo lama) → None → fee pending + alert KLAIM MENDARAT · REWARD TAK TERUKUR, ⊖ notif KLAIM REWARD SUKSES 0.0000 palsu (kasus 8 Agt: klaim 48c2066f reward 1683.3641 tapi app notif 0.0000, fee tak terkirim) ; reward 0 asli dibuktikan dari isi tx (Decimal 0) ; oracle B17-a delta-0 → pending, B17-b catch-up reward asli ; image claim rebuild + push 2026-08-10 06:47:36 +07:00
proitlab 2f043c20bd klaim: baseline landing stabil per window (V54/B16): poll_claim baca last_claim_time sekali & oper ke tiap _try_claim_once (⊥ baseline ikut maju tiap attempt → retry-loop permanen walau klaim mendarat) ; _confirm_landed retry baca last_after (node RPC lag) ; oracle stale-lalu-maju → claimed ; image claim rebuild + push 2026-08-08 23:13:30 +07:00
proitlab a5e8367363 promo VEXWALLET pakai logo IDRS (static/idrs.png) + BARU band kolom TOTAL REWARD → MATURITY PERIOD (V52/V53) ; image web rebuild + push 2026-08-08 21:40:23 +07:00
proitlab cd1452a353 BARU band: hapus kolom VOTE TERAKHIR + tambah countdown VALID DALAM N HARI (V52) — _edge_rows bawa first_seen_at, filter countdown (first_seen + MATURITY), grid band-list 4 kolom, .valid-in ; image web rebuild + push 2026-08-08 21:09:01 +07:00
proitlab d6a15a07ae maturity pemilih baru diukur dari first_seen_at (V52/B15): last_vote terkuantisasi pekan (stake2vote) membuat voter baru Senin–Kamis tampak matang → lolos wait 3-hari ; BARU = first_seen baru/NULL, VALID = first_seen matang ; dashboard + distribusi konsisten ; image web+dist rebuild + push 2026-08-08 00:46:11 +07:00
proitlab 9b040a8c71 dashboard: nama akun jadi link https://vexascan.com/account/{owner} (V51) — index list (4 spot) + history pay-list + live search, .owner-link target=_blank ; image web rebuild + push 2026-08-08 00:07:55 +07:00
proitlab 5618bad427 klaim: _confirm_landed ⊥ menyerah saat Hyperion executed:false (V50/B14) — tx baru mendarat belum terindeks (lag) dianggap tak mendarat → retry-loop permanen, reward & fee tak tercatat ; landing = Hyperion executed ATAU last_claim_time maju, dicek utk verified False MAUPUN None ; oracle + image claim rebuild + push 2026-08-07 23:01:46 +07:00
proitlab 642822989c klaim: math jendela UTC-naive (V49/B13): _utcnow() di step/poll_claim/_try_claim_once konsisten dgn last_claim_time chain — ⊥ datetime.now() lokal (TZ Asia/Jakarta) yang menggeser jendela ~7 jam → poll prematur ditolak chain → retry tanpa henti ; oracle UTC + image claim rebuild + push 2026-08-07 22:34:57 +07:00
proitlab 0e83137b5a dashboard: hapus TOTAL VEX + perbaiki wrap mobile (V48) ; catatan stats-note dipendekkan (buang perbandingan vexascan) ; formula last_vote 52-pekan (V13/B12) ; image web rebuild + push 2026-08-07 22:04:25 +07:00
proitlab e9df89aaac klaim: ⊥ percaya send() semata (V46/B11): landing dikonfirmasi di jalur sukses & exception via _confirm_landed (Hyperion executed ATAU last_claim_time maju dari baseline) — send() pyntelope ⊥ raise saat chain tolak (HTTP 500 sbg body) ; tak terkonfirmasi → retry senyap ⊥ catat/notif ; next_window baca producer gagal → raise (⊥ clamp prematur) ; image claim rebuild + push 2026-08-07 16:45:05 +07:00
proitlab cc9df71d9e klaim: anti false-success + ukur reward dari isi tx (V45): deteksi mendarat = Hyperion executed ATAU last_claim_time maju dari baseline (⊥ heuristik 24 jam) ; reward_from_tx (bpay+vpay → BP) prioritas, selisih saldo fallback ; reward tak terukur → fee pending + notif KLAIM MENDARAT · REWARD TAK TERUKUR ; next_window baca producer di-retry (⊥ clamp prematur) ; image claim rebuild + push 2026-08-07 16:22:54 +07:00
proitlab a6db3be436 jadwal distribusi multi-hari Rabu & Sabtu (V44/T49): DISTRIBUTE_WEEKDAY=wed,sat (CSV), next_boundary pilih hari terdekat, banner BERITA SETIAP RABU & SABTU ; image web+dist rebuild + push 2026-08-07 15:49:26 +07:00
proitlab bdb5923097 compose: publik port web 5000 → 5001 (host) 2026-08-07 12:08:54 +07:00
proitlab 81ec6bdfe7 V40-V43: band umur vote (BARU/VALID/KADALUARSA, scan simpan semua umur) → REVOTE = VALID langsung (V42) → tag mint 1-hari + badge VOTE ULANG SEGERA (V43) ; klaim recovery jendela lewat + parse respons dict rows (B7/B8) ; backend mysql test_mariadb ; oracle test_dashboard/distribute/get_voters/mariadb ; images web/scan/dist/claim rebuild + push 2026-08-07 12:07:34 +07:00
proitlab af9a0291b9 banner BERITA: sembunyikan baris SETIAP SABTU saat event berikutnya = first-run (17 Agt) — hanya tampil utk putaran mingguan 2026-08-06 21:44:19 +07:00
proitlab 192e27aff3 first run distribusi baked 17 Agt 2026 (config default + loop via config + compose) ; saldo liquid pindah paling kiri ; empty-state history jadi BELUM ADA DISTRIBUSI 2026-08-06 21:35:19 +07:00
proitlab 2f1f19c435 jadwal distribusi mingguan + banner BERITA jadwal berikutnya di dashboard (V37/V38, T43/T44) 2026-08-06 19:05:03 +07:00
proitlab b1e6e93796 klaim reward BP harian (claim service V32-V34) + kill-switch DISTRIBUTE_ENABLED (V35) + Telegram dua kanal komunitas/internal (V30/V36) + placement web/scan/dist/claim != server2U 2026-08-06 06:30:19 +07:00
proitlab d3a0e768a0 spec: fix §I /api/search hasil tambah total_reward; §C lib += pyntelope 2026-08-06 05:42:13 +07:00
proitlab 0b1749a062 backprop §B.4 + §V.31: image dist ⊥ bawa modul yang diimpor → ModuleNotFoundError (oracle test_images.py) 2026-08-06 05:36:24 +07:00
proitlab 025f46d399 distribusi: notifikasi Telegram mulai/selesai/gagal (telegram.py, V30) 2026-08-06 05:30:22 +07:00
proitlab 397e20ec3a spec: T25 x — service distribute (Dockerfile.dist + distribute_loop.py, key via bind NFS config.env:/app/.env:ro) 2026-08-05 22:38:13 +07:00
proitlab 83e294dec8 stack: distribute baca VEX_BP_PRIVATE_KEY dari bind NFS config.env:/app/.env:ro (bukan interpolasi) 2026-08-05 22:34:30 +07:00
proitlab 4e02701887 stack: service distribute (Dockerfile.dist + distribute_loop.py, harian DISTRIBUTE_HOUR) 2026-08-05 22:22:24 +07:00
proitlab f77681793f history: grid pay-list desktop 200/200/120 + TXID fleksibel, tablet 180/180/110, ?v=8 2026-08-05 21:16:18 +07:00
proitlab d30dd8b292 history: TXID pay-list kolom diukur (460px), ?v=7 2026-08-05 21:05:42 +07:00
proitlab 3ad5eb32d2 dashboard: kolom TOTAL REWARD per pemilih + TXID jadi link explorer 2026-08-05 20:56:28 +07:00
proitlab 344de409cd history: payments hanya run terbaru, TANGGAL pindah ke judul, MEMO dihapus (DB+UI) 2026-08-05 20:37:50 +07:00
proitlab a3251eebc4 dashboard: kolom AKUN/STAKE/VOTE sama lebar di desktop 2026-08-05 19:58:35 +07:00
proitlab e4fa6bf1dc dashboard: seimbangkan kolom dan responsifkan riwayat 2026-08-05 17:28:38 +07:00
proitlab 9a269e224c tata letak: kolom STAKE (VEX) rata tengah 2026-08-05 16:57:08 +07:00
proitlab 8ab8001b34 T19-T28: distribusi profit harian (distribute.py, /history, tests) 2026-08-05 16:07:36 +07:00
proitlab 656f26085f T18: stack swarm docker — image web+scan, scan_loop.py, mariadb bind-mount server5.saltis.id
- Dockerfile (web) + Dockerfile.scan (scan; sertakan tzdata) + .dockerignore
- scan_loop.py: jadwal skan SCAN_HOURS (0,8,16 lokal via TZ) + skan-awal SCAN_RUN_ON_START; loop tak keluar; tunggu DB siap
- docker-compose.yml → STACK SWARM produksi (mariadb bind mount /data/db/mariadb/databisnisid/data + placement node.hostname == server5.saltis.id; web :5000; scan); image dari registry git.proit.id/proitlab/databisnisid-{web,scan}
- docker-compose.dev.yml: mariadb uji lokal (127.0.0.1:3306) — pengganti docker-compose.yml lama
- SPEC.md §V18/T18; AGENTS.md runbook swarm + dev compose
- .env.example: TZ/SCAN_HOURS/SCAN_RUN_ON_START/MARIADB_*
2026-08-05 14:26:29 +07:00
proitlab fdb5d0de15 T17: backend penyimpanan bersama db.py (sqlite | mysql via PyMySQL)
- db.py: abstraksi connect/query/queryone/replace_snapshot; %s → ? utk sqlite;
  replace snapshot mysql = CREATE IF NOT EXISTS + DELETE + INSERT (MVCC)
- config/.env.example: VEX_DB_BACKEND (default sqlite) + VEX_DB_HOST/PORT/USER/PASS/NAME
- get_voters.persist → db.replace_snapshot; dashboard baca via db.query
- LIKE pakai ESCAPE '!' (backslash memutus literal string MySQL, B2);
  db.query selalu kembalikan list (PyMySQL tuple vs sqlite list, B3)
- docker-compose.yml: kontainer uji mariadb:11 (databisnisid-mariadb, 127.0.0.1:3306)
- test_mariadb.py: suite integrasi opt-in (skip bila bukan mysql)
- SPEC.md §V17/T17/B2/B3; AGENTS.md runbook
2026-08-05 12:41:17 +07:00
41 changed files with 5720 additions and 235 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Artifak runtime & env lokal — jangan masuk build context
venv/
.git/
.env
voters.db
.oc/
__pycache__/
*.pyc
*.log
+103 -3
View File
@@ -7,9 +7,25 @@ VEX_TARGET_BP=databisnisid
# Node RPC publik Vexanium (ganti bila node utama lambat/timeout) # Node RPC publik Vexanium (ganti bila node utama lambat/timeout)
VEX_API_NODE=https://v2.vexascan.com:2096 VEX_API_NODE=https://v2.vexascan.com:2096
# Node pool RPC utk failover distribusi (V58): CSV, urutan prioritas. Default =
# `VEX_API_NODE` + https://api.databisnis.id (node mainnet terverifikasi).
# Distribusi coba node berikutnya bila node sebelumnya gagal.
# VEX_API_NODES=https://v2.vexascan.com:2096,https://api.databisnis.id
# Lokasi file SQLite (disarankan absolut bila dijadwalkan via cron) # Lokasi file SQLite (disarankan absolut bila dijadwalkan via cron)
VEX_DB_PATH=voters.db VEX_DB_PATH=voters.db
# Backend basis data: sqlite (default) atau mysql (MariaDB/MySQL via PyMySQL)
VEX_DB_BACKEND=sqlite
# Koneksi MariaDB/MySQL — dipakai saat VEX_DB_BACKEND=mysql
# Cocokkan dengan docker-compose.yml (MARIADB_DATABASE/USER/PASSWORD)
VEX_DB_HOST=127.0.0.1
VEX_DB_PORT=3306
VEX_DB_USER=databisnis
VEX_DB_PASS=databisnis
VEX_DB_NAME=databisnisid
# Syarat minimum stake (VEX) agar pemilih disimpan # Syarat minimum stake (VEX) agar pemilih disimpan
VEX_MIN_STAKED_VEX=1000 VEX_MIN_STAKED_VEX=1000
@@ -23,11 +39,95 @@ DASH_PORT=5000
# Dashboard: jumlah worker gunicorn (produksi) # Dashboard: jumlah worker gunicorn (produksi)
DASH_WORKERS=2 DASH_WORKERS=2
# Ambang batas vote "basi" (hari sejak revote terakhir) untuk penanda staleness # Ambang batas vote "basi" (hari sejak revote terakhir). Pemilih lewat batas
# ini disimpan scan tapi keluar band VALID (bagian KADALUARSA 28..31 hari,
# >31 hari disembunyikan dari dashboard).
VEX_STALE_DAYS=28 VEX_STALE_DAYS=28
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard) # Masa matang (hari): pemilih baru (revote < VEX_MATURITY_DAYS lalu) BELUM
DATABISNIS_API=https://api.databisnis.id # berhak reward — dibayar mulai siklus berikutnya setelah votenya berumur ≥
# ambang ini (band VALID).
VEX_MATURITY_DAYS=3
# Jendela "kadaluarsa" (hari SETELAH VEX_STALE_DAYS) yang masih tampil di
# dashboard dengan sorot VOTE ULANG; lewat 28+3=31 hari → disembunyikan.
VEX_EXPIRED_DAYS=3
# Tag REVOTE (mint) bertahan (hari) sejak revote terakhir: hanya pemilih lama
# yang revote dalam VEX_REVOTE_TAG_DAYS terakhir diberi tag; setelah itu tampil
# sbg VALID biasa (tetap dibayar langsung — V42, ⊥ tunggu matang).
VEX_REVOTE_TAG_DAYS=1
# Jendela peringatan (hari SEBELUM VEX_STALE_DAYS): pemilih VALID yang
# `now − STALE < last_vote ≤ now − (STALE − WARN)` diberi badge `VOTE ULANG
# SEGERA` (segera kadaluarsa, masih dibayar).
VEX_WARN_DAYS=3
# Hyperion API untuk saldo liquid akun + verifikasi txid (balance BP,
# disajikan dashboard; verifikasi distribusi/klaim). Nama lama `DATABISNIS_API`
# masih dibaca utk backward-compat deploy lama.
HYPERION_API=https://api.databisnis.id
# Pool Hyperion (V60, failover): CSV host yang melayani /v2/* — verifikasi
# txid distribusi/klaim + saldo liquid dashboard. Default = HYPERION_API +
# API_NODES (keduanya mainnet terverifikasi layani /v2). Kosongkan → default.
# VEX_HYPERION_NODES=https://api.databisnis.id,https://v2.vexascan.com:2096
# Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown # Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown
DASH_LIQUID_TTL=60 DASH_LIQUID_TTL=60
# — Stack swarm (docker-compose.yml) —
# Interpolasi compose; override default di bawah lewat .env bila perlu.
# TZ dipakai juga sebagai zona waktu jadwal skan (`datetime.now()` lokal).
TZ=Asia/Jakarta
SCAN_HOURS=0,8,16
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
SCAN_RUN_ON_START=1
# — Distribusi profit-share (service distribute) —
# Jam (lokal, TZ) run. Jadwal MINGGUAN (V37): tiap DISTRIBUTE_WEEKDAY (nama
# `sat`/`mon`.. atau 0-6; Senin=0, CSV utk beberapa hari: `wed,sat` = Rabu &
# Sabtu) jam DISTRIBUTE_HOUR, plus satu one-off DISTRIBUTE_FIRST_RUN
# (YYYY-MM-DD; default baked `2026-08-17`). Contoh produksi:
# DISTRIBUTE_FIRST_RUN=2026-08-17 # putaran pertama Senin 17 Agt
# DISTRIBUTE_WEEKDAY=wed,sat # lalu tiap Rabu & Sabtu
# `VEX_BP_PRIVATE_KEY` TIDAK lagi lewat interpolasi .env: di stack ia dibaca
# dari file `.env` yang di-bind dari NFS
# (`/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env`,
# dibaca `config.py` via load_dotenv). Di luar stack, set key di `.env` lokal.
DISTRIBUTE_HOUR=10
DISTRIBUTE_WEEKDAY=wed,sat
DISTRIBUTE_FIRST_RUN=2026-08-17
DISTRIBUTE_MAX_ATTEMPTS=3
# Jeda (detik) ANTAR kirim transfer antar pemilih — beri node/Hyperion waktu
# mengejar indeks + meratakan beban broadcast. N pemilih → N−1 jeda per run.
DISTRIBUTE_SEND_DELAY=30
# Kill-switch: FALSE (default) = payout off; set TRUE untuk mengaktifkan
# pembayaran. --dry-run tetap jalan walau off.
DISTRIBUTE_ENABLED=false
# — Notifikasi Telegram (dua kanal) —
# Satu bot token; dua daftar chat id (CSV; id user atau grup):
# TELEGRAM_COMMUNITY_CHAT_IDS — publik: hanya brief MULAI/SELESAI distribusi
# TELEGRAM_INTERNAL_CHAT_IDS — internal: detail distribusi, semua kegagalan,
# dan klaim reward (⊥ pernah ke komunitas)
# Kosong = kanal senyap. Di stack, isi di config.env NFS (dibaca config.py
# load_dotenv dari /app/.env).
TELEGRAM_BOT_TOKEN=
TELEGRAM_COMMUNITY_CHAT_IDS=
TELEGRAM_INTERNAL_CHAT_IDS=
# — Klaim reward BP harian (service claim) —
# Poll `vexcore::claimrewards` tiap CLAIM_RETRY_SECONDS (detik) saat mendekati
# jendela klaim (last_claim_time + 24 jam). Setelah klaim sukses, 10% reward
# (VEX_BP_FEE_PERCENT) dikirim ke VEX_BP_FEE_WALLET. Key `VEX_BP_PRIVATE_KEY`
# di stack dari bind NFS config.env (sama dgn distribute).
CLAIM_RETRY_SECONDS=60
VEX_BP_FEE_WALLET=bpdbsjasprod
VEX_BP_FEE_PERCENT=0.10
# Kredensial mariadb stack — wajib konsisten antar-service (mariadb/web/scan)
MARIADB_ROOT_PASSWORD=root
MARIADB_DATABASE=databisnisid
MARIADB_USER=databisnis
MARIADB_PASSWORD=databisnis
+3
View File
@@ -4,6 +4,9 @@ scan.log
__pycache__/ __pycache__/
*.pyc *.pyc
# opencode session state
.oc/
# local env config (secrets/overrides — jangan di-commit) # local env config (secrets/overrides — jangan di-commit)
.env .env
+32 -12
View File
@@ -1,15 +1,21 @@
# AGENTS.md # AGENTS.md
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters on a weekly schedule: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`. A fifth tool (`claim.py`/`claim_loop.py`, service `claim`) auto-claims the BP reward: at the 24h window it calls `vexcore::claimrewards`, then transfers 10% of the claimed reward to the fee wallet `bpdbsjasprod` (spec §V32–V34).
## Run ## Run
- Node reference script: `node get_voters.js` (no deps, Node 18+). - Node reference script: `node get_voters.js` (no deps, Node 18+).
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`. - Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) + `pyntelope` (distribute signing) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
- Web dashboard (read-only, reads `voters.db`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans. - Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot **restricted to the reward window** (not stale AND account mature, `last_vote > now−28d` AND `first_seen_at ≤ now−3d`, §V40/V52: new voters (`first_seen` recent/NULL) and stale voters get nothing — returning re-voters with mature first_seen are paid regardless of vote age), fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed. **Send safety (V59/V61)**: if a transfer broadcasts but Hyperion can't confirm it, the payment is held for real — marked `failed` at the first attempt and never re-signed (a resend would carry a fresh tapos/expiration → new txid → possible double-send). `verify_txid → None` (Hyperion unreachable) and `executed: false` (index lag, V50/B14) BOTH hold now — the send-loop exception path runs `_verify_settled` (retries the verify 3× with 2s delay to let Hyperion catch up; None → False immediately, pool fully dead) and only `True` marks `sent` + breaks, anything else holds (V61/B21). Chain rejections that come back as HTTP-500 bodies (pyntelope does NOT raise on them — duplicate/insufficient-balance, pattern V46/B11) are detected by `_send_rejected` (V61: WHITELIST — success is only a dict with `transaction_id` + receipt `executed`/no status; any other 500 body or soft_fail → rejected) and routed through the verify/retry path, never recorded `sent` for a tx that didn't land. **Pacing (V63)**: the send loop sleeps `DISTRIBUTE_SEND_DELAY` (default 30s) between voters — N voters → N−1 sleeps, none after the last — so the node/Hyperion can catch up with indexing and broadcast load stays even (real run only; `--dry-run` never sleeps). All `/v2/*` reads guard `isinstance(data, dict)` so a non-dict 200 (proxy misconfig) is skipped, never a crash (V61/B23). **Node resilience (V58)**: all chain RPC calls fail over across the `API_NODES` pool (`VEX_API_NODES` CSV, default `v2.vexascan.com:2096` + `https://api.databisnis.id`) — balance fetch, ABI+TAPOS, and broadcast; the real-run balance fetch retries with backoff ~10 min before aborting to the next schedule, `--dry-run` fails fast. **Hyperion resilience (V60)**: every `/v2/*` read (`verify_txid`, `claim.reward_from_tx`, dashboard SALDO LIQUID) fails over across the `HYPERION_NODES` pool (`VEX_HYPERION_NODES` CSV, default = `HYPERION_API` + `API_NODES` dedup — both hosts verified to serve Hyperion) via `distribute._get_json` (GET mirror of the V58 `_post_json`, 3 rounds, 2s), so a single Hyperion blip no longer force-holds payments or fails reward measurement.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. - Daily reward claim (production): `./venv/bin/python claim_loop.py` — the `claim` service scheduler. `claim.py` computes the claim window as `last_claim_time + 24h` (from the `vexcore` `producers` table) in **UTC-naive time** (`_utcnow()`, matching the chain's UTC `last_claim_time`; the container's local TZ must not shift the window — V49/B13), sleeps until near it, then polls `vexcore::claimrewards` every `CLAIM_RETRY_SECONDS` (default 60) until the chain accepts; if 24h have already elapsed since the last claim the window clamps to now (missed-window recovery, no permanent spin-timeout). A claim is only treated as "landed" if Hyperion says `executed` OR `last_claim_time` actually advanced past its pre-send value (`_claim_time_advanced`) — on **both** the send-success and send-exception paths (`_confirm_landed`), because pyntelope's `send()` does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11), and Hyperion returning `executed: false` for a just-landed-but-not-yet-indexed tx must NOT count as "didn't land" (V50/B14). Unconfirmed ⇒ silent `retry` (no `claim_runs` row, no success notify) — self-heals when Hyperion indexes the tx on the next poll. The landing baseline (`last_claim_time` before send) is captured **once per poll window** in `poll_claim` and passed to every `_try_claim_once` — it must NOT be re-read per attempt, or a landed-but-stale-first-read claim falls into a permanent retry loop (V54/B16); `_confirm_landed` also retries the post-send `last_claim_time` read so a lagging RPC node doesn't cause a false negative. The **pre-claim liquid balance** is likewise captured **once per poll window** (`before_balance`) and passed to every `_try_claim_once` (V56/B18) — if a claim lands on attempt 1, the next (rejected "already claimed") attempt must still measure the reward via the window-start balance, not a re-read `before` that already includes the credit (which reads delta 0 → false `KLAIM MENDARAT · REWARD TAK TERUKUR`, e.g. the 2026-08-10 claim `bfa9ab02…` reward 1679.3365 recorded against rejected tx `b0a8b2f9…`). `next_window` raises if all producer reads fail (never clamps to now prematurely). Reward is measured **from the claim tx itself** (sum of `vex.bpay`+`vex.vpay` → BP transfers via Hyperion, `reward_from_tx`), **retried up to 4× at `SETTLE_SECONDS` (30s)** so a just-landed tx not yet indexed by Hyperion doesn't read as `None` (V55/B17); if Hyperion is down it falls back to the liquid-balance delta, but **only a positive delta (`after > before`) counts as measured** — a 0 delta is ambiguous (stale node balance) and is treated as unmeasured, never as a genuine zero; if neither can be read the claim is recorded with fee `pending` and an internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert is sent (never a misleading 0-reward success — a real 0-reward claim must be evidenced by `reward_from_tx` returning `Decimal('0')` from an executed tx). 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py gunicorn.conf.py test_get_voters.py test_dashboard.py`. - Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py` and `./venv/bin/python test_dashboard.py` must both exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. - Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container:
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5001`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` twice-weekly at `DISTRIBUTE_WEEKDAY` default `wed,sat` (CSV, multi-day V44) + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42/V52): BARU (new first-time voters, `first_seen_at` within 3 days, own table on top, never paid — maturity is measured from `first_seen_at`, NOT `last_vote` which is week-quantized) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with `VOTE ULANG` badge + legend) then VALID (the paged 50/page list, `staked DESC`, live owner search via `/api/search`); voters older than 31 days are stored by the scan but hidden. Since V42, **REVOTE** (returning voters, `voter_first_seen.first_seen_at ≤ now−3d`) are VALID immediately — they join the main list tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`), no separate band, no maturity wait; since V43 the mint tag shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS` (default 1 day) — after that they're plain VALID (still paid); VALID rows within `VEX_WARN_DAYS` (default 3) of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge (legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH`); only genuinely new voters pass the 3-day maturity in BARU. Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_API_NODES` (CSV pool RPC failover distribusi V58, default = `VEX_API_NODE` + `https://api.databisnis.id`), `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`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `HYPERION_API` (rename dari `DATABISNIS_API`; nama lama masih dibaca utk backward-compat), `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY` (CSV multi-hari V44, default `wed,sat`), `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_SEND_DELAY` (jeda detik ANTAR kirim antar pemilih, default 30, V63), `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37/V44) is pure calendar logic — weekly schedule boundary (multi-day CSV) + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
## Key facts ## Key facts
@@ -20,18 +26,31 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- The node sometimes returns `staked` as a string instead of a number — normalize coerces to float (spec §V8). - The node sometimes returns `staked` as a string instead of a number — normalize coerces to float (spec §V8).
- The voters table has a sentinel first row (`owner="...........q"`, uint64 0) that must be skipped. - The voters table has a sentinel first row (`owner="...........q"`, uint64 0) that must be skipped.
- Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6). - Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6).
- Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`. - Freshness filter (spec §V13/V40): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(weeks since 2000 / 52)` → `last_vote = 2000-01-01 + round(52 × log2(weight / (staked×10000))) weeks` — the chain (`stake2vote`) quantizes the exponent to whole weeks (`int64((now−2000-01-01)/(86400×7))/52.0`), so derived dates land on 7-day boundaries (B12; earlier 365.25-day divisor drifted ~33 days forward for 2026 votes). Since V40 `normalize()` stores **every** BP voter with a derivable `last_vote` — any age; only unverifiable votes (zero/empty weight) are dropped. Banding (BARU/VALID/KADALUARSA), display and payout are computed at query time (dashboard cuts 28/31 days on `last_vote` age; distribution pays `last_vote > now−28d`). **Maturity (3 days) is measured from `first_seen_at`, not `last_vote`** — `last_vote` is week-quantized so a new voter voting Mon–Thu would look 4–6 days old and bypass the wait (B15); V52 uses `voter_first_seen.first_seen_at` (scan-precise to the second) for the new-voter wait. `first_seen_at`/`scanned_at` are stored UTC-naive by the scan (V62/B24) — all age cutoffs compute `datetime.now(timezone.utc)`, so the comparison is apples-to-apples; a local-TZ stamp would hold new voters in BARU ~7h longer (B24). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`. Since V41, `voter_first_seen.first_seen_at` separates PEMILIH BARU (new, first-seen within the 3-day maturity) from REVOTE (returning re-voters); since V42 REVOTE are VALID immediately (paid + shown in the main list, mint tag), only new voters wait out maturity; since V43 the mint tag lasts only `VEX_REVOTE_TAG_DAYS` (1) and VALID rows nearing the KADALUARSA cutoff (within `VEX_WARN_DAYS`, 3) get a `VOTE ULANG SEGERA` badge — display-only.
- The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column. - The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of `distribute_payments.amount` where `status='sent'` (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=<BP>`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB. - Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key.
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {HYPERION_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next run. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam; a window already ≥24h past clamps to now so a missed claim is caught up, never spin-timeout (B8). `get_table_rows` responses are always `{"rows":[...]}` — parse `data.get('rows')`, never `data[0]` (B7, V39). Landed detection = Hyperion `executed` OR `last_claim_time` advanced past pre-send value, checked on BOTH the send-success and send-exception paths (`_confirm_landed`) because pyntelope's `send()` returns HTTP 500 as a body instead of raising on a chain rejection (V46, B11); Hyperion `executed: false` on a not-yet-indexed tx still falls back to the `last_claim_time` advance check (V50, B14). `next_window` raises if all producer reads fail so a flaky node never triggers a premature claim. Reward = sum of `vex.bpay`+`vex.vpay` → BP transfers read from the claim tx via Hyperion (`reward_from_tx`), balance-delta as fallback (V33); if neither can be read → fee `pending` + internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert, never a misleading 0-reward success (B10). 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run.
- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr); `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to `https://vexascan.com/transaction/{txid}` — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {node}/v2/state/get_account?account=<BP>` across the `HYPERION_NODES` pool (V60 — host mati → cadangan, SALDO tetap live). The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
## Layout ## Layout
- `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it. - `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.
- `get_voters.py` — production fetcher: scan → filter (stake + freshness, basi dibuang) → SQLite `voters.db` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history). - `get_voters.py` — production fetcher: scan → filter (stake-min + BP target, semua umur vote disimpan V40; hanya last_vote tak terverifikasi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history) → `db.record_first_seen` (V41: catat kemunculan pertama tiap owner ke `voter_first_seen`, idempoten). V62: `scanned_at`/`first_seen_at` disimpan UTC-naive (`datetime.now(timezone.utc)`) — konsisten dgn SEMUA cutoff umur (dashboard + distribusi) & `last_vote` (epoch UTC); jangan kembali ke `datetime.now()` lokal (B24: pemilih BARU tertahan +7 jam).
- `db.py` — storage abstraction (sqlite default | mysql via PyMySQL); `connect/query/queryone/replace_snapshot`; `%s` → `?` for sqlite; `query` returns list. Juga tabel distribusi: `distribute_runs` + `distribute_payments` (append-only) + helper `ensure_distribute_schema/record_run/record_payment/update_payment_status/update_run_status/list_runs/list_payments`. Juga tabel klaim: `claim_runs` + helper `ensure_claim_schema/record_claim/update_claim_fee/pending_claim_fee`. Juga tabel V41 `voter_first_seen` (owner PK + `first_seen_at`, append-only) + helper `ensure_first_seen_schema/record_first_seen` — dipakai dashboard membedakan PEMILIH BARU vs REVOTE. Juga helper V57 `eligible_voters(owner=None)` — single-source SQL jendela reward (payout window, V40/V52): `last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY` (cutoff UTC-naive); tanpa `owner` → semua baris `(owner, staked)` utk distribusi; dgn `owner` → baris akun itu (`[]`|one row) utk endpoint cek.
- `get_voters.js` — reference implementation only. - `get_voters.js` — reference implementation only.
- `dashboard.py` + `templates/index.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads `voters.db`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. - `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V52** (`db.eligible_voters()` — V57 single source: `last_vote > now−28d AND first_seen_at ≤ now−3d`, pemilih baru (`first_seen` baru/NULL) & basi ⊥ dibayar; akun yang sudah matang dibayar langsung, ⊖ peduli umur vote, V52) → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). **V58 failover node pool**: `_post_json` coba SEMUA `API_NODES` per putaran (semua gagal → RuntimeError); `build_signed_transfer` coba tiap node utk link+sign (net ter-bind → send ikut node sama); `fetch_balance_with_retry` backoff `[30,60,90,120,150,180]s` (~10 mnt) di real-run sebelum menyerah ke jadwal berikutnya, dry-run satu attempt. **V59 send aman**: `verify_txid` → None (Hyperion down) = TAHAN sungguhan (break + `failed` di attempt 1, ⊖ resend dgn tapos baru yang bisa ganda). **V61 send-path hardening**: `_send_rejected(resp)` WHITELIST — sukses HANYA dict ber-`transaction_id` + receipt `executed`/tanpa-status, body HTTP-500 apa pun (pyntelope ⊥ raise, pola V46/B11) → tolak → raise → jalur verify/retry, ⊖ pernah `sent` utk tx yg ⊥ mendarat; `_verify_settled(txid)` retry `verify_txid` 3× dgn delay utk lag indeks (None → False langsung, pool mati semua) — jalur exception send-loop hold (`failed`+break) pada apa pun selain True, ⊖ RESEND utk `executed:false` (B21). **V60 Hyperion failover**: `_get_json` (GET mirror `_post_json`, 3 putaran, 2s, coba semua `HYPERION_NODES` per putaran → semua gagal → None) dipakai `verify_txid` — None kini berarti SEMUA host Hyperion gagal, jadi hold V59 jarang palsu; semua pembaca `/v2/*` guard `isinstance(data, dict)` (non-dict 200 → skip/skip-node/None, ⊖ crash run, B23). **V63 jeda antar kirim**: send-loop tidur `DISTRIBUTE_SEND_DELAY` (default 30s) ANTAR pemilih — N pemilih → N−1 tidur (⊥ setelah terakhir), beri node/Hyperion waktu mengejar indeks; real-run saja (dry-run ⊖ tidur). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`, di-retry V46) → `next_window` = +24 jam, math UTC-naive via `_utcnow()` (V49/B13 — ⊥ `datetime.now()` lokal yang geser +TZ → poll prematur) → (≥24 jam lewat → clamp ke now, recovery B8); tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; deteksi mendarat via `_confirm_landed` di KEDUA jalur (send sukses & exception) = Hyperion `executed` ATAU `last_claim_time` maju dari baseline (V46/B11 — `send()` pyntelope ⊥ raise saat chain menolak, HTTP 500 sbg body; Hyperion `executed: false` utk tx baru-mendarat-belum-terindeks juga dicek lewat `_claim_time_advanced`, V50/B14); tak terkonfirmasi → retry senyap; reward = isi tx via Hyperion (bpay+vpay → BP, `reward_from_tx` via `dist._get_json` pool `HYPERION_NODES`, V60) prioritas — di-RETRY 4× dgn `SETTLE_SECONDS` (30) beri waktu Hyperion indeks tx baru (V55/B17); selisih saldo fallback HANYA delta positif (`after > before`), delta 0 ambigu → ⊥ dianggap reward 0; baseline saldo (`before_balance`) dibaca SEKALI per window & dibawa tiap attempt (V56/B18) — klaim mendarat attempt 1 lalu attempt 2 ditolak (already claimed) ⊖ boleh baca-ulang `before` yg sudah termasuk reward → delta 0 → alert palsu; reward ⊥ terukur → fee `pending` + notif `KLAIM MENDARAT · REWARD TAK TERUKUR` (V33/B10/V55/V56), ⊖ pernah `KLAIM REWARD SUKSES 0.0000` palsu; fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; fee yang ditolak chain (HTTP-500 body via `dist._send_rejected`, V61/B20) → `failed` + retry, ⊖ pernah `sent` utk fee yg ⊖ mendarat; parse `get_table_rows` pakai `data.get('rows')` (bentuk asli dict, ⊥ `data[0]` — B7/V39); `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, bentuk respons asli, temp DB).
- `claim_loop.py` — scheduler harian dalam container stack (service `claim`): `_wait_db` (duplikat scan_loop) lalu `claim.step()` terus, loop tak pernah keluar.
- `telegram.py` — kirim notifikasi status distribusi + klaim + peringatan VOTE ULANG SEGERA via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi + VOTE ULANG SEGERA saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`. V66a: `send_text`/`notify_warn` return bool pengiriman nyata (⊥ false-positive utk dedup warn, B27).
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40/V52): the voters list is split into BARU (top, `PEMILIH BARU · BELUM MATANG`, new accounts by `first_seen_at` within 3 days) and the main list (`voter-list` section, `.reward` cells, V29) where KADALUARSA rows are pinned at the top with amber styling + `VOTE ULANG` badge + legend before the paged VALID rows, which LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column; since V42 the VALID list also holds REVOTE (returning re-voters, `voter_first_seen.first_seen_at ≤ now−3d`) tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS`) — no separate band, no maturity wait; only genuinely new voters sit in BARU tagged `BARU`; since V43 VALID rows within `VEX_WARN_DAYS` of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge + legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH` (display-only); search (`?q=` and `/api/search`) covers all bands and tags each result (`group` = `baru|revote|valid|kadaluarsa` + `expired` + `expiring`); `/api/voter/<owner>` (V57) returns `{"owner", "is_valid_voter"}` — payout-eligibility check via the SAME `db.eligible_voters(owner)` window distribute pays from (never a second SQL copy), DB-snapshot only, always 200; Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard; the banner clock is injectable via `dashboard._now()` — the route calls the helper instead of `datetime.now()` inline so the oracle fixes a date instead of depending on the real wall clock, V64/B25); the `SETIAP ... · HH:00` recurrence line renders only for regular weekly rounds — hidden while the next event is the one-off first-run (`_is_first_run_next`, date ≠ weekly day). A `stats-note` caption states the list is single-vote-to-BP accounts ≥ min stake (V47); account names link to `https://vexascan.com/account/{owner}` in the index list, `/history` pay-list, and live search (`.owner-link`, V51); a promo card (`BY DATABISNISID · VEXWALLET`) links the VexWallet Android app on the index page via the IDRS logo (`static/idrs.png`) + `UNDUH DI PLAY STORE` (V53); BARU rows show a `MATURITY PERIOD` countdown column (`VALID DALAM N HARI`) instead of TOTAL REWARD. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd. - `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`; shared by get_voters & dashboard. - `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `API_NODES` (pool RPC distribusi, CSV `VEX_API_NODES`), `HYPERION_API`, `HYPERION_NODES` (pool Hyperion V60, CSV `VEX_HYPERION_NODES`, default `HYPERION_API` + `API_NODES` dedup), `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_SEND_DELAY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar. Setelah persist, `get_voters.main()` memanggil `_notify_warn(now)` — VOTE ULANG SEGERA ke kanal komunitas (V66), butuh `TELEGRAM_*` dari bind `config.env` (V66a/B27).
- `distribute_loop.py` — scheduler dua-kali-minggu dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `wed,sat`, CSV multi-hari V44) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute + claim); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `scan`, `distribute` & `claim` bawa `VEX_BP_PRIVATE_KEY`/`TELEGRAM_*` via bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (dibaca `config.py` `load_dotenv`; ⊥ interpolasi; scan juga butuh `TELEGRAM_*` utk notif warn V66a/B27). `docker-compose.dev.y...
- `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono). - `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).
- `voters.db` — SQLite output (daily snapshot, gitignored in spirit). - `voters.db` — SQLite output (daily snapshot, gitignored in spirit).
@@ -41,3 +60,4 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- Dashboard UI labels are Indonesian uppercase captions (e.g. "DAFTAR PEMILIH", "TOTAL PEMILIH"). - Dashboard UI labels are Indonesian uppercase captions (e.g. "DAFTAR PEMILIH", "TOTAL PEMILIH").
- JS style: 2-space indent, semicolons, single quotes, trailing commas, async/await. - JS style: 2-space indent, semicolons, single quotes, trailing commas, async/await.
- Python style: 4-space indent, stdlib `sqlite3`, `requests`, `flask`, PEP8. - Python style: 4-space indent, stdlib `sqlite3`, `requests`, `flask`, PEP8.
- New SQL shared across backends: `%s` placeholders (never `?`), `ESCAPE '!'` for LIKE (backslash breaks MySQL string literals), no MySQL-only syntax.
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py dashboard.py gunicorn.conf.py ./
COPY templates ./templates
COPY static ./static
EXPOSE 5000
CMD ["gunicorn", "-c", "gunicorn.conf.py", "dashboard:app"]
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
# tzdata → TZ env (jadwal klaim lokal, default Asia/Jakarta) bekerja di container
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py telegram.py distribute.py claim.py claim_loop.py ./
CMD ["python", "claim_loop.py"]
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
# tzdata → TZ env (jadwal distribusi lokal, default Asia/Jakarta) bekerja di container
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py distribute.py distribute_loop.py telegram.py ./
CMD ["python", "distribute_loop.py"]
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
# tzdata → TZ env (jadwal skan lokal, default Asia/Jakarta) bekerja di container
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py get_voters.py scan_loop.py telegram.py ./
CMD ["python", "scan_loop.py"]
-2
View File
@@ -1,2 +0,0 @@
# databisnisid
+168 -11
View File
@@ -3,14 +3,16 @@
## §G — Goal ## §G — Goal
Scan Vexanium voters table → persist `databisnisid` voters + weight in SQLite. Scan Vexanium voters table → persist `databisnisid` voters + weight in SQLite.
Re-runnable scheduled. Python 3.12, venv `./venv`. 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 ## §C — Constraints
- venv `./venv` exists (py3.12, only pip) — install deps there, ⊥ global - venv `./venv` exists (py3.12, only pip) — install deps there, ⊥ global
- lib: `requests`, `flask`, `python-dotenv`, `gunicorn` ! pip-installed; others stdlib - 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 - config via env/`.env` (`config.py`, python-dotenv); default = konstanta lama
- db: SQLite stdlib `sqlite3` → `DB_PATH` (default `voters.db`); WAL journal mode → pembaca tak terblokir saat snapshot harian - 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) - api: `API_NODE` (default `https://v2.vexascan.com:2096`, public, flaky → retry)
- saldo liquid akun BP: API databisnis (`DATABISNIS_API`, default `https://api.databisnis.id`) — diambil dashboard, cache TTL `DASH_LIQUID_TTL` (default 60s), cooldown kegagalan - 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 - contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥) — ⊥ env-able
- filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP` - filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP`
- `staked` scaled ×10000 → store ÷10000 - `staked` scaled ×10000 → store ÷10000
@@ -20,17 +22,58 @@ Re-runnable scheduled. Python 3.12, venv `./venv`.
- voter basi (revote > `VEX_STALE_DAYS`) atau `last_vote` tak diketahui → dibuang di `normalize` (⊥ disimpan); hanya pemilih segar yang masuk DB - voter basi (revote > `VEX_STALE_DAYS`) atau `last_vote` tak diketahui → dibuang di `normalize` (⊥ disimpan); hanya pemilih segar yang masuk DB
- output/comments: Indonesian - output/comments: Indonesian
- run harian → ganti snapshot (DROP+INSERT tiap skan; ⊥ history/append) - 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 ## §I — Interfaces
api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows` 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} → 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 → rows[owner, proxy, producers[], staked, last_vote_weight, proxied_vote_weight, is_proxy], more, next_key
db: `voters.db` → table `voters` (owner PK, weight, staked, scanned_at, last_vote) — ganti tiap skan; weight disimpan (audit/asal `last_vote`) tapi ⊥ ditampilkan web 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) 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 `/` (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 `/` + `?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}]}`, rank global, cap 500 web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote,total_reward}]}`, rank global, cap 500
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL` — via `config.py` (`.env`) 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_SEND_DELAY` (default 30, V63), `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 ## §V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry) V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
@@ -39,15 +82,62 @@ V3: ∀ page after first → shift 1st row (dup guard)
V4: skip `owner="...........q"` (uint64 0 sentinel) rows V4: skip `owner="...........q"` (uint64 0 sentinel) rows
V5: persist ∃ row where producers length 1 & [0]==TARGET_BP V5: persist ∃ row where producers length 1 & [0]==TARGET_BP
V6: weight stored string verbatim, ⊥ float conversion V6: weight stored string verbatim, ⊥ float conversion
V7: each scan replaces table; ∀ row → latest snapshot; ⊥ append/duplicate owner 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) V8: normalize coerces `staked` → float (node kadang memberi string)
V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000) V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000)
V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB
V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensitive); urut+paging terjaga; `rank` global (⊥ rank-dalam-filter); `?q=` & `/api/search` read-only 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` V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config`
V13: `last_vote` = `2000-01-01 + log2(last_vote_weight / (staked×10000)) × 365.25 hari` (None bila bobot/stake nol); estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` atau `< now − VEX_STALE_DAYS` dibuang di `normalize` (⊥ disimpan) → DB hanya berisi pemilih segar 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)/VOTE TERAKHIR — ⊥ BOBOT SUARA, ⊥ penanda/stat/filter basi; stats = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI); `/api/search` hasil tetap `{owner,staked,weight,rank,last_vote}` (weight disimpan & di-API, ⊥ dirender) 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 {DATABISNIS_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 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
V62: stempel scan UTC-naive (B24): `get_voters.persist` menulis `scanned_at`/`first_seen_at` dgn `datetime.now(timezone.utc).replace(tzinfo=None)` — sama basis waktu dgn SEMUA cutoff umur (dashboard `_freshness_cutoffs`/`_revote_cut`/countdown + `db.eligible_voters` + `distribute` semuanya `datetime.now(timezone.utc)`) dan dgn `last_vote` (diturunkan dari epoch 2000 UTC). Dulu `datetime.now()` (waktu LOKAL container `TZ=Asia/Jakarta`, UTC+7) → `first_seen_at` tersimpan WIB tapi dibandingkan sbg string UTC → semua ambang maturity BARU/REVOTE bergeser +7 jam: pemilih baru tertahan di band BARU ~7 jam lebih lama dari seharusnya (B24). Migrasi data existing: UPDATE `voter_first_seen`/`voters` kurangi 7 jam (WIB→UTC), backup tabel `*_bak_wib` di prod. Oracle test_get_voters V62: stempel ≈ now-UTC-naive (selisih <2 mnt) DAN ≠ now−7h (guard anti-WIB) | V62,V52,V40,V41
V63: jeda antar kirim distribusi: send-loop `run_distribution` tidur `DISTRIBUTE_SEND_DELAY` (int, default 30) detik ANTAR pemilih — setelah pemilih ke-i selesai diproses (sent/hold/failed), sebelum pemilih i+1; N pemilih → N−1 tidur, ⊖ tidur setelah pemilih TERAKHIR; real-run saja (dry-run ⊖ tidur — tak ada kirim). Tujuan: beri node/Hyperion waktu mengejar indeks + meratakan beban broadcast run panjang; jeda seragam terlepas hasil pemilih sebelumnya (pacing deterministik utk oracle). Oracle test_distribute V63: 3 pemilih → sleep dipanggil 2× dgn nilai `DISTRIBUTE_SEND_DELAY` | V63,V21,V22
V64: banner BERITA hermetik utk oracle (B25): route index menghitung jam via helper modul `_now()` (isi = `datetime.now()` lokal, ⊖ inline di route) & mengoper ke `_next_schedule(now)`/`_recurrence_label(now)` — test mock `dashboard._now` ke tanggal TETAP (sebelum/sesudah launch) sehingga asersi baris-berulang ⊖ bergantung jam dinding nyata; tanpa ini, setelah tanggal first-run baked (2026-08-17) LEWAT di kalender, `_is_first_run_next(real_now)` ⊖ pernah True (`start >= now` gagal) → baris berulang tampil → asersi "TAK tampil saat first-run" gagal walau `_next_schedule` sudah di-mock (B25; test lulus hanya kalau kalender < tanggal launch). Oracle test_dashboard V38: mock `_now` sebelum launch → baris hilang; sesudah launch → baris muncul | V64,V38,V44
V65: semua stempel data (audit trail, pembayaran, klaim) UTC-naive konsisten — `distribute.py` `created_at`, `db.py` `updated_at` (update_payment_status), `db.py` `now` (update_claim_fee) memakai `datetime.now(timezone.utc).replace(tzinfo=None)` — sama basis dengan `get_voters.persist` (V62), `eligible_voters` (V57), semua cutoff umur dashboard; ⊖ `datetime.now()` lokal yang menghasilkan WIB (UTC+7) dan merusak perbandingan string UTC. Oracle: test_distribute + test_claim tetap hijau | V65,V62,V57,V40,V52
V66: notifikasi VOTE ULANG SEGERA via Telegram + emoticon pada SEMUA notifikasi: `get_voters._notify_warn(now)` setelah `persist()` — query pemilih dalam jendela `now−STALE < last_vote ≤ now−(STALE−WARN)` dari tabel `voters`, hitung urgensi dari sisa hari (≤1d=🚨/3, ≤2d=🔴/2, >2d=⚠️/1), filter yg sdh diberi tahu pada level ≥ urgensi saat ini (tabel `voter_notify`, state-change-only → max 3 notif/pemilih per jendela), kirim via `telegram.notify_warn` ke KOMUNITAS (daftar max 50 + link per-akun `mining.databisnis.id?q={owner}`), catat ke `voter_notify`, bersihkan baris stale. Tabel `voter_notify` (owner PK, urgency INT, notified_at TEXT) — `ensure_notify_schema`/`get_notify_urgency`/`record_notify`/`cleanup_notify` di `db.py`. `Dockerfile.scan` salin `telegram.py`. SEMUA notifikasi Telegram (`notify_start`/`notify_finish`/`notify_failure`/`notify_claim`/`notify_claim_unmeasured`/`notify_claim_failure`) kini punya emoticon prefix (🚀/✅/⚠️/🔴/💰/🔍). Oracle test_get_voters V66: voter_notify schema + record/get/cleanup + _notify_warn integrasi (mock telegram) + skip redundancy + escalation trigger | V66,V62,V40 V66a: kirim dikunci ke pengiriman NYATA — `send_text` return bool (True bila SEMUA chat terkirim), `notify_warn` return bool (True bila semua chunk terkirim), `_notify_warn` catat `voter_notify` HANYA bila `notify_warn` True — gagal/no-op (token kosong, Telegram down) ⊖ pernah mencatat → dedup palsu (B27). `docker-compose.yml` service `scan` bind NFS `config.env:/app/.env:ro` (token+chat id) — sebelum ini scan container ⊖ punya TELEGRAM_* → notif senyap (B27)
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 ## §T — Tasks
id|status|task|cites id|status|task|cites
@@ -67,7 +157,74 @@ T13|x|produksi web: gunicorn (gunicorn.conf.py, DASH_WORKERS) + WAL di persist|V
T14|x|stale-vote: last_vote di scan (rasio bobot/stake); basi/unverifiable dibuang di normalize (⊥ disimpan)|V13,I.db 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 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 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
T55|x|stempel scan UTC-naive V62 (B24): `get_voters.persist` ganti `datetime.now()` → `datetime.now(timezone.utc).replace(tzinfo=None)` utk `scanned_at`/`first_seen_at` (konsisten dgn semua cutoff umur UTC + `last_vote`); migrasi data existing DB prod WIB→UTC (−7 jam, backup `*_bak_wib`); docs §V/V62 + §B/B24 + T55 + AGENTS; oracle test_get_voters V62 (stempel ≈ now-UTC-naive, ⊖ WIB)|V62,V52,V41,V7
T56|x|jeda antar kirim V63: `config.py` += `DISTRIBUTE_SEND_DELAY` (int, default 30); `distribute.py` send-loop `enumerate(pending)` + `time.sleep(DISTRIBUTE_SEND_DELAY)` ANTAR pemilih (N pemilih → N−1 tidur, ⊖ setelah terakhir; dry-run ⊖); env compose distribute += `DISTRIBUTE_SEND_DELAY` + `.env.example` + docs §V/V63 + §I/env + T56 + AGENTS; oracle test_distribute V63 (3 pemilih → 2 sleep @ nilai config)|V63,V21,V22,I.env
T57|x|banner hermetik utk oracle V64 (B25): `dashboard.py` route index `now = _now()` (helper modul, ⊖ `datetime.now()` inline) utk `_next_schedule`/`_recurrence_label`; `test_dashboard.py` V38 mock `dashboard._now` ke tanggal tetap sebelum/sesudah launch; docs §V/V64 + §B/B25 + T57 + AGENTS; oracle test_dashboard V38|V64,V38,V44
## §B — Bug log ## §B — Bug log
id|date|cause|fix id|date|cause|fix
B1|2026-08-04|node balikin `staked` sbg string utk sebagian baris → TypeError int÷int|V8 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
B24|2026-08-17|`first_seen_at`/`scanned_at` tersimpan WIB (UTC+7) tapi dibandingkan sbg string UTC di SEMUA cutoff umur (dashboard + `db.eligible_voters` + distribusi): `get_voters.persist` memakai `datetime.now()` (waktu lokal container `TZ=Asia/Jakarta`) sedangkan cutoff pakai `datetime.now(timezone.utc)` — offset +7 jam membuat pemilih BARU tampak belum matang ~7 jam lebih lama (kasus nyata: idrsaku33351/m4dhanchocil/danuprasetya first_seen 13 Agt 17:39 WIB, seharusnya matang 16 Agt 17:39 WIB, kode melepas dari BARU baru 17 Agt 00:39 WIB)|`persist` stempel UTC-naive (`datetime.now(timezone.utc).replace(tzinfo=None)`); migrasi data existing WIB→UTC (−7 jam) dgn backup `*_bak_wib` (V62); oracle V62 guard stempel ≈ now-UTC ⊖ WIB
B25|2026-08-19|oracle V38 HTML-render bergantung jam dinding nyata: route index `_now = datetime.now()` inline → `_recurrence_label(real_now)` → `_is_first_run_next` cek `start >= now` — setelah tanggal first-run baked (2026-08-17) LEWAT di kalender nyata, `start >= now` ⊖ pernah True → baris berulang `SETIAP RABU & SABTU` tampil → asersi "baris berulang TAK tampil saat first-run" GAGAL walau `_next_schedule` sudah di-mock ke 2026-08-17 (test lulus hanya kalau kalender nyata < tanggal launch)|route pakai helper modul `_now()` injectable + oracle mock `dashboard._now` ke tanggal tetap sebelum/sesudah launch (V64)
B26|2026-08-27|tidak ada notifikasi Telegram untuk pemilih yang masuk jendela VOTE ULANG SEGERA — pemilih hanya terlihat di dashboard dgn badge amber, ⊥ alert proaktif ke komunitas → vote kedaluwarsa tanpa peringatan|`get_voters._notify_warn` + `telegram.notify_warn` + tabel `voter_notify` utk state-change dedup + emoticon pada SEMUA notifikasi (V66)
B27|2026-09-02|notifikasi VOTE ULANG SEGERA ⊖ pernah sampai ke kanal komunitas walau dashboard tampil badge: (1) service `scan` di `docker-compose.yml` ⊖ bind NFS `config.env` (hanya distribute/claim) → container scan `TELEGRAM_BOT_TOKEN`/`TELEGRAM_COMMUNITY_CHAT_IDS` kosong → `send_text` no-op senyap (2) walau no-op, `_notify_warn` TETAP `record_notify` → ledgers `voter_notify` terisi dedup palsu urgent 1→2→3 tiap scan → setelah token dibenahi, dedup `prev >= urgency` memblokir kirim (test manual `_notify_warn` senyap)|(1) compose bind `config.env:/app/.env:ro` utk scan (2) V66a: `send_text`/`notify_warn` return bool pengiriman nyata; `_notify_warn` catat `voter_notify` HANYA bila `True` (V66a); oracle V66 kirim-gagal → ⊖ catat
+398
View File
@@ -0,0 +1,398 @@
"""Klaim reward BP + transfer fee harian: `vexcore::claimrewards` utk TARGET_BP.
Setiap siklus (V32..V34): tentukan jendela klaim = `last_claim_time` (tabel
`producers`) + 24 jam (math UTC-naive via `_utcnow`, konsisten dgn stempel
chain; V49/B13), tidur sampai mendekatinya, lalu poll `claimrewards`
tiap `CLAIM_RETRY_SECONDS` sampai diterima chain. Jendela yang sudah lewat
(≥24 jam sejak last) → klaim dikejar segera (recovery, ⊥ spin-timeout
selamanya). Landing dikonfirmasi via Hyperion `executed` ATAU `last_claim_time`
maju dari baseline — `send()` pyntelope TIDAK raise saat chain menolak (HTTP
500 dikembalikan sbg body), jadi ⊥ pernah percaya send() semata (V46/B11).
Reward diukur PRIORITAS dari isi tx klaim via Hyperion (jumlah transfer
`vex.bpay`/`vex.vpay` → TARGET_BP; V45) — ⊥ selisih saldo yang flaky/stale;
selisih saldo jadi fallback; keduanya gagal → fee `pending` + notif cek
manual. Fee = floor(`VEX_BP_FEE_PERCENT` × reward) 4 desimal →
`VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD` (V33).
Siklus selesai ⊥ bila klaim tercatat di `claim_runs` DAN fee terkirim; fee
`pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (crash-safe,
V34). ⊥ mutex dgn distribute.py — distribute beku saldo di awal run (V20),
klaim yang jatuh di tengah run didefer ke run berikutnya.
Modul logika (⊥ CLI) — `claim_loop.py` yang menjalankan loop tak-berhenti.
"""
import time
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
import db
import telegram
import distribute as dist
from config import (API_NODE, BP_FEE_PERCENT, BP_FEE_WALLET, BP_PRIVATE_KEY,
CLAIM_RETRY_SECONDS, TARGET_BP, TOKEN_CONTRACT)
VEX_PREC = Decimal('0.0001')
SETTLE_SECONDS = 30 # tunggu finalitas sebelum baca saldo setelah klaim
POLL_SPAN_SECONDS = 7200 # batas poll sekali jendela (2 jam)
def _utcnow():
"""UTC-naive `datetime.now()` — konsisten dgn `last_claim_time` chain
(Vexanium mengembalikan UTC) & `get_voters`/`dashboard` (V49).
Memakai waktu lokal (`datetime.now()`, TZ container Asia/Jakarta) untuk
math jendela 24 jam membuat jendela maju ~7 jam lebih awal → poll ditolak
chain → retry tanpa henti (B13)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def _post_json(url, payload, max_retries=3, timeout=30):
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
for attempt in range(max_retries):
try:
resp = dist.requests.post(url, json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
except Exception:
if attempt == max_retries - 1:
raise
print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... '
f'({attempt + 1}/{max_retries})', flush=True)
time.sleep(2)
def fetch_producer_last_claim():
"""`last_claim_time` baris producers utk TARGET_BP dari chain (| None).
`get_table_rows` mengembalikan dict `{"rows":[...]}` (bukan list) — parse
bentuk asli; bentuk tak dikenal → None (⊥ crash → siklus tak mati)."""
data = _post_json(f'{API_NODE}/v1/chain/get_table_rows',
{'json': True, 'code': 'vexcore', 'scope': 'vexcore',
'table': 'producers', 'limit': 1,
'lower_bound': TARGET_BP, 'upper_bound': TARGET_BP})
rows = (data or {}).get('rows') or []
return (rows[0] or {}).get('last_claim_time') if rows else None
def parse_claim_time(raw):
"""ISO `last_claim_time` chain → datetime; format tak dikenal → None."""
if not raw:
return None
for fmt in ('%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S'):
try:
return datetime.strptime(raw, fmt)
except ValueError:
continue
return None
def next_window(now):
"""Jendela klaim berikutnya = `last_claim_time` + 24 jam; ⊥ last → now.
Recovery: bila ≥24 jam sudah lewat sejak klaim terakhir, jendela = `now`
(klaim dibolehkan) — jendela yang terlewat (container down / poll habis)
⊥ jadi spin-timeout selamanya; klaim dikejar segera, lalu jendela dihitung
ulang dari last_claim_time yang baru.
Pembacaan `last_claim_time` node flaky di-retry: bila SEMUA pembacaan
gagal/kosong → raise (loop catch-and-sleep, ⊥ clamp ke now prematur yang
memicu percobaan klaim sebelum jendela → ditolak chain). `now` hanya utk
pembacaan yang BENAR-BENAR berhasil tanpa klaim sebelumnya (BP baru).
`now` & `last_claim_time` sama-sama UTC-naive (V49/B13)."""
raw = None
for _ in range(3):
try:
raw = fetch_producer_last_claim()
if raw:
break
except Exception:
raw = None
time.sleep(2)
if not raw:
raise RuntimeError('tak bisa membaca last_claim_time producer')
last = parse_claim_time(raw)
if last is None:
return now
window = last + timedelta(hours=24)
return window if now < window else now
def build_claim_action():
"""Bangun + tanda tangani aksi `vexcore::claimrewards` via pyntelope.
Kembalikan objek SignedTransaction (⊥ broadcast). ABI + TAPOS dari node
saat `link()` — pola `distribute.build_signed_transfer`.
"""
from pyntelope import Action, Authorization, Data, Net, Transaction, types
trx = Transaction(actions=[
Action(
account='vexcore',
name='claimrewards',
authorization=[Authorization(actor=TARGET_BP, permission='active')],
data=[Data(name='owner', value=types.Name(TARGET_BP))],
)
])
linked = trx.link(net=Net(host=API_NODE))
return linked.sign(key=BP_PRIVATE_KEY)
def _claim_time_advanced(before_raw, after_raw):
"""True bila `last_claim_time` benar-benar MAJU dari nilai sebelum kirim.
Heuristik pengganti "dalam 24 jam terakhir" (yang memicu false-success:
klaim lama yang masih <24 jam membuat percobaan yang ditolak dianggap
mendarat). Hanya pergerakan nyata (`after > before`) yang membuktikan tx
diterima chain; baseline tak terbaca → ⊥ dianggap maju (aman, retry)."""
before = parse_claim_time(before_raw)
after = parse_claim_time(after_raw)
return before is not None and after is not None and after > before
def reward_from_tx(txid):
"""Reward BP dari isi tx klaim via Hyperion: jumlah transfer `vex.bpay`/
`vex.vpay` → TARGET_BP. → Decimal | None (Hyperion tak terjangkau / tx tak
ada / bentuk tak dikenal). Prioritas pengukuran (V45) — ⊥ selisih saldo
yang flaky/stale. Tx yang tak `executed` → None. V60: jalan lewat
`dist._get_json` — failover pool `HYPERION_NODES` (bukan satu host)."""
if not txid:
return None
data = dist._get_json('/v2/history/get_transaction', params={'id': txid})
if not isinstance(data, dict): # V61: None/daftar/bentuk tak dikenal
return None
if not data.get('executed'):
return None
total = Decimal('0')
for act in (data.get('actions') or []):
a = act.get('act') or {}
if a.get('account') != TOKEN_CONTRACT or a.get('name') != 'transfer':
continue
d = a.get('data') or {}
if (d.get('to') == TARGET_BP
and d.get('from') in ('vex.bpay', 'vex.vpay')):
try:
total += Decimal(str(d.get('quantity', '')).split()[0])
except (ValueError, IndexError, InvalidOperation):
continue
return total
def _measure_reward(txid, before):
"""Reward klaim: prioritas isi tx (V45) → fallback selisih saldo (V55).
Return `Decimal` | None bila keduanya gagal (reward tak terukur).
V55/B17: `reward_from_tx` di-RETRY dgn jeda settle — tx klaim baru mendarat
bisa belum terindeks Hyperion sesaat (None palsu) → beri waktu mengejar.
Fallback selisih saldo HANYA mengakui delta POSITIF (after > before) sbg
terukur; delta 0 ambigu (node balik saldo lama / saldo belum ter-update) →
tetap None → jalur tak-terukur (fee pending + alert), ⊖ sukses-0 palsu.
Reward nol asli (tx executed tanpa bpay/vpay) tetap terbaca via isi tx."""
for _ in range(4):
reward = reward_from_tx(txid)
if reward is not None:
return reward
time.sleep(SETTLE_SECONDS)
for _ in range(3):
time.sleep(SETTLE_SECONDS)
after = dist.fetch_balance()
if after is not None and after > before:
return max(after - before, Decimal('0'))
return None
def _confirm_landed(txid, last_before):
"""Konfirmasi tx klaim benar-benar mendarat. → True | False (⊥ None).
`send()` pyntelope TIDAK raise saat chain menolak (HTTP 500 dikembalikan
sbg body) — jadi "sukses kirim" ⊥ cukup (V46/B11). Mendarat hanya bila
Hyperion `executed` ATAU `last_claim_time` MAJU dari baseline sebelum kirim.
Hyperion `False` (bukan hanya None) ⊥ langsung dianggap tak mendarat:
Hyperion bisa merespons `executed: false` utk tx yang BARU SAJA mendarat
tapi belum terindeks (lag) — padahal klaim sukses. Jadi utk verified False
maupun None, tetap cek `_claim_time_advanced`; hanya bila itu pun False →
retry senyap (⊥ catat baris / sukses-0). Klaim yang ditolak ⊥ memajukan
`last_claim_time` → tetap retry (V50/B14).
V54/B16: baca `last_claim_time` sesudah kirim di-RETRY beberapa kali dgn
jeda singkat — node RPC bisa terlambat mencerminkan `last_claim_time` yang
baru (lag sesaat); baca stale sekali ⊥ langsung dianggap tak mendarat."""
verified = dist.verify_txid(txid)
if verified is True:
return True
last_after = None
for _ in range(3):
try:
last_after = fetch_producer_last_claim()
except Exception:
last_after = None
if last_after is not None:
break
time.sleep(2)
return _claim_time_advanced(last_before, last_after)
def _try_claim_once(last_before=None, before_balance=None):
"""Satu percobaan klaim: klaim → konfirmasi mendarat → ukur reward →
catat `claim_runs`.
Return `claimed` (baris tercatat, fee pending/skipped) | `retry`.
Landing dikonfirmasi di KEDUA jalur (send sukses maupun exception) — `send()`
pyntelope ⊥ raise saat chain menolak (V46/B11); tak terkonfirmasi → retry
senyap. Reward diukur dari isi tx (transfer bpay/vpay) — prioritas (V45);
selisih saldo fallback; keduanya gagal → fee `pending` + notif cek manual
(⊥ sukses-0 yang menyesatkan).
V54/B16: `last_before` (baseline `last_claim_time` sebelum kirim) dipakai
TETAP dari awal window (`poll_claim` menghitung sekali) — retry berikutnya
membandingkan baseline ORISINIL thd `last_after` yang sudah maju, jadi klaim
yang mendarat tapi baca-pertama-nya basi tetap terdeteksi (⊥ baseline ikut
maju tiap attempt → retry-loop permanen). Bila None (pemanggil langsung),
baca sendiri sbg fallback.
V56/B18: `before_balance` (saldo liquid SEBELUM ada attempt) juga dibawa
TETAP dari awal window — klaim yang mendarat di attempt 1 lalu attempt 2
ditolak (already claimed) membuat `before` baca-ulang per attempt SUDAH
termasuk reward → delta 0 → reward tak terukur → alert palsu (kasus 10 Agt:
tx bfa9ab02 mendarat, app ukur tx ditolak b0a8b2f9 + before post-credit).
Baseline stabil → delta fallback mengukur reward dengan benar. Bila None
(pemanggil langsung), baca sendiri sbg fallback.
"""
before = (before_balance if before_balance is not None
else dist.fetch_balance())
if before is None:
return 'retry'
if last_before is None:
try:
last_before = fetch_producer_last_claim()
except Exception:
last_before = None
txid = None
landed = False
try:
signed = build_claim_action()
txid = signed.id()
signed.send()
landed = _confirm_landed(txid, last_before)
except Exception as exc:
if txid is not None:
landed = _confirm_landed(txid, last_before)
if not landed:
print(f'[Peringatan] klaim gagal: {exc}', flush=True)
return 'retry'
if not landed:
print('[Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat '
'— retry (⊥ catat sukses)', flush=True)
return 'retry'
reward = _measure_reward(txid, before)
now = _utcnow()
now_iso = now.isoformat(timespec='seconds')
txid_label = txid[:16] + '...' if txid else '?'
if reward is None:
# klaim mendarat tapi reward tak terukur → fee pending utk cek manual
db.record_claim(now.strftime('%Y-%m-%d'), txid, 0.0, 0.0, 'pending',
now_iso)
print(f'[Peringatan] klaim mendarat (txid {txid_label}) tapi reward '
f'tak terukur — fee pending utk cek manual', flush=True)
telegram.notify_claim_unmeasured(txid_label)
return 'claimed'
fee = (reward * BP_FEE_PERCENT).quantize(VEX_PREC, rounding=ROUND_FLOOR)
fee_status = 'pending' if fee > 0 else 'skipped'
db.record_claim(now.strftime('%Y-%m-%d'), txid, float(reward), float(fee),
fee_status, now_iso)
print(f'[OK] klaim reward {reward} VEX (txid {txid_label}) | '
f'fee {fee} VEX → {BP_FEE_WALLET}', flush=True)
telegram.notify_claim(reward, fee, BP_FEE_WALLET)
return 'claimed'
def poll_claim(deadline):
"""Poll `claimrewards` tiap `CLAIM_RETRY_SECONDS` sampai sukses / deadline.
Return `claimed` | `timeout` (deadline lewat — jadwal dihitung ulang).
V54/B16: baseline `last_claim_time` dihitung SEKALI di awal window dan
dipakai utk SEMUA attempt — klaim yang mendarat tapi baca-pertama-nya basi
terdeteksi di attempt berikutnya (baseline orisinil vs `last_after` maju),
⊥ baseline ikut maju tiap attempt → retry-loop permanen.
V56/B18: saldo liquid sebelum klaim (`before_balance`) JUGA dibaca SEKALI
di awal window dan dipakai utk SEMUA attempt — kalau klaim mendarat di
attempt 1, attempt 2 yang ditolak (already claimed) memakai baseline yang
BELUM termasuk reward → delta fallback mengukur reward benar (⊥ before
baca-ulang per attempt yang sudah post-credit → delta 0 → alert palsu).
"""
try:
last_before = fetch_producer_last_claim()
except Exception:
last_before = None
try:
before_balance = dist.fetch_balance()
except Exception:
before_balance = None
while _utcnow() < deadline:
status = _try_claim_once(last_before, before_balance)
if status == 'claimed':
return 'claimed'
time.sleep(CLAIM_RETRY_SECONDS)
print(f'[Peringatan] jendela klaim habis tanpa sukses '
f'({deadline.isoformat(timespec="seconds")})', flush=True)
return 'timeout'
def _send_fee(claim):
"""Kirim fee satu baris `claim_runs` (retry-until-sent, verifikasi V22).
`claim` = baris dari `db.pending_claim_fee()`. Return True bila terkirim
atau fee 0 (skipped); False → diulang siklus berikutnya.
"""
claim_id, run_date = claim[0], claim[1]
fee = Decimal(str(claim[4]))
if fee <= 0:
db.update_claim_fee(claim_id, 'skipped')
return True
first_fail = claim[5] != 'failed' # notif gagal ⊥ sekali per klaim
memo = f'BP FEE {run_date}'
txid = None
try:
signed = dist.build_signed_transfer(BP_FEE_WALLET, fee, memo)
txid = signed.id()
resp = signed.send()
# V61: pyntelope ⊥ raise pada HTTP 500 — penolakan chain (duplicate /
# insufficient-balance) datang sbg body JSON (pola V46/B11). Deteksi →
# perlakukan sbg gagal, ⊖ tandai 'sent' utk fee yang ⊥ pernah mendarat.
if dist._send_rejected(resp):
reason = (resp.get('error', {}).get('what')
if isinstance(resp, dict) else resp)
raise RuntimeError(f'chain menolak fee: {reason}')
except Exception as exc:
if txid is not None and dist.verify_txid(txid) is True:
pass # mendarat walau timeout — lanjut tandai sent
else:
db.update_claim_fee(claim_id, 'failed', fee_txid=txid)
print(f'[GAGAL] fee {fee} VEX → {BP_FEE_WALLET}: {exc}', flush=True)
if first_fail:
telegram.notify_claim_failure(exc)
return False
db.update_claim_fee(claim_id, 'sent', fee_txid=txid)
print(f'[OK] fee {fee} VEX → {BP_FEE_WALLET} ({txid[:16]}...)', flush=True)
return True
def step():
"""Satu iterasi loop klaim. Return aksi utk uji: `fee_sent` | `fee_failed`
| `sleep` | `claimed` | `timeout`."""
claim = db.pending_claim_fee()
if claim is not None:
return 'fee_sent' if _send_fee(claim) else 'fee_failed'
now = _utcnow()
window = next_window(now)
margin = timedelta(seconds=CLAIM_RETRY_SECONDS)
if now < window - margin:
wait = (window - margin - now).total_seconds()
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
f'sampai {window.isoformat(timespec="seconds")}', flush=True)
time.sleep(max(wait, 0))
return 'sleep'
deadline = window + timedelta(seconds=POLL_SPAN_SECONDS)
return poll_claim(deadline)
+50
View File
@@ -0,0 +1,50 @@
"""Jadwal klaim reward BP harian: jalankan `claim.step()` terus-menerus.
Menunggu hingga jendela klaim (dihitung `claim.py` dari `last_claim_time` +
24 jam), lalu `step()` menangani resume fee → jadwal → poll `claimrewards`
tiap `CLAIM_RETRY_SECONDS`. Loop tak pernah keluar — service dijaga hidup oleh
restart_policy swarm; restart aman karena fee yang belum terkirim dilanjutkan
dari `claim_runs` (V34). `TZ` env menentukan waktu lokal via `datetime.now()`
(libc); image Dockerfile.claim sudah menyertakan tzdata.
Berbeda dgn scan: ⊥ ada klaim-awal saat start — klaim dgn key salah / jendela
belum buka hanya dicatat lalu dicoba lagi (⊥ menabrak rule 24 jam).
Scheduler helper (`_wait_db`) sengaja diduplikasi dari `scan_loop.py` (bukan
di-import) karena image Docker terpisah — `Dockerfile.claim` membawa
`config.py db.py telegram.py distribute.py claim.py claim_loop.py` saja.
"""
import time
import db
import claim
def _wait_db(timeout=120):
"""Tunggu basis data siap (mariadb baru boot) sebelum loop."""
start = time.monotonic()
while time.monotonic() - start < timeout:
try:
db.queryone('SELECT 1')
return
except Exception:
time.sleep(3)
raise RuntimeError('basis data tak siap setelah menunggu')
def main():
from config import CLAIM_RETRY_SECONDS
print(f'Jadwal klaim reward BP: poll tiap {CLAIM_RETRY_SECONDS}s '
f'di jendela klaim (last_claim_time + 24 jam)', flush=True)
_wait_db()
while True:
try:
claim.step()
except Exception as exc:
print(f'[Error] Siklus klaim gagal: {exc}', flush=True)
time.sleep(CLAIM_RETRY_SECONDS)
if __name__ == '__main__':
main()
+112 -3
View File
@@ -5,6 +5,7 @@ di sini supaya semua modul (get_voters, dashboard) berbagi sumber yang sama.
""" """
import os import os
from decimal import Decimal
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -16,9 +17,30 @@ TARGET_BP = os.getenv('VEX_TARGET_BP', 'databisnisid')
# Node RPC publik Vexanium # Node RPC publik Vexanium
API_NODE = os.getenv('VEX_API_NODE', 'https://v2.vexascan.com:2096') API_NODE = os.getenv('VEX_API_NODE', 'https://v2.vexascan.com:2096')
# Node pool RPC (V58): CSV `VEX_API_NODES` — node utama + cadangan utk failover
# distribusi (fetch saldo, ABI/TAPOS, broadcast). Default = `API_NODE` +
# `https://api.databisnis.id` (node mainnet Vexanium terverifikasi, host sama
# dgn HYPERION_API tapi melayani juga /v1/chain). Dipakai `distribute.py`;
# scan/klaim tetap memakai `API_NODE` tunggal.
API_NODES = [
n.strip() for n in os.getenv(
'VEX_API_NODES', f'{API_NODE},https://api.databisnis.id').split(',')
if n.strip()
]
# Lokasi basis data SQLite (dipakai fetcher & dashboard) # Lokasi basis data SQLite (dipakai fetcher & dashboard)
DB_PATH = os.getenv('VEX_DB_PATH', 'voters.db') DB_PATH = os.getenv('VEX_DB_PATH', 'voters.db')
# Backend basis data: 'sqlite' (default) atau 'mysql' (MariaDB/MySQL via PyMySQL)
DB_BACKEND = os.getenv('VEX_DB_BACKEND', 'sqlite')
# Koneksi MariaDB/MySQL (dipakai saat DB_BACKEND='mysql')
DB_HOST = os.getenv('VEX_DB_HOST', '127.0.0.1')
DB_PORT = int(os.getenv('VEX_DB_PORT', '3306'))
DB_USER = os.getenv('VEX_DB_USER', '')
DB_PASS = os.getenv('VEX_DB_PASS', '')
DB_NAME = os.getenv('VEX_DB_NAME', '')
# Syarat minimum stake (dalam VEX) untuk disimpan # Syarat minimum stake (dalam VEX) untuk disimpan
MIN_STAKED_VEX = float(os.getenv('VEX_MIN_STAKED_VEX', '1000')) MIN_STAKED_VEX = float(os.getenv('VEX_MIN_STAKED_VEX', '1000'))
@@ -32,11 +54,98 @@ DASH_PORT = int(os.getenv('DASH_PORT', '5000'))
# Dashboard: jumlah worker gunicorn (produksi) # Dashboard: jumlah worker gunicorn (produksi)
DASH_WORKERS = int(os.getenv('DASH_WORKERS', '2')) DASH_WORKERS = int(os.getenv('DASH_WORKERS', '2'))
# Ambang batas vote "basi" (hari tanpa revote) — pemilih lewat batas ini dibuang di scan # Ambang batas vote "basi" (hari tanpa revote). Sejak V40 pemilih lewat batas
# ini TETAP disimpan di scan (basi dibuang hanya saat tampil/bayar): band
# kadaluarsa 28..31 hari tetap tampil (sorot VOTE ULANG), >31 hari disembunyikan.
VEX_STALE_DAYS = int(os.getenv('VEX_STALE_DAYS', '28')) VEX_STALE_DAYS = int(os.getenv('VEX_STALE_DAYS', '28'))
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard) # Masa matang (hari): pemilih baru (revote < VEX_MATURITY_DAYS lalu) BELUM
DATABISNIS_API = os.getenv('DATABISNIS_API', 'https://api.databisnis.id') # berhak reward (⊥ dibayar distribusi) hingga votenya berumur ≥ ambang ini.
VEX_MATURITY_DAYS = int(os.getenv('VEX_MATURITY_DAYS', '3'))
# Jendela "kadaluarsa" (hari SETELAH VEX_STALE_DAYS) yang masih tampil di
# dashboard (band sorot VOTE ULANG). Lewat 28+3 = 31 hari → disembunyikan.
VEX_EXPIRED_DAYS = int(os.getenv('VEX_EXPIRED_DAYS', '3'))
# Tag REVOTE (mint) bertahan (hari) sejak revote terakhir: hanya pemilih lama
# yang revote dalam `VEX_REVOTE_TAG_DAYS` terakhir diberi tag; setelah itu
# tampil sbg VALID biasa (tetap dibayar langsung — V42, ⊥ tunggu matang).
VEX_REVOTE_TAG_DAYS = int(os.getenv('VEX_REVOTE_TAG_DAYS', '1'))
# Jendela peringatan (hari SEBELUM VEX_STALE_DAYS): pemilih VALID yang
# `now − STALE < last_vote ≤ now − (STALE − WARN)` diberi badge `VOTE ULANG
# SEGERA` (segera kadaluarsa, masih dibayar).
VEX_WARN_DAYS = int(os.getenv('VEX_WARN_DAYS', '3'))
# Hyperion API untuk saldo liquid akun + verifikasi txid (balance BP,
# disajikan dashboard; verifikasi distribusi/klaim). `HYPERION_API` nama baru
# (V-rename); `DATABISNIS_API` masih dibaca utk backward-compat deploy lama.
HYPERION_API = (os.getenv('HYPERION_API')
or os.getenv('DATABISNIS_API')
or 'https://api.databisnis.id')
# Node pool Hyperion (V60): CSV `VEX_HYPERION_NODES` — host yang melayani
# `/v2/*` utk failover GET Hyperion (verifikasi txid, reward klaim, saldo
# liquid dashboard). Default = `HYPERION_API` (utama) + semua `API_NODES`
# (keduanya mainnet terverifikasi layani /v2, probe 2026-08-13), dedup,
# utama di depan. Dipakai `distribute._get_json` + dashboard `_get_liquid_vex`.
HYPERION_NODES = [
n.strip() for n in os.getenv(
'VEX_HYPERION_NODES',
','.join([HYPERION_API] + [n for n in API_NODES if n != HYPERION_API]),
).split(',') if n.strip()
]
# Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown # Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown
DASH_LIQUID_TTL = int(os.getenv('DASH_LIQUID_TTL', '60')) DASH_LIQUID_TTL = int(os.getenv('DASH_LIQUID_TTL', '60'))
# ————————————————— Distribusi profit-share (V19..V27) —————————————————
# Jam (lokal, TZ) run harian distribusi; key aktif BP via swarm secret/env.
# ⊥ env-able: konstanta chain Vexanium (token + chain_id untuk signing).
BP_PRIVATE_KEY = os.getenv('VEX_BP_PRIVATE_KEY', '')
DISTRIBUTE_HOUR = int(os.getenv('DISTRIBUTE_HOUR', '10'))
DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3'))
# Jeda (detik) ANTAR kirim transfer antar pemilih (V63): send-loop distribusi
# tidur sebelum lanjut ke pemilih berikutnya — beri node/Hyperion waktu
# mengejar indeks + meratakan beban broadcast run panjang. Default 30.
DISTRIBUTE_SEND_DELAY = int(os.getenv('DISTRIBUTE_SEND_DELAY', '30'))
# Kill-switch distribusi (V35): default OFF — payout hanya jalan saat diset
# `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis).
DISTRIBUTE_ENABLED = os.getenv(
'DISTRIBUTE_ENABLED', 'false').lower() in ('1', 'true', 'yes')
# Jadwal distribusi (V37/V44): hari `DISTRIBUTE_WEEKDAY` — satu nama (`sat`) atau
# CSV beberapa hari (`wed,sat`), jam `DISTRIBUTE_HOUR`; plus satu one-off
# `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`). Dipakai distribute_loop (via config,
# konsisten dgn dashboard V38). Default baked = launch pertama 2026-08-17
# (Senin) 10:00 lalu tiap Rabu & Sabtu; env tetap override. Setelah tanggal
# lewat → otomatis hanya mingguan (batas skip event masa lalu).
DISTRIBUTE_WEEKDAY = os.getenv('DISTRIBUTE_WEEKDAY', 'wed,sat')
DISTRIBUTE_FIRST_RUN = os.getenv('DISTRIBUTE_FIRST_RUN', '2026-08-17')
# ————————————————— Notifikasi Telegram (V30/V36) —————————————————
# Bot token tunggal; dua daftar chat id (CSV): KOMUNITAS (`..._COMMUNITY_...`)
# hanya brief mulai/selesai distribusi, INTERNAL (`..._INTERNAL_...`) detail
# distribusi + semua kegagalan + klaim reward (⊥ komunitas). Kosong → kanal
# itu senyap.
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_COMMUNITY_CHAT_IDS = [
c.strip() for c in os.getenv('TELEGRAM_COMMUNITY_CHAT_IDS', '').split(',')
if c.strip()
]
TELEGRAM_INTERNAL_CHAT_IDS = [
c.strip() for c in os.getenv('TELEGRAM_INTERNAL_CHAT_IDS', '').split(',')
if c.strip()
]
# ————————————————— Klaim reward BP (V32..V34) —————————————————
# Poll interval (detik) saat mendekati jendela klaim; fee wallet + persentase
# 10% dari reward yang baru diklaim (floor 4 desimal).
CLAIM_RETRY_SECONDS = int(os.getenv('CLAIM_RETRY_SECONDS', '60'))
BP_FEE_WALLET = os.getenv('VEX_BP_FEE_WALLET', 'bpdbsjasprod')
BP_FEE_PERCENT = Decimal(os.getenv('VEX_BP_FEE_PERCENT', '0.10'))
# Kontrak token VEX = vex.token (⊥ eosio.token — tak ada di chain)
TOKEN_CONTRACT = 'vex.token'
VEX_SYMBOL = 'VEX'
VEX_PRECISION = 4
VEX_CHAIN_ID = 'f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f'
+515 -79
View File
@@ -6,21 +6,56 @@ API databisnis dengan cache TTL singkat (V16). Styling per `DESIGN.md`.
Konfigurasi dari env/`.env`. Konfigurasi dari env/`.env`.
""" """
import sqlite3 import math
import time import time
from datetime import datetime from datetime import datetime, timedelta, timezone
import requests import requests
from flask import Flask, abort, jsonify, render_template, request from flask import Flask, abort, jsonify, render_template, request
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API, import db
DB_PATH, PAGE_SIZE, TARGET_BP) from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, HYPERION_NODES,
DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY,
PAGE_SIZE, TARGET_BP, VEX_EXPIRED_DAYS, VEX_MATURITY_DAYS,
VEX_REVOTE_TAG_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS)
SEARCH_CAP = 500 SEARCH_CAP = 500
MAX_Q_LEN = 64 MAX_Q_LEN = 64
# Tabel distribute_payments diperlukan utk kolom TOTAL REWARD (V29). Dipastikan
# ada sekali per worker (idempoten) sebelum query list/search dijalankan.
_dist_schema_ready = False
# Tabel voter_first_seen (V41) dipakai utk band BARU vs REVOTE. Dipastikan ada
# (idempoten) — scan juga yang menciptakannya; dashboard siap bila belum.
_first_seen_schema_ready = False
def _ensure_dist_schema():
global _dist_schema_ready
if not _dist_schema_ready:
db.ensure_distribute_schema()
_dist_schema_ready = True
def _ensure_first_seen_schema():
global _first_seen_schema_ready
if not _first_seen_schema_ready:
db.ensure_first_seen_schema()
_first_seen_schema_ready = True
app = Flask(__name__) app = Flask(__name__)
@app.after_request
def _no_cache_html(resp):
"""HTML ⊥ di-cache browser (V28): tautan style.css memakai ?v= utk
memaksa fetch versi baru; halaman itu sendiri tak boleh heuristik cache
sehingga tiap muat selalu menarik tautan ber-version tsb."""
if resp.mimetype == 'text/html':
resp.headers['Cache-Control'] = 'no-store'
return resp
# Cache saldo liquid per worker (in-memory, ⊥ tulis DB). `at` mencatat # Cache saldo liquid per worker (in-memory, ⊥ tulis DB). `at` mencatat
# kapan sukses ATAU gagal terakhir → API dihubungi paling banyak sekali # kapan sukses ATAU gagal terakhir → API dihubungi paling banyak sekali
# per TTL (cooldown saat gagal pun ikut mencegah pukulan berulang). # per TTL (cooldown saat gagal pun ikut mencegah pukulan berulang).
@@ -44,103 +79,468 @@ def shortdate(value):
return '—' return '—'
@app.template_filter('countdown')
def countdown(value, now=None):
"""'VALID DALAM N HARI' utk pemilih BARU (V52).
Maturity = `first_seen_at` + `VEX_MATURITY_DAYS`. Nilai NULL/legacy →
None (⊥ cue). Dihitung utk hari penuh, minimal 1 hari."""
if not value:
return None
try:
valid_at = datetime.fromisoformat(value) + timedelta(days=VEX_MATURITY_DAYS)
except ValueError:
return None
now = now or datetime.now(timezone.utc).replace(tzinfo=None)
days = max(1, math.ceil((valid_at - now).total_seconds() / 86400))
return f'VALID DALAM {days} HARI'
# ————— Jadwal distribusi berikutnya (V38) —————
# Banner BERITA di dashboard menampilkan run distribusi berikutnya. Logika
# boundary sengaja mirror `distribute_loop.next_boundary` (⊥ impor lintas-image,
# V31) — oracle test_dashboard menjaga keduanya tak melenceng.
_SCHED_DAYS = ('SENIN', 'SELASA', 'RABU', 'KAMIS', 'JUMAT', 'SABTU', 'MINGGU')
# Parser pakai singkatan Inggris (sama dgn distribute_loop.WEEKDAYS); display
# id-ID via `_SCHED_DAYS` (index = weekday Python, Senin=0).
_SCHED_WEEKDAYS = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3,
'fri': 4, 'sat': 5, 'sun': 6}
_SCHED_MONTHS = ('JANUARI', 'FEBRUARI', 'MARET', 'APRIL', 'MEI', 'JUNI',
'JULI', 'AGUSTUS', 'SEPTEMBER', 'OKTOBER', 'NOVEMBER',
'DESEMBER')
def _parse_weekday(value):
"""Nama hari (`sat`) atau angka 0-6 → weekday Python (Senin=0)."""
v = str(value).strip().lower()
if v in _SCHED_WEEKDAYS:
return _SCHED_WEEKDAYS[v]
if v.isdigit() and 0 <= int(v) <= 6:
return int(v)
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
def _parse_weekdays(value):
"""Satu hari (`sat`) atau CSV (`wed,sat`) → daftar weekday unik terurut
(int, Senin=0). Mirror `distribute_loop.parse_weekdays` (V44)."""
if not value:
raise ValueError('DISTRIBUTE_WEEKDAY kosong')
days = sorted({_parse_weekday(d)
for d in str(value).split(',') if d.strip()})
if not days:
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
return days
def _parse_first_run(value):
"""`YYYY-MM-DD` → date; kosong → None (⊥ one-off)."""
if not value:
return None
return datetime.strptime(str(value).strip(), '%Y-%m-%d').date()
def _next_boundary(now, hour, weekdays, first_run=None):
"""Mirror `distribute_loop.next_boundary`: tiap hari di `weekdays` jam
`hour` + one-off `first_run` (yang sebelum launch dilewati); pilih event
terdekat > `now`."""
if isinstance(weekdays, int):
weekdays = [weekdays]
start = None
if first_run is not None:
start = datetime(first_run.year, first_run.month, first_run.day,
hour=hour)
candidates = []
if start is not None and start >= now:
candidates.append(start)
for weekday in weekdays:
days_ahead = (weekday - now.weekday()) % 7
weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0)
+ timedelta(days=days_ahead))
if weekly <= now:
weekly += timedelta(days=7)
if start is None or weekly >= start:
candidates.append(weekly)
return min(candidates)
def _next_schedule(now=None):
"""Run distribusi berikutnya (local TZ). `now` bisa di-inject utk oracle."""
return _next_boundary(now or datetime.now(), DISTRIBUTE_HOUR,
_parse_weekdays(DISTRIBUTE_WEEKDAY),
_parse_first_run(DISTRIBUTE_FIRST_RUN))
def _fmt_schedule(dt):
"""`SENIN · 17 AGUSTUS 2026 · 10:00 WIB` (id-ID, uppercase)."""
tz = dt.astimezone().tzname() or ''
return (f'{_SCHED_DAYS[dt.weekday()]} · {dt.day} '
f'{_SCHED_MONTHS[dt.month - 1]} {dt.year} · '
f'{dt.hour:02d}:{dt.minute:02d} {tz}').strip()
def _recurrence_label(now=None):
"""Baris berulang: `SETIAP RABU & SABTU · 10:00`. ⊥ tampil saat event
berikutnya = one-off first-run (tanggal launch ≠ hari mingguan → tak
konsisten); baris hanya utk putaran mingguan (muncul lagi setelah launch
lewat)."""
if _is_first_run_next(now):
return None
hari = ' & '.join(_SCHED_DAYS[d] for d in _parse_weekdays(DISTRIBUTE_WEEKDAY))
return f'SETIAP {hari} · {DISTRIBUTE_HOUR:02d}:00'
def _is_first_run_next(now=None):
"""True bila event distribusi berikutnya = one-off `DISTRIBUTE_FIRST_RUN`
(launch), bukan putaran mingguan."""
now = now or datetime.now()
first = _parse_first_run(DISTRIBUTE_FIRST_RUN)
if first is None:
return False
start = datetime(first.year, first.month, first.day, hour=DISTRIBUTE_HOUR)
return start >= now and _next_schedule(now) == start
def _now():
"""Jam lokal utk banner jadwal — injectable utk oracle (V64/B25).
Route memanggil helper ini (⊥ `datetime.now()` inline) agar test bisa
mem-fix tanggal; jam dinding nyata berubah seiring kalender, membuat
asersi "first-run masih depan" ⊖ stabil (B25).
"""
return datetime.now()
def _get_liquid_vex(): def _get_liquid_vex():
"""V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan. """V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan.
Ambil `account.core_liquid_balance` dari API databisnis. Bila TTL belum Ambil `account.core_liquid_balance` dari Hyperion. Bila TTL belum lewat →
lewat → nilai cache. Bila API gagal → nilai lama tetap dipakai (atau None nilai cache. V60: coba SEMUA `HYPERION_NODES` (failover pool) — host mati
bila belum pernah sukses), dan `at` ikut diset → API tak dipukul berulang. → lanjut cadangan, SALDO LIQUID tetap live. Bila SEMUA gagal → nilai lama
tetap dipakai (atau None bila belum pernah sukses), dan `at` ikut diset →
API tak dipukul berulang.
""" """
now = time.monotonic() now = time.monotonic()
if _liquid_cache['value'] is not None and now - _liquid_cache['at'] < DASH_LIQUID_TTL: if _liquid_cache['value'] is not None and now - _liquid_cache['at'] < DASH_LIQUID_TTL:
return _liquid_cache['value'] return _liquid_cache['value']
value = None value = None
for node in HYPERION_NODES:
try: try:
resp = requests.get( resp = requests.get(
f'{DATABISNIS_API}/v2/state/get_account', f'{node}/v2/state/get_account',
params={'account': TARGET_BP}, params={'account': TARGET_BP},
timeout=8, timeout=8,
) )
resp.raise_for_status() resp.raise_for_status()
raw = resp.json().get('account', {}).get('core_liquid_balance') data = resp.json()
# V61: bentuk tak dikenal (list/string/proxy salah) → lewati host
raw = None
if isinstance(data, dict):
raw = data.get('account', {}).get('core_liquid_balance')
if raw: if raw:
value = float(raw.split()[0]) value = float(raw.split()[0])
except (requests.RequestException, KeyError, TypeError, ValueError): break
value = _liquid_cache['value'] # gagal → nilai lama bila ada except (requests.RequestException, KeyError, TypeError, ValueError,
AttributeError):
continue
if value is None:
value = _liquid_cache['value'] # semua gagal → nilai lama bila ada
_liquid_cache.update(at=now, value=value) _liquid_cache.update(at=now, value=value)
return value return value
def _escape_like(q): def _escape_like(q):
"""Escape wildcard LIKE agar `%`/`_` dari pengguna tidak jadi pola.""" """Escape wildcard LIKE agar `%`/`_`/`!` dari pengguna tidak jadi pola.
return (q.replace('\\', '\\\\')
.replace('%', '\\%') `!` dipakai sebagai karakter escape, bukan backslash: backslash bermasalah
.replace('_', '\\_')) di literal string MySQL (`'\'` memutus string), sedangkan `!` netral di
kedua backend (sqlite & mysql sama-sama paham `ESCAPE '!'`).
"""
return (q.replace('!', '!!')
.replace('%', '!%')
.replace('_', '!_'))
def _filter_sql(q): def _filter_sql(q, tbl=''):
"""V11: klausa WHERE + params untuk filter owner substring.""" """V11: klausa WHERE + params untuk filter owner substring.
`tbl` = prefix kolom owner (mis. `v.`) bila query memakai JOIN; kosong utk
query tunggal agar kolom tak ambigu dengan kolom `owner` dari subquery.
"""
clauses = [] clauses = []
params = [] params = []
if q: if q:
clauses.append("owner LIKE ? ESCAPE '\\'") clauses.append(f"{tbl}owner LIKE %s ESCAPE '!'")
params.append(f'%{_escape_like(q)}%') params.append(f'%{_escape_like(q)}%')
if not clauses: if not clauses:
return '', [] return '', []
return 'WHERE ' + ' AND '.join(clauses), params return 'WHERE ' + ' AND '.join(clauses), params
def _reward_join():
"""V29: total reward per voter = Σ amount distribute_payments status=sent.
Gabung (LEFT) subquery agregat agar baris tanpa transfer tetap tampil dgn
reward 0. Hanya status `sent` (⊥ failed/pending) → tidak ada dobel hitung
saat baris gagal ikut run berikutnya.
"""
return ('LEFT JOIN (SELECT owner, SUM(amount) AS total '
' FROM distribute_payments '
' WHERE status = %s GROUP BY owner) rw '
' ON rw.owner = v.owner'), ['sent']
def _freshness_cutoffs(now=None):
"""V40/V43/V52: empat ambang umur (UTC-naive, konsisten dgn stempel
`last_vote` hasil scan & `first_seen_at`). Stempel lebih besar = lebih baru.
new_cut = now − VEX_MATURITY_DAYS: maturity akun — `first_seen_at
≤ new_cut` → matang/VALID;
`> new_cut`/NULL → BARU (V52)
warn_cut = now − (STALE − WARN): `expired_cut < last_vote ≤ warn_cut`
→ VALID + badge `VOTE ULANG SEGERA`
expired_cut = now − VEX_STALE_DAYS: `last_vote > expired_cut` → belum
basi (VALID bila akun matang)
hidden_cut = now − (STALE+EXPIRED): `hidden_cut < last_vote ≤ expired_cut`
→ KADALUARSA; `≤ hidden_cut` → sembunyi
V43: tag REVOTE hanya selama `last_vote > now − VEX_REVOTE_TAG_DAYS`
(ambang `revote_cut`, dihitung pemanggil); badge peringatan selama
`expired_cut < last_vote ≤ warn_cut`.
"""
if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None)
def cut(days):
return (now - timedelta(days=days)).isoformat(timespec='seconds')
return (cut(VEX_MATURITY_DAYS),
cut(VEX_STALE_DAYS - VEX_WARN_DAYS),
cut(VEX_STALE_DAYS),
cut(VEX_STALE_DAYS + VEX_EXPIRED_DAYS))
def _revote_cut(now=None):
"""V43: ambang tag REVOTE (mint) — hanya pemilih lama yang revote dalam
`now − VEX_REVOTE_TAG_DAYS` terakhir (`last_vote > revote_cut`) diberi
tag; setelah itu tampil VALID biasa (tetap dibayar langsung, V42)."""
if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None)
return (now - timedelta(days=VEX_REVOTE_TAG_DAYS)).isoformat(timespec='seconds')
def _edge_rows():
"""V40/V52: baris di luar band VALID — BARU (pemilih BARU) dan KADALUARSA
(`hidden < last_vote ≤ expired`). Jendela kecil → selalu tampil penuh
(⊥ pager). Rank = ordinal dalam band.
V52: BARU = akun yang baru pertama kali terlihat (`first_seen` None/DB lama
atau `> new_cut`) dengan `last_vote > exp_cut` — maturity 3 hari diukur
dari `first_seen_at` (presisi detik), BUKAN `last_vote` yang terkuantisasi
pekan (⊥ B15: voter baru Senin–Kamis tampak matang). Akun yang sudah
pernah terlihat (`first_seen ≤ new_cut`) → band VALID (`_db_rows`).
"""
new_cut, _warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
rw_join, rw_params = _reward_join()
sql = (
'SELECT v.owner, v.staked, v.last_vote, COALESCE(rw.total, 0), '
' fs.first_seen_at '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
+ rw_join + ' '
'WHERE v.last_vote > %s '
'ORDER BY v.staked DESC, v.owner ASC'
)
new_rows, expired_rows = [], []
for r in db.query(sql, rw_params + [hid_cut]):
if r[2] > exp_cut:
fs = r[4]
if fs is None or fs > new_cut:
# V52: BARU membawa first_seen_at (elemen ke-4) utk countdown
# `VALID DALAM N HARI` di template.
new_rows.append(r[:4] + (fs,))
else:
expired_rows.append(r[:4])
return new_rows, expired_rows
def _db_rows(page, q): def _db_rows(page, q):
"""V10/V11: satu halaman, urut staked DESC lalu owner ASC; rank global. """V10/V11/V40/V52: satu halaman band VALID (akun matang, `first_seen ≤
now−MATURITY`), rank dalam band.
Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke
subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter. subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter.
Correlated count selalu menghitung atas SELURUH tabel → rank tetap global. Correlated count menghitung atas SELURUH tabel → rank tetap global.
V40: subquery korelasi dibatasi jendela VALID agar rank 1..N konsisten dgn
daftar yang tampil (tiap band punya penomoran sendiri).
V52: jendela VALID = `last_vote > now−STALE AND first_seen ≤ now−MATURITY`
(maturity diukur dari `first_seen_at`, presisi detik — ⊥ `last_vote` yang
terkuantisasi pekan, B15); pemilih BARU (first_seen None/`> new_cut`)
→ band BARU, ⊖ di sini.
V43: flag revote (elemen ke-8) hanya utk revote < `VEX_REVOTE_TAG_DAYS`
(`last_vote > revote_cut`); elemen ke-9 = flag `VOTE ULANG SEGERA`
(`expired_cut < last_vote ≤ warn_cut`).
""" """
offset = (page - 1) * PAGE_SIZE offset = (page - 1) * PAGE_SIZE
where, params = _filter_sql(q) new_cut, warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
where, params = _filter_sql(q, 'v.')
rw_join, rw_params = _reward_join()
mature = ('fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s')
band = 'v.last_vote > %s AND ' + mature
combined = ('WHERE ' + band
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
mature2 = ('fs2.first_seen_at IS NOT NULL AND fs2.first_seen_at <= %s')
sql = ( sql = (
'SELECT v.owner, v.staked, v.weight, v.scanned_at, ' 'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
' (SELECT COUNT(*) FROM voters v2 ' ' (SELECT COUNT(*) FROM voters v2 '
' WHERE v2.staked > v.staked ' ' LEFT JOIN voter_first_seen fs2 ON fs2.owner = v2.owner '
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, ' ' WHERE v2.last_vote > %s AND ' + mature2 + ' '
' v.last_vote ' ' AND (v2.staked > v.staked '
'FROM voters v ' + where + ' ' ' OR (v2.staked = v.staked AND v2.owner <= v.owner))) AS rn, '
'ORDER BY v.staked DESC, v.owner ASC LIMIT ? OFFSET ?' ' v.last_vote, '
' COALESCE(rw.total, 0), '
' fs.first_seen_at '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
+ rw_join + ' ' + combined + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
) )
conn = sqlite3.connect(DB_PATH) rows = db.query(sql, [exp_cut, new_cut] + rw_params
try: + [exp_cut, new_cut]
return conn.execute(sql, params + [PAGE_SIZE, offset]).fetchall() + params + [PAGE_SIZE, offset])
finally: return [
conn.close() r + ((1,) if (r[7] is not None and r[7] <= new_cut
and r[5] > revote_cut) else (0,))
+ ((1,) if exp_cut < r[5] <= warn_cut else (0,))
for r in rows
]
def _count(q): def _count(q):
"""V11: banyak baris cocok filter (atau total bila tanpa filter).""" """V11/V40/V52: banyak baris band VALID cocok filter (utk pager).
where, params = _filter_sql(q) V52: jendela VALID = `last_vote > now−STALE AND first_seen ≤ now−MATURITY`
conn = sqlite3.connect(DB_PATH) (maturity dari `first_seen_at` — B15); tag REVOTE 1-hari & badge peringatan
try: ⊥ ubah cakupan."""
return conn.execute( new_cut, _warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
'SELECT COUNT(*) FROM voters ' + where, params where, params = _filter_sql(q, 'v.')
).fetchone()[0] mature = ('fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s')
finally: combined = ('WHERE v.last_vote > %s AND ' + mature
conn.close() + ((' AND ' + where[len('WHERE '):]) if where else ''))
return db.queryone(
'SELECT COUNT(*) FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner ' + combined,
[exp_cut, new_cut] + params)[0]
def _stats(): def _stats():
"""Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search).""" """Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search).
conn = sqlite3.connect(DB_PATH)
try: V40/V52: cakupan = semua baris TAMPIL (BARU + VALID + KADALUARSA,
total_voters, total_staked, top_staked = conn.execute( ⊖ yang tersembunyi > 31 hari). V52: maturity dari `first_seen_at`
'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) ' (presisi detik, ⊥ `last_vote` terkuantisasi pekan — B15): BARU = akun
'FROM voters' baru (`first_seen` None/`> new_cut`) dgn `last_vote > exp_cut`; VALID =
).fetchone() akun matang (`first_seen ≤ new_cut`) dgn `last_vote > exp_cut`.
scanned = conn.execute( Invariant: n_new + n_valid + n_expired == total_voters (partisi semua
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters' baris tampil). V43: `n_revote` (legenda mint) = subset VALID yg revote
).fetchone()[0] < `VEX_REVOTE_TAG_DAYS` (`last_vote > revote_cut`); `n_expiring` =
finally: subset VALID dgn `exp_cut < last_vote ≤ warn_cut` utk badge peringatan.
conn.close() """
return total_voters, total_staked, top_staked, scanned new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
row = db.queryone(
'SELECT COUNT(*), COALESCE(MAX(staked), 0) '
'FROM voters WHERE last_vote > %s', [hid_cut])
total_voters, top_staked = row
counts = db.queryone(
'SELECT '
' COALESCE(SUM(CASE WHEN v.last_vote > %s '
' AND (fs.first_seen_at IS NULL OR fs.first_seen_at > %s) '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s AND v.last_vote > %s '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN v.last_vote > %s '
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s THEN 1 ELSE 0 END), 0) '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
'WHERE v.last_vote > %s',
[exp_cut, new_cut,
new_cut, revote_cut,
hid_cut, exp_cut,
exp_cut, new_cut,
exp_cut, warn_cut, new_cut,
hid_cut],
)
n_new, n_revote, n_expired, n_valid, n_expiring = counts
scanned = db.queryone(
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters')[0]
return (total_voters, top_staked, scanned,
n_new, n_revote, n_expired, n_valid, n_expiring)
def _search_rows(q, cap):
"""V11/V40/V52: pencarian owner (substring) di SEMUA band tampil.
Kembalikan daftar dict bertag `group` ('baru'|'revote'|'valid'|
'kadaluarsa') + `expired` + rank ordinal dalam kelompok. Dipakai
`api_search` (JSON) dan mode `?q=` tanpa-JS agar hasilnya konsisten.
V52: BARU = akun baru (`first_seen` None/`> new_cut`) dgn `last_vote >
exp_cut`; VALID = akun matang (`first_seen ≤ new_cut`) dgn `last_vote >
exp_cut`. V43: tag `revote` HANYA utk akun matang yg revote <
`VEX_REVOTE_TAG_DAYS` (`last_vote > revote_cut`); revote lebih lama →
`valid` polos. Baris juga diberi `expiring` (`VOTE ULANG SEGERA`) saat
`exp_cut < last_vote ≤ warn_cut`.
"""
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
where, params = _filter_sql(q, 'v.')
rw_join, rw_params = _reward_join()
# V40: pencarian menjangkau SEMUA baris tampil (BARU + VALID + KADALUARSA)
# = seluruh band di atas ambang tersembunyi `last_vote > hid_cut`.
window = 'v.last_vote > %s'
if where:
combined = where + ' AND ' + window
params = params + [hid_cut]
else:
combined = 'WHERE ' + window
params = [hid_cut]
sql = (
'SELECT v.owner, v.staked, v.weight, v.last_vote, '
' COALESCE(rw.total, 0), fs.first_seen_at '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
+ rw_join + ' ' + combined + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s'
)
rows = db.query(sql, rw_params + params + [cap])
buckets = {'baru': 0, 'revote': 0, 'valid': 0, 'kadaluarsa': 0}
results = []
for r in rows:
fs = r[5]
is_mature = fs is not None and fs <= new_cut
tag_revote = is_mature and r[3] > revote_cut
expiring = exp_cut < r[3] <= warn_cut
if r[3] > exp_cut:
group = ('revote' if tag_revote
else ('valid' if is_mature else 'baru'))
else:
group = 'kadaluarsa'
buckets[group] += 1
results.append({
'owner': r[0], 'staked': r[1], 'weight': r[2],
'rank': buckets[group], 'last_vote': r[3], 'total_reward': r[4],
'group': group, 'expired': group == 'kadaluarsa',
'expiring': expiring,
})
return results
@app.get('/') @app.get('/')
@@ -150,61 +550,97 @@ def index():
except ValueError: except ValueError:
abort(400) abort(400)
q = request.args.get('q', '').strip()[:MAX_Q_LEN] q = request.args.get('q', '').strip()[:MAX_Q_LEN]
total_voters, total_staked, top_staked, scanned = _stats() _ensure_dist_schema()
_ensure_first_seen_schema()
(total_voters, top_staked, scanned,
n_new, n_revote, n_expired, n_valid, n_expiring) = _stats()
liquid_vex = _get_liquid_vex() liquid_vex = _get_liquid_vex()
if q:
# Mode pencarian (⊥ JS): satu daftar datar ber-tag, ⊥ bagian/pager.
search_rows = _search_rows(q, SEARCH_CAP)
filtered_count = len(search_rows)
rows = new_rows = expired_rows = ()
page = total_pages = 1
else:
search_rows = None
filtered_count = _count(q) filtered_count = _count(q)
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE) total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages) page = min(page, total_pages)
rows = _db_rows(page, q) rows = _db_rows(page, q)
new_rows, expired_rows = _edge_rows()
now = _now()
return render_template( return render_template(
'index.html', 'index.html',
bp=TARGET_BP, bp=TARGET_BP,
rows=rows, rows=rows,
new_rows=new_rows,
expired_rows=expired_rows,
search_rows=search_rows,
page=page, page=page,
total_pages=total_pages, total_pages=total_pages,
total_voters=total_voters, total_voters=total_voters,
total_staked=total_staked,
top_staked=top_staked, top_staked=top_staked,
liquid_vex=liquid_vex, liquid_vex=liquid_vex,
scanned=scanned, scanned=scanned,
q=q, q=q,
filtered_count=filtered_count, filtered_count=filtered_count,
new_count=n_new,
revote_count=n_revote,
expired_count=n_expired,
valid_count=n_valid,
expiring_count=n_expiring,
stale_days=VEX_STALE_DAYS,
warn_days=VEX_WARN_DAYS,
next_schedule=_fmt_schedule(_next_schedule(now)),
schedule_recur=_recurrence_label(now),
) )
@app.get('/api/search') @app.get('/api/search')
def api_search(): def api_search():
"""V11: JSON hasil pencarian owner (substring), urut staked DESC, rank global.""" """V11/V40: JSON hasil pencarian owner (substring) di SEMUA band tampil."""
q = request.args.get('q', '').strip()[:MAX_Q_LEN] q = request.args.get('q', '').strip()[:MAX_Q_LEN]
where, params = _filter_sql(q) _ensure_dist_schema()
sql = ( _ensure_first_seen_schema()
'SELECT v.owner, v.staked, v.weight, ' results = _search_rows(q, SEARCH_CAP)
' (SELECT COUNT(*) FROM voters v2 '
' WHERE v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
' v.last_vote '
'FROM voters v ' + where + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT ?'
)
conn = sqlite3.connect(DB_PATH)
try:
rows = conn.execute(sql, params + [SEARCH_CAP]).fetchall()
finally:
conn.close()
return jsonify({ return jsonify({
'query': q, 'query': q,
'count': len(rows), 'count': len(results),
'cap': SEARCH_CAP, 'cap': SEARCH_CAP,
'results': [ 'results': results,
{
'owner': r[0], 'staked': r[1], 'weight': r[2],
'rank': r[3], 'last_vote': r[4],
}
for r in rows
],
}) })
@app.get('/api/voter/<owner>')
def api_voter(owner):
"""V57: cek apakah akun = pemilih databisnisid yang memenuhi syarat reward.
Jendela sama persis dgn distribusi (`db.eligible_voters` — satu sumber,
dipakai pula `distribute.py`): `last_vote > now−STALE` (⊥ basi) DAN akun
matang (`first_seen_at ≤ now−MATURITY`). Akun BARU/KADALUARSA/tersembunyi/
tak ada → `false`. Baca snapshot DB saja (kesegaran = skan harian), ⊥ chain
live; ⊥ format-validation nama akun. Selalu 200, ⊖ 404.
"""
_ensure_first_seen_schema()
rows = db.eligible_voters(owner)
return jsonify({'owner': owner, 'is_valid_voter': len(rows) > 0})
@app.get('/history')
def history():
"""V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih.
Payments hanya run terbaru (V28); tanggal run terbaru ditampilkan di judul.
"""
runs = db.list_runs()
latest = runs[0][0] if runs else None
payments = db.list_payments(run_id=latest)
return render_template('history.html', runs=runs, payments=payments,
latest_date=runs[0][1] if runs else None,
bp=TARGET_BP)
if __name__ == '__main__': if __name__ == '__main__':
app.run(host=DASH_HOST, port=DASH_PORT, debug=False) app.run(host=DASH_HOST, port=DASH_PORT, debug=False)
+519
View File
@@ -0,0 +1,519 @@
"""Abstraksi penyimpanan: sqlite (default) atau MariaDB/MySQL (PyMySQL).
Pilih backend via config `VEX_DB_BACKEND` (env `.env`). Kedua backend memakai
skema `voters` yang sama; query berbagi sintaks ditulis dengan placeholder
`%s` yang diterjemahkan ke `?` untuk sqlite. Dashboard read-only (V10).
"""
import sqlite3
from config import (DB_BACKEND, DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_PATH,
DB_USER, VEX_MATURITY_DAYS, VEX_STALE_DAYS)
# Skema voters — sama untuk kedua backend (I.db)
_COLS = (
'owner VARCHAR(13) PRIMARY KEY, '
'weight TEXT NOT NULL, '
'staked DOUBLE NOT NULL, '
'scanned_at TEXT NOT NULL, '
'last_vote TEXT'
)
_SCHEMA = f'CREATE TABLE voters ({_COLS})'
_SCHEMA_IF_NOT_EXISTS = f'CREATE TABLE IF NOT EXISTS voters ({_COLS})'
def _translate(sql):
"""sqlite pakai `?`; mysql pakai `%s`. Ubah `%s` → `?` untuk sqlite."""
return sql.replace('%s', '?') if DB_BACKEND == 'sqlite' else sql
def connect():
"""Buka koneksi backend. sqlite: WAL agar pembaca tak terblokir."""
if DB_BACKEND == 'sqlite':
conn = sqlite3.connect(DB_PATH)
conn.execute('PRAGMA journal_mode=WAL')
return conn
if DB_BACKEND == 'mysql':
import pymysql
return pymysql.connect(
host=DB_HOST, port=DB_PORT, user=DB_USER, password=DB_PASS,
database=DB_NAME, charset='utf8mb4',
)
raise ValueError(f'VEX_DB_BACKEND tak dikenal: {DB_BACKEND!r}')
def query(sql, params=()):
"""Jalankan SELECT → daftar baris (tuple), koneksi dibuka-tutup tiap panggil."""
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(sql), params)
return list(cur.fetchall()) # sqlite→list, PyMySQL→tuple: seragamkan
finally:
conn.close()
def queryone(sql, params=()):
"""Jalankan SELECT → baris pertama (tuple) atau None."""
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(sql), params)
return cur.fetchone()
finally:
conn.close()
def replace_snapshot(rows, scanned_at):
"""V7: ganti snapshot voters tiap skan (⊥ history/append).
sqlite: DROP+CREATE+INSERT (WAL melindungi pembaca).
mysql: CREATE IF NOT EXISTS + DELETE + INSERT dalam satu transaksi
→ MVCC memberi pembaca snapshot konsisten (⊥ torn read saat ganti harian).
"""
tuples = [(r['owner'], r['weight'], r['staked'], scanned_at, r.get('last_vote'))
for r in rows]
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute('DROP TABLE IF EXISTS voters')
cur.execute(_SCHEMA)
cur.executemany(
'INSERT INTO voters (owner, weight, staked, scanned_at, last_vote) '
'VALUES (?, ?, ?, ?, ?)',
tuples,
)
else:
cur.execute(_SCHEMA_IF_NOT_EXISTS)
cur.execute('DELETE FROM voters')
cur.executemany(
'INSERT INTO voters (owner, weight, staked, scanned_at, last_vote) '
'VALUES (%s, %s, %s, %s, %s)',
tuples,
)
conn.commit()
finally:
conn.close()
# ——————————————————————————————————————————————————————————————————————
# Riwayat distribusi profit-share (V19..V27). Tabel append — tiap transfer
# & tiap run tercatat permanen (⊥ hapus). Skema sama untuk kedua backend,
# beda hanya klausa primary key autoincrement.
# ——————————————————————————————————————————————————————————————————————
_RUN_COLS = (
'run_date TEXT NOT NULL, '
'balance_start DOUBLE NOT NULL, '
'total_voters INTEGER NOT NULL, '
'total_staked DOUBLE NOT NULL, '
'total_sent DOUBLE NOT NULL, '
'status TEXT NOT NULL, '
'created_at TEXT NOT NULL'
)
_PAY_COLS = (
'run_id INTEGER NOT NULL, '
'owner VARCHAR(13) NOT NULL, '
'amount DOUBLE NOT NULL, '
'txid TEXT, '
'status TEXT NOT NULL, '
'error TEXT, '
'created_at TEXT NOT NULL, '
'updated_at TEXT NOT NULL'
)
_RUN_DDL_SQLITE = f'CREATE TABLE IF NOT EXISTS distribute_runs (run_id INTEGER PRIMARY KEY AUTOINCREMENT, {_RUN_COLS})'
_PAY_DDL_SQLITE = f'CREATE TABLE IF NOT EXISTS distribute_payments (payment_id INTEGER PRIMARY KEY AUTOINCREMENT, {_PAY_COLS})'
_RUN_DDL_MYSQL = f'CREATE TABLE IF NOT EXISTS distribute_runs (run_id INT AUTO_INCREMENT PRIMARY KEY, {_RUN_COLS})'
_PAY_DDL_MYSQL = f'CREATE TABLE IF NOT EXISTS distribute_payments (payment_id INT AUTO_INCREMENT PRIMARY KEY, {_PAY_COLS})'
def ensure_distribute_schema():
"""Buat tabel riwayat distribusi bila belum ada (idempoten, kedua backend)."""
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(_RUN_DDL_SQLITE)
cur.execute(_PAY_DDL_SQLITE)
else:
cur.execute(_RUN_DDL_MYSQL)
cur.execute(_PAY_DDL_MYSQL)
_drop_pay_memo(cur)
conn.commit()
finally:
conn.close()
def _drop_pay_memo(cur):
"""Migrasi: buang kolom memo lama dari distribute_payments (idempoten).
Kolom `memo` pernah disimpan (V23) lalu dihapus — DB produksi yang sudah
punya kolom perlu di-alter; DB baru langsung tanpa kolom.
"""
if DB_BACKEND == 'sqlite':
cur.execute(
"SELECT name FROM pragma_table_info('distribute_payments')")
cols = {row[0] for row in cur.fetchall()}
else:
cur.execute(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
" AND TABLE_NAME = 'distribute_payments'")
cols = {row[0] for row in cur.fetchall()}
if 'memo' in cols:
cur.execute(_translate('ALTER TABLE distribute_payments DROP COLUMN memo'))
def record_run(run_date, balance_start, total_voters, total_staked,
total_sent, status, created_at):
"""Insert baris `distribute_runs`, kembalikan run_id."""
ensure_distribute_schema()
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'INSERT INTO distribute_runs '
'(run_date, balance_start, total_voters, total_staked, total_sent, '
' status, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)'),
(run_date, balance_start, total_voters, total_staked, total_sent,
status, created_at))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def record_payment(run_id, owner, amount, status, created_at,
txid=None, error=None):
"""Insert baris `distribute_payments` → payment_id."""
ensure_distribute_schema()
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'INSERT INTO distribute_payments '
'(run_id, owner, amount, txid, status, error, created_at, '
' updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)'),
(run_id, owner, amount, txid, status, error, created_at,
created_at))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def update_payment_status(payment_id, status, txid=None, error=None, updated_at=None):
"""Perbarui status satu transfer (V21): pending → sent|failed."""
from datetime import datetime, timezone
if updated_at is None:
updated_at = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec='seconds')
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'UPDATE distribute_payments SET status = %s, txid = %s, '
'error = %s, updated_at = %s WHERE payment_id = %s'),
(status, txid, error, updated_at, payment_id))
conn.commit()
finally:
conn.close()
def update_run_status(run_id, status, total_sent):
"""Tutup run: status ok|partial + total terkirim nyata."""
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'UPDATE distribute_runs SET status = %s, total_sent = %s '
'WHERE run_id = %s'),
(status, total_sent, run_id))
conn.commit()
finally:
conn.close()
def list_runs():
"""Ringkasan run distribusi, terbaru dulu."""
ensure_distribute_schema()
return query(
'SELECT run_id, run_date, balance_start, total_voters, total_staked, '
' total_sent, status, created_at '
'FROM distribute_runs ORDER BY run_id DESC')
def list_payments(run_id=None):
"""Transfer per-pemilih, terbaru dulu; opsional filter satu run."""
ensure_distribute_schema()
sql = ('SELECT payment_id, run_id, owner, amount, txid, status, error, '
' created_at, updated_at FROM distribute_payments')
params = ()
if run_id is not None:
sql += ' WHERE run_id = %s'
params = (run_id,)
sql += ' ORDER BY payment_id DESC'
return query(sql, params)
# ——————————————————————————————————————————————————————————————————————
# Riwayat klaim reward BP (V32..V34). Satu baris per klaim sukses; fee yang
# belum terkirim (`pending`/`failed`) jadi sumber resume saat restart.
# ——————————————————————————————————————————————————————————————————————
_CLAIM_COLS = (
'run_date TEXT NOT NULL, '
'claim_txid TEXT, '
'reward DOUBLE NOT NULL, '
'fee_amount DOUBLE NOT NULL, '
'fee_status TEXT NOT NULL, ' # pending | sent | failed | skipped
'fee_txid TEXT, '
'claimed_at TEXT, '
'fee_sent_at TEXT, '
'created_at TEXT NOT NULL'
)
_CLAIM_DDL_SQLITE = (f'CREATE TABLE IF NOT EXISTS claim_runs '
f'(claim_id INTEGER PRIMARY KEY AUTOINCREMENT, '
f'{_CLAIM_COLS})')
_CLAIM_DDL_MYSQL = (f'CREATE TABLE IF NOT EXISTS claim_runs '
f'(claim_id INT AUTO_INCREMENT PRIMARY KEY, '
f'{_CLAIM_COLS})')
def ensure_claim_schema():
"""Buat tabel riwayat klaim bila belum ada (idempoten, kedua backend)."""
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(_CLAIM_DDL_SQLITE)
else:
cur.execute(_CLAIM_DDL_MYSQL)
conn.commit()
finally:
conn.close()
def record_claim(run_date, claim_txid, reward, fee_amount, fee_status,
created_at):
"""Insert baris `claim_runs` → claim_id."""
ensure_claim_schema()
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'INSERT INTO claim_runs '
'(run_date, claim_txid, reward, fee_amount, fee_status, '
' claimed_at, fee_sent_at, created_at) '
'VALUES (%s, %s, %s, %s, %s, %s, %s, %s)'),
(run_date, claim_txid, reward, fee_amount, fee_status, created_at,
None, created_at))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def update_claim_fee(claim_id, fee_status, fee_txid=None):
"""Perbarui status fee satu klaim (V33): pending → sent|failed|skipped."""
from datetime import datetime, timezone
conn = connect()
try:
cur = conn.cursor()
if fee_status == 'sent':
now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec='seconds')
cur.execute(_translate(
'UPDATE claim_runs SET fee_status = %s, fee_txid = %s, '
'fee_sent_at = %s WHERE claim_id = %s'),
(fee_status, fee_txid, now, claim_id))
else:
cur.execute(_translate(
'UPDATE claim_runs SET fee_status = %s, fee_txid = %s '
'WHERE claim_id = %s'),
(fee_status, fee_txid, claim_id))
conn.commit()
finally:
conn.close()
# ——————————————————————————————————————————————————————————————————————
# Kemunculan pertama pemilih (V41). Tabel append kecil — satu baris per owner
# (PK), mengingat KAPAN pertama kali muncul di snapshot. `voters` tetap
# snapshot murni (diganti tiap skan); first_seen bertahan di sini. Dipakai
# dashboard utk membedakan PEMILIH BARU (belum pernah terlihat) vs REVOTE
# (pemilih yang sudah pernah ada, revote/perpanjang).
# ——————————————————————————————————————————————————————————————————————
_FIRST_SEEN_COLS = 'owner VARCHAR(13) PRIMARY KEY, first_seen_at TEXT NOT NULL'
_FIRST_SEEN_DDL_SQLITE = (f'CREATE TABLE IF NOT EXISTS voter_first_seen '
f'({_FIRST_SEEN_COLS})')
_FIRST_SEEN_DDL_MYSQL = (f'CREATE TABLE IF NOT EXISTS voter_first_seen '
f'({_FIRST_SEEN_COLS})')
def ensure_first_seen_schema():
"""Buat tabel kemunculan pertama bila belum ada (idempoten, kedua backend)."""
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(_FIRST_SEEN_DDL_SQLITE)
else:
cur.execute(_FIRST_SEEN_DDL_MYSQL)
conn.commit()
finally:
conn.close()
def record_first_seen(rows, scanned_at):
"""Catat kemunculan pertama tiap owner (idempoten, ⊥ overwrite).
INSERT OR IGNORE (sqlite) / INSERT IGNORE (mysql): hanya baris yang belum
ada yang masuk — `first_seen_at` selalu tanggal kemunculan pertama.
"""
ensure_first_seen_schema()
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
sql = ('INSERT OR IGNORE INTO voter_first_seen '
'(owner, first_seen_at) VALUES (?, ?)')
else:
sql = ('INSERT IGNORE INTO voter_first_seen '
'(owner, first_seen_at) VALUES (%s, %s)')
cur.executemany(sql, [(r['owner'], scanned_at) for r in rows])
conn.commit()
finally:
conn.close()
# ——————————————————————————————————————————————————————————————————————
# V66: Notifikasi VOTE ULANG SEGERA — tabel lacak siapa sudah diberi tahu
# pada level urgensi berapa. Satu baris per owner (PK), di-update saat
# urgensi naik. Bersihkan otomatis saat pemilih keluar dari jendela warn.
# ——————————————————————————————————————————————————————————————————————
_NOTIFY_COLS = (
'owner VARCHAR(13) PRIMARY KEY, '
'urgency INTEGER NOT NULL, '
'notified_at TEXT NOT NULL'
)
_NOTIFY_DDL_SQLITE = (f'CREATE TABLE IF NOT EXISTS voter_notify '
f'({_NOTIFY_COLS})')
_NOTIFY_DDL_MYSQL = (f'CREATE TABLE IF NOT EXISTS voter_notify '
f'({_NOTIFY_COLS})')
def ensure_notify_schema():
"""Buat tabel lacak notifikasi VOTE ULANG SEGERA bila belum ada (idempoten)."""
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(_NOTIFY_DDL_SQLITE)
else:
cur.execute(_NOTIFY_DDL_MYSQL)
conn.commit()
finally:
conn.close()
def get_notify_urgency(owner):
"""V66: ambil level urgensi terakhir yang sudah diberi tahu utk `owner`.
Kembalikan integer (1/2/3) atau None bila belum pernah diberi tahu.
"""
ensure_notify_schema()
row = queryone(
'SELECT urgency FROM voter_notify WHERE owner = %s', (owner,))
return row[0] if row else None
def record_notify(owner, urgency, notified_at):
"""V66: catat bahwa `owner` sudah diberi tahu pada level `urgency`.
INSERT OR IGNORE (baru) + UPDATE bila urgensi naik (tidak turun).
Idempoten — safe dipanggil berulang.
"""
ensure_notify_schema()
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(
'INSERT OR IGNORE INTO voter_notify '
'(owner, urgency, notified_at) VALUES (?, ?, ?)',
(owner, urgency, notified_at))
cur.execute(
'UPDATE voter_notify SET urgency = ?, notified_at = ? '
'WHERE owner = ? AND urgency < ?',
(urgency, notified_at, owner, urgency))
else:
cur.execute(
'INSERT INTO voter_notify '
'(owner, urgency, notified_at) VALUES (%s, %s, %s) '
'ON DUPLICATE KEY UPDATE '
'urgency = IF(%s > urgency, %s, urgency), '
'notified_at = IF(%s > urgency, %s, notified_at)',
(owner, urgency, notified_at, urgency, urgency,
urgency, notified_at))
conn.commit()
finally:
conn.close()
def cleanup_notify(cutoff):
"""V66: hapus baris notifikasi yang sudah lewat jendela warn (stale)."""
ensure_notify_schema()
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'DELETE FROM voter_notify WHERE notified_at < %s'), (cutoff,))
conn.commit()
finally:
conn.close()
def eligible_voters(owner=None):
"""V57: pemilih yang memenuhi syarat reward (single source, dipakai
`distribute.py` + endpoint `/api/voter/<owner>`).
Jendela reward V40/V52 = `last_vote > now−STALE` (⊥ basi) DAN akun MATANG
(`first_seen_at IS NOT NULL` DAN `first_seen_at ≤ now−MATURITY`). Maturity
diukur dari `first_seen_at` (presisi detik; B15). Tanpa `owner` → semua
baris `(owner, staked)` utk distribusi; dengan `owner` → baris akun itu
(`[]` bila tak memenuhi syarat) utk cek endpoint.
"""
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc).replace(tzinfo=None)
stale_cutoff = (now - timedelta(days=VEX_STALE_DAYS)).isoformat(timespec='seconds')
mature_cutoff = (now - timedelta(days=VEX_MATURITY_DAYS)).isoformat(timespec='seconds')
sql = ('SELECT v.owner, v.staked FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
'WHERE v.last_vote > %s '
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s')
params = [stale_cutoff, mature_cutoff]
if owner is not None:
sql += ' AND v.owner = %s'
params.append(owner)
ensure_first_seen_schema()
return query(sql, params)
def pending_claim_fee():
"""Klaim terbaru dengan fee belum terkirim (fee_amount > 0) → baris | None.
Source of truth resume: fee `pending`/`failed` diulang tiap siklus dan
dilanjutkan saat restart (V34).
"""
ensure_claim_schema()
return queryone(
'SELECT claim_id, run_date, claim_txid, reward, fee_amount, '
' fee_status, fee_txid, claimed_at, fee_sent_at, created_at '
'FROM claim_runs '
'WHERE fee_status IN (%s, %s) AND fee_amount > 0 '
'ORDER BY claim_id DESC LIMIT 1',
('pending', 'failed'))
+368
View File
@@ -0,0 +1,368 @@
"""Distribusi profit-share harian: kirim saldo liquid VEX akun BP ke pemilih.
Setiap hari jam `DISTRIBUTE_HOUR` (lokal, TZ), `distribute.py` membaca pemilih
segar tersimpan (snapshot terkini dari scan 08:00), menghitung porsi
proporsional stake (V20), lalu mengirim transfer `vex.token::transfer`
bertanda tangan (pyntelope, key aktif BP dari secret/env
`VEX_BP_PRIVATE_KEY`). Setiap transfer dicatat di `distribute_payments`
sebagai `pending` SEBELUM kirim (V21). Retry hanya baris `failed`; sebelum
resend, txid diverifikasi di Hyperion agar transfer yang sempat mendarat tak
terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan
(V26). Tidak ada freezing/carryover (V24): tiap run menghitung ulang segar.
V63: send-loop tidur `DISTRIBUTE_SEND_DELAY` (default 30s) ANTAR pemilih —
beri node/Hyperion waktu mengejar indeks + meratakan beban broadcast.
"""
import sys
import time
from datetime import datetime, timezone
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
import requests
import db
import telegram
from config import (API_NODES, BP_PRIVATE_KEY, HYPERION_NODES,
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS,
DISTRIBUTE_SEND_DELAY, TARGET_BP,
TOKEN_CONTRACT, VEX_SYMBOL)
VEX_PREC = Decimal('0.0001')
# V58: jendela retry saldo sebelum menyerah ke jadwal berikutnya (~10 menit).
# Dipakai real-run saja; dry-run gagal cepat (single attempt).
BALANCE_RETRY_DELAYS = [30, 60, 90, 120, 150, 180]
def _post_json(url, payload, max_retries=3, timeout=30):
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`.
V58: mencoba SEMUA node di `API_NODES` per putaran — gagal di node satu
lanjut node berikutnya; baru menyerah setelah `max_retries` putaran penuh
tanpa satu pun node berhasil.
"""
for attempt in range(max_retries):
for node in API_NODES:
try:
resp = requests.post(f'{node}{url}', json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
except requests.RequestException:
continue
if attempt == max_retries - 1:
raise RuntimeError(
f'Gagal menghubungi semua node RPC {API_NODES} '
f'({max_retries}×)')
print(f'[Peringatan] Semua node gagal, mencoba lagi... '
f'({attempt + 1}/{max_retries})', flush=True)
time.sleep(2)
def fetch_balance():
"""V20: saldo liquid VEX akun `TARGET_BP` dari chain get_currency_balance.
V58: jalan lewat `_post_json` failover node pool — bila semua node gagal
→ raise (bukan None). Kembalikan Decimal (`0` bila kosong).
"""
data = _post_json('/v1/chain/get_currency_balance',
{'code': TOKEN_CONTRACT, 'account': TARGET_BP,
'symbol': VEX_SYMBOL})
raw = (data or [''])[0] or ''
try:
return Decimal(raw.split()[0])
except (IndexError, ValueError, InvalidOperation):
return Decimal('0')
def fetch_balance_with_retry(dry_run=False):
"""V58: fetch saldo dgn retry backoff (~10 menit) di real-run.
Bila SEMUA node di `API_NODES` gagal (via `_post_json`), tidur
`BALANCE_RETRY_DELAYS[i]` lalu coba lagi — jendela total ~10 menit sebelum
menyerah (raise → main → notify_failure → jadwal berikutnya). Dry-run:
satu attempt saja, gagal cepat (preview tak boleh tertahan lama).
"""
delays = [] if dry_run else BALANCE_RETRY_DELAYS
for i, delay in enumerate(delays + [0]):
try:
return fetch_balance()
except Exception:
if i == len(delays):
raise
print(f'[Peringatan] Semua node gagal saat baca saldo — coba '
f'lagi dalam {delay}s ({i + 1}/{len(delays)})', flush=True)
time.sleep(delay)
def compute_shares(balance, voters):
"""V20: porsi per pemilih = floor(balance × stake ÷ total_stake), 4 desimal.
`voters` = iterable (owner, staked). Σshare ≤ balance; sisa (dust) tetap
di akun (⊥ drain penuh). Porsi nol tidak dikembalikan.
"""
total = sum((Decimal(str(s)) for _, s in voters), Decimal('0'))
if total <= 0:
return []
shares = []
for owner, staked in voters:
share = (balance * Decimal(str(staked)) / total).quantize(
VEX_PREC, rounding=ROUND_FLOOR)
if share > 0:
shares.append((owner, share))
return shares
def _get_json(url, params=None, max_retries=3, timeout=10):
"""GET JSON dengan retry + failover pool Hyperion (V60).
`url` = path di belakang host (mis. `/v2/history/get_transaction`) — coba
SETIAP `HYPERION_NODES` per putaran (semua mainnet terverifikasi layani
`/v2`), `max_retries` putaran penuh, jeda 2s antar putaran. → dict bila
sukses, None bila SEMUA host gagal SEMUA putaran (⊥ raise — pemanggil
butuh None utk jalan tak-terukur / hold, pola `verify_txid`).
"""
for attempt in range(max_retries):
for node in HYPERION_NODES:
try:
resp = requests.get(f'{node}{url}', params=params, timeout=timeout)
resp.raise_for_status()
data = resp.json()
if isinstance(data, dict):
return data
# V61: 200 tapi bentuk tak dikenal (list/string/proxy salah)
# ⊖ dianggap sukses — lewati host ini, coba cadangan.
continue
except requests.RequestException:
continue
if attempt < max_retries - 1:
print(f'[Peringatan] Semua node Hyperion gagal, mencoba lagi... '
f'({attempt + 1}/{max_retries})', flush=True)
time.sleep(2)
return None
def verify_txid(txid):
"""V22: cek tx benar-benar mendarat via Hyperion `get_transaction`.
→ True bila `executed`, False bila tak ditemukan/diproses, None bila
Hyperion TAK TERJANGKAU (⊥ kirim ulang bila tak bisa dipastikan).
V60: None kini berarti SEMUA `HYPERION_NODES` gagal semua putaran
(bukan satu host hiccup) — hold V59 jadi jarang palsu.
"""
data = _get_json('/v2/history/get_transaction', params={'id': txid})
if not isinstance(data, dict): # V61: bentuk tak dikenal → ⊖ asumsi apapun
return None
return bool(data.get('executed'))
def _verify_settled(txid, retries=3, delay=2):
"""V61: verifikasi txid dgn settle-retry singkat — tx yang BARU mendarat
bisa belum terindeks Hyperion (`executed: false` sesaat, pola V50/B14 di
klaim). Baca `verify_txid` beberapa kali sebelum menyimpulkan tak-mendarat.
None (SEMUA host Hyperion mati) → langsung False (⊖ buang waktu sleep utk
pool yang mati — pemanggil TAHAN). → True bila pernah `executed`; False
bila tak bisa dikonfirmasi (pemanggil TAHAN, ⊖ resend). """
for _ in range(retries):
landed = verify_txid(txid)
if landed is True:
return True
if landed is None:
return False
time.sleep(delay)
return False
def _send_rejected(resp):
"""V61: deteksi penolakan chain dari respons `send()` — WHITELIST.
pyntelope ⊥ raise pada HTTP 500 — chain rejection datang sbg body JSON
(pola V46/B11). Sukses HANYA bila respons berbentuk dict YANG MEMILIKI
`transaction_id` (nodeos selalu menyertakan pada push yang diterima);
bentuk penolakan (`{"code":500,"error":...}`), bentuk tak dikenal, maupun
`soft_fail`/`delayed` (200 tapi receipt ⊥ `executed`) → dianggap TOLAK.
→ True bila harus diperlakukan sbg gagal (⊥ tandai `sent` utk tx yang
belum tentu mendarat); False bila terbukti sukses sah.
"""
if not isinstance(resp, dict) or not resp.get('transaction_id'):
return True
receipt = ((resp.get('processed') or {}).get('receipt') or {}).get('status')
if receipt is not None and receipt != 'executed':
return True
return False
def build_signed_transfer(to, amount, memo):
"""Bangun + tanda tangani tx `vex.token::transfer` via pyntelope.
Kembalikan objek SignedTransaction (⊥ broadcast). `amount` = Decimal.
Mengambil ABI + TAPOS dari node saat `link()` — V58: coba SETIAP node di
`API_NODES` (net di-bind ke node itu, jadi broadcast `send()` ikut ke node
yang sama); baru raise bila semua node gagal.
"""
from pyntelope import Action, Authorization, Data, Net, Transaction, types
last_err = None
for node in API_NODES:
try:
net = Net(host=node)
trx = Transaction(actions=[
Action(
account=TOKEN_CONTRACT,
name='transfer',
authorization=[Authorization(actor=TARGET_BP,
permission='active')],
data=[
Data(name='from', value=types.Name(TARGET_BP)),
Data(name='to', value=types.Name(to)),
Data(name='quantity',
value=types.Asset(f'{amount:.4f} {VEX_SYMBOL}')),
Data(name='memo', value=types.String(memo)),
],
)
])
linked = trx.link(net=net)
return linked.sign(key=BP_PRIVATE_KEY)
except Exception as exc: # node mati/link gagal → coba node cadangan
last_err = exc
raise RuntimeError(
f'Gagal membangun transfer {to} di semua node RPC {API_NODES}: '
f'{last_err}')
def run_distribution(dry_run=False):
"""V21..V26: satu run distribusi harian.
Urutan: baca pemilih tersimpan → saldo → porsi → catat run + payment →
kirim tiap transfer (retry dalam hari, guard anti-duplikat) → tutup run.
Dry-run berhenti sebelum menulis DB / tanda tangan.
"""
if not dry_run and not DISTRIBUTE_ENABLED:
print('Distribusi dinonaktifkan (DISTRIBUTE_ENABLED=false) — no-op.',
flush=True)
return 0
db.ensure_first_seen_schema()
voters = db.eligible_voters()
if not voters:
print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True)
return 0
balance = fetch_balance_with_retry(dry_run=dry_run)
if balance is None or balance < VEX_PREC:
print('Saldo liquid kosong / di bawah 0.0001 VEX — no-op.', flush=True)
return 0
shares = compute_shares(balance, voters)
if not shares:
print('Tak ada porsi positif — no-op.', flush=True)
return 0
run_date = datetime.now().strftime('%Y-%m-%d')
memo = f'DATABISNISID PROFIT SHARE {run_date}'
if dry_run:
print('=== RENCANA DISTRIBUSI (dry-run, ⊥ tanda tangan) ===')
print(f'Tanggal: {run_date} | Saldo: {balance} VEX | Memo: {memo}')
for owner, share in shares:
print(f'{owner}: {share} VEX')
print(f'TOTAL: {sum(share for _, share in shares)} VEX '
f'({len(shares)} pemilih)')
return 0
if not BP_PRIVATE_KEY:
raise RuntimeError(
'VEX_BP_PRIVATE_KEY kosong — set secret dulu sebelum run nyata '
'(⊥ distribusi tanpa key)')
total_share = sum(share for _, share in shares)
telegram.notify_start(run_date, len(shares), total_share, balance)
created_at = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec='seconds')
total_stake = sum(float(s) for _, s in voters)
run_id = db.record_run(run_date, float(balance), len(voters), total_stake,
0.0, 'ok', created_at)
pending = []
for owner, share in shares:
pid = db.record_payment(run_id, owner, float(share), 'pending',
created_at)
pending.append((pid, owner, share))
print(f'Mulai distribusi: {len(pending)} transfer, memo "{memo}"',
flush=True)
failed = []
sent_amount = 0.0
for idx, (pid, owner, share) in enumerate(pending):
txid = None
for attempt in range(1, DISTRIBUTE_MAX_ATTEMPTS + 1):
try:
signed = build_signed_transfer(owner, share, memo)
txid = signed.id() # deterministik, sebelum broadcast
resp = signed.send()
# V59: pyntelope ⊥ raise pada HTTP 500 — penolakan chain datang
# sbg body JSON (pola V46/B11). Deteksi → perlakukan sbg gagal,
# ⊖ tandai 'sent' untuk tx yang ⊥ pernah mendarat.
if _send_rejected(resp):
raise RuntimeError(
f'chain menolak transfer: '
f"{resp.get('error', {}).get('what') or resp}")
db.update_payment_status(pid, 'sent', txid=txid)
sent_amount += float(share)
print(f'[OK] {owner}: {share} VEX ({txid[:16]}...)',
flush=True)
break
except Exception as exc:
# Timeout/penolakan setelah broadcast → tx mungkin mendarat.
# Verifikasi SEBELUM resend agar ⊥ ganda (V22). V61: HANYA
# `executed` yang boleh dilanjutkan; False (Hyperion bilang tak
# mendarat — bisa lag indeks V50/B14) MAUPUN None (Hyperion
# down) → TAHAN (tandai failed, ⊖ resend): resend membawa
# tapos/expiration baru → txid baru → ganda.
if txid is not None:
landed = _verify_settled(txid)
if landed is True:
db.update_payment_status(pid, 'sent', txid=txid)
sent_amount += float(share)
print(f'[OK] {owner}: {share} VEX '
f'(terverifikasi, {txid[:16]}...)', flush=True)
break
print(f'[Peringatan] Verifikasi txid tak tersedia '
f'untuk {owner} — ditahan (⊥ resend)',
flush=True)
db.update_payment_status(pid, 'failed', txid=txid,
error=str(exc))
failed.append((owner, exc))
print(f'[DITAHAN] {owner}: {share} VEX — {exc}',
flush=True)
break
if attempt == DISTRIBUTE_MAX_ATTEMPTS:
db.update_payment_status(pid, 'failed', txid=txid,
error=str(exc))
failed.append((owner, exc))
print(f'[GAGAL] {owner}: {share} VEX — {exc}', flush=True)
break
time.sleep(2)
# V63: jeda ANTAR pemilih — beri node/Hyperion waktu mengejar indeks
# + meratakan beban broadcast run panjang. N pemilih → N−1 tidur
# (⊥ setelah pemilih terakhir); tetap jalan walau pemilih ini
# ditahan/gagal (pacing seragam). Dry-run ⊖ pernah sampai sini.
if idx < len(pending) - 1:
time.sleep(DISTRIBUTE_SEND_DELAY)
status = 'ok' if not failed else 'partial'
db.update_run_status(run_id, status, round(sent_amount, 4))
telegram.notify_finish(status, len(pending) - len(failed), len(pending),
total_share, failed, run_date)
print(f'SELESAI: {len(pending) - len(failed)}/{len(pending)} terkirim '
f'(status run: {status})', flush=True)
return 0
def main():
dry_run = '--dry-run' in sys.argv
try:
return run_distribution(dry_run=dry_run)
except Exception as exc:
telegram.notify_failure(exc)
raise
if __name__ == '__main__':
sys.exit(main())
+134
View File
@@ -0,0 +1,134 @@
"""Jadwal distribusi: jalankan distribute.py pada hari `DISTRIBUTE_WEEKDAY`
(satu atau CSV, mis. `wed,sat`) jam `DISTRIBUTE_HOUR`, plus satu one-off
`DISTRIBUTE_FIRST_RUN` (YYYY-MM-DD, default baked `2026-08-17` via config).
Contoh produksi: `DISTRIBUTE_FIRST_RUN=2026-08-17` (Senin) lalu
`DISTRIBUTE_WEEKDAY=wed,sat` → putaran pertama Senin 17 Agt 10:00, berikutnya
tiap Rabu & Sabtu 10:00.
Menunggu hingga batas terjadwal berikutnya (waktu lokal via `TZ`) lalu memanggil
`distribute.main()`. Loop tak pernah keluar — service dijaga hidup oleh
restart_policy swarm. Gagal distribusi dicatat lalu lanjut ke jadwal berikutnya
(⊥ membunuh loop). Tanpa `VEX_BP_PRIVATE_KEY` run nyata raise RuntimeError yang
jelas — ditangkap, dicetak, dicoba lagi di jadwal berikutnya.
Berbeda dengan scan: ⊥ ada "distribusi-awal" saat start (SCAN_RUN_ON_START) —
membayar di container start berisiko memakai snapshot basi; distribusi hanya
berjalan pada jadwal.
Scheduler helper (`next_boundary`, `_wait_db`) sengaja diduplikasi dari
`scan_loop.py` (bukan di-import) karena kedua image Docker terpisah:
`Dockerfile.dist` ⊥ membawa `scan_loop.py`/`get_voters.py`.
"""
import time
from datetime import datetime, timedelta
import config
import db
import distribute
WEEKDAYS = {
'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3,
'fri': 4, 'sat': 5, 'sun': 6,
}
def parse_weekday(value):
"""Nama hari (`sat`) atau angka 0-6 → weekday Python (Senin=0)."""
v = str(value).strip().lower()
if v in WEEKDAYS:
return WEEKDAYS[v]
if v.isdigit() and 0 <= int(v) <= 6:
return int(v)
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
def parse_weekdays(value):
"""Satu hari (`sat`) atau CSV beberapa hari (`wed,sat`) → daftar weekday
(int, Senin=0) unik terurut. V44: jadwal bisa multi-hari per minggu."""
if not value:
raise ValueError('DISTRIBUTE_WEEKDAY kosong')
days = sorted({parse_weekday(d) for d in str(value).split(',') if d.strip()})
if not days:
raise ValueError(f'DISTRIBUTE_WEEKDAY tak valid: {value!r}')
return days
def parse_first_run(value):
"""`YYYY-MM-DD` → date; kosong → None (⊥ one-off)."""
if not value:
return None
return datetime.strptime(str(value).strip(), '%Y-%m-%d').date()
def next_boundary(now, hour, weekdays, first_run=None):
"""datetime berikutnya utk distribusi.
Mingguan: tiap hari di `weekdays` (list int) jam `hour`. Bila `first_run`
diset, distribusi ⊥ jalan sebelum tanggal itu — hari-hari mingguan yang
jatuh sebelum launch dilewati; event = {one-off `first_run` (jika masih
depan)} ∪ {mingguan ≥ tanggal `first_run`}; pilih yang terdekat > `now`."""
if isinstance(weekdays, int):
weekdays = [weekdays]
start = None
if first_run is not None:
start = datetime(first_run.year, first_run.month, first_run.day,
hour=hour)
candidates = []
if start is not None and start >= now:
candidates.append(start)
for weekday in weekdays:
days_ahead = (weekday - now.weekday()) % 7
weekly = (now.replace(hour=hour, minute=0, second=0, microsecond=0)
+ timedelta(days=days_ahead))
if weekly <= now:
weekly += timedelta(days=7)
if start is None or weekly >= start:
candidates.append(weekly)
return min(candidates)
def _wait_db(timeout=120):
"""Tunggu basis data siap (mariadb baru boot) sebelum loop."""
start = time.monotonic()
while time.monotonic() - start < timeout:
try:
db.queryone('SELECT 1')
return
except Exception:
time.sleep(3)
raise RuntimeError('basis data tak siap setelah menunggu')
def _run_distribute(label):
print(f'[{label}] memulai distribusi...', flush=True)
try:
distribute.main()
except Exception as exc:
print(f'[Error] Distribusi gagal: {exc}', flush=True)
def main():
hour = config.DISTRIBUTE_HOUR
if not 0 <= hour <= 23:
raise ValueError(f'DISTRIBUTE_HOUR tak valid: {hour!r}')
weekdays = parse_weekdays(config.DISTRIBUTE_WEEKDAY)
first_run = parse_first_run(config.DISTRIBUTE_FIRST_RUN)
hari = ' & '.join(list(WEEKDAYS)[d] for d in weekdays)
jadwal = (f'jam {hour} tiap hari {hari} (waktu lokal)'
+ (f', one-off {first_run.isoformat()}' if first_run else ''))
print(f'Jadwal distribusi: {jadwal}')
_wait_db()
while True:
now = datetime.now()
target = next_boundary(now, hour, weekdays, first_run)
wait = (target - now).total_seconds()
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
f'sampai {target.isoformat(timespec="seconds")}', flush=True)
time.sleep(max(wait, 0))
_run_distribute('distribusi terjadwal')
if __name__ == '__main__':
main()
+22
View File
@@ -0,0 +1,22 @@
services:
mariadb:
image: mariadb:11
container_name: databisnisid-mariadb
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root}
MARIADB_DATABASE: ${MARIADB_DATABASE:-databisnisid}
MARIADB_USER: ${MARIADB_USER:-databisnis}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-databisnis}
ports:
- "127.0.0.1:3306:3306"
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 12
volumes:
mariadb_data:
+160
View File
@@ -0,0 +1,160 @@
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
#
# 5 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5001),
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ),
# distribute (pembayaran harian distribute.py pada DISTRIBUTE_HOUR), dan
# claim (klaim reward BP harian claim_loop.py + fee 10% ke VEX_BP_FEE_WALLET).
#
# Catatan swarm:
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
# (harus dibangun lebih dulu; multi-node perlu registry).
# - `env_file` ⊥ didukung stack → environment inline dgn interpolasi `.env`.
# - Tanpa `container_name`/`restart:` — pakai `deploy.restart_policy`.
services:
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root}
MARIADB_DATABASE: ${MARIADB_DATABASE:-databisnisid}
MARIADB_USER: ${MARIADB_USER:-databisnis}
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-databisnis}
volumes:
- /data/db/mariadb/databisnisid/data:/var/lib/mysql
networks:
- appnet
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 5s
timeout: 5s
retries: 12
deploy:
replicas: 1
placement:
constraints:
- node.hostname == server5.saltis.id
restart_policy:
condition: any
web:
image: git.proit.id/proitlab/databisnisid-web:latest
environment:
VEX_DB_BACKEND: mysql
VEX_DB_HOST: mariadb
VEX_DB_PORT: "3306"
VEX_DB_USER: ${MARIADB_USER:-databisnis}
VEX_DB_PASS: ${MARIADB_PASSWORD:-databisnis}
VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid}
DASH_HOST: "0.0.0.0" # wajib di container → gunicorn bind terbuka utk ingress
DASH_PORT: "5000"
DASH_WORKERS: "2"
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
DASH_LIQUID_TTL: "60"
TZ: ${TZ:-Asia/Jakarta}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-wed,sat}
DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17}
ports:
- published: 5001
target: 5000
networks:
- appnet
healthcheck:
test: ["CMD", "python", "-c", "import socket,sys;sys.exit(0 if socket.create_connection(('127.0.0.1',5000),5) else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
scan:
image: git.proit.id/proitlab/databisnisid-scan:latest
command: ["python", "scan_loop.py"]
environment:
VEX_DB_BACKEND: mysql
VEX_DB_HOST: mariadb
VEX_DB_PORT: "3306"
VEX_DB_USER: ${MARIADB_USER:-databisnis}
VEX_DB_PASS: ${MARIADB_PASSWORD:-databisnis}
VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid}
TZ: ${TZ:-Asia/Jakarta}
SCAN_HOURS: ${SCAN_HOURS:-0,8,16}
SCAN_RUN_ON_START: ${SCAN_RUN_ON_START:-1}
volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
networks:
- appnet
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
distribute:
image: git.proit.id/proitlab/databisnisid-dist:latest
command: ["python", "distribute_loop.py"]
environment:
VEX_DB_BACKEND: mysql
VEX_DB_HOST: mariadb
VEX_DB_PORT: "3306"
VEX_DB_USER: ${MARIADB_USER:-databisnis}
VEX_DB_PASS: ${MARIADB_PASSWORD:-databisnis}
VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid}
TZ: ${TZ:-Asia/Jakarta}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-wed,sat}
DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17}
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
DISTRIBUTE_SEND_DELAY: ${DISTRIBUTE_SEND_DELAY:-30}
DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false}
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
networks:
- appnet
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
claim:
image: git.proit.id/proitlab/databisnisid-claim:latest
command: ["python", "claim_loop.py"]
environment:
VEX_DB_BACKEND: mysql
VEX_DB_HOST: mariadb
VEX_DB_PORT: "3306"
VEX_DB_USER: ${MARIADB_USER:-databisnis}
VEX_DB_PASS: ${MARIADB_PASSWORD:-databisnis}
VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid}
TZ: ${TZ:-Asia/Jakarta}
CLAIM_RETRY_SECONDS: ${CLAIM_RETRY_SECONDS:-60}
VEX_BP_FEE_WALLET: ${VEX_BP_FEE_WALLET:-bpdbsjasprod}
VEX_BP_FEE_PERCENT: ${VEX_BP_FEE_PERCENT:-0.10}
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
networks:
- appnet
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
networks:
appnet:
driver: overlay
+77 -47
View File
@@ -1,18 +1,23 @@
"""Scan tabel voters Vexanium, simpan pemilih BP databisnisid + bobot suara ke SQLite. """Scan tabel voters Vexanium, simpan pemilih BP databisnisid + bobot suara ke SQLite.
Hanya pemilih segar (revote ≤ VEX_STALE_DAYS lalu) yang disimpan — vote basi Sejak V40 SEMUA pemilih BP dengan last_vote terverifikasi disimpan — termasuk
dibuang. Berjalan imparsial (repeatable) dan siap dijadwalkan (cron). yang basi (> VEX_STALE_DAYS). Banding/kadaluarsa bukan urusan scan: tampilan
(dashboard) dan pembayaran (distribute) menghitung umur vote saat itu juga.
Hanya pemilih dengan last_vote tak terverifikasi (bobot nol/kosong) yang dibuang.
Berjalan imparsial (repeatable) dan siap dijadwalkan (cron).
""" """
import math import math
import sqlite3
import sys import sys
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import requests import requests
from config import API_NODE, DB_PATH, MIN_STAKED_VEX, TARGET_BP, VEX_STALE_DAYS import db
import telegram
from config import (API_NODE, MIN_STAKED_VEX, TARGET_BP,
VEX_EXPIRED_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS)
# Kontrak & scope sistem Vexanium = vexcore (BUKAN vexio sesuai konvensi EOS) # Kontrak & scope sistem Vexanium = vexcore (BUKAN vexio sesuai konvensi EOS)
CODE = 'vexcore' CODE = 'vexcore'
@@ -25,7 +30,8 @@ LIMIT = 500
# Ambang-batas nama akun "0" (kodifikasi uint64 0) pada tabel voters # Ambang-batas nama akun "0" (kodifikasi uint64 0) pada tabel voters
ZERO_OWNER = '...........q' ZERO_OWNER = '...........q'
# Skala stake (×10000) dan epoch bobot suara Vexanium (1 Jan 2000) # Skala stake (×10000) dan epoch bobot suara Vexanium (1 Jan 2000,
# block_timestamp_epoch). Eksponen bobot = PEKAN utuh sejak epoch ÷ 52.
VEX_SCALE = 10000 VEX_SCALE = 10000
WEIGHT_EPOCH = datetime(2000, 1, 1) WEIGHT_EPOCH = datetime(2000, 1, 1)
@@ -33,8 +39,11 @@ WEIGHT_EPOCH = datetime(2000, 1, 1)
def derive_last_vote(weight, staked_vex): def derive_last_vote(weight, staked_vex):
"""V13: perkiraan waktu revote terakhir dari rasio bobot/stake. """V13: perkiraan waktu revote terakhir dari rasio bobot/stake.
Vexanium: last_vote_weight = staked_raw × 2^(tahun sejak 2000). Vexanium (sistem EOSIO): `last_vote_weight = staked_raw × 2^(pekan/52)`
Maka t = log2(last_vote_weight / staked_raw) → tanggal revote. — eksponen = `int64((now − 2000-01-01) / (86400×7)) / 52.0`, jadi bobot
dikuantisasi ke PEKAN UTUH sejak epoch 2000 (stake2vote). Maka
`t_pekan = round(52 × log2(last_vote_weight / staked_raw))` → tanggal
revote = epoch + t_pekan pekan (kelipatan 7 hari, 00:00:00).
Kembalikan None bila tak bisa dihitung (bobot/stake nol atau tak valid). Kembalikan None bila tak bisa dihitung (bobot/stake nol atau tak valid).
""" """
staked_raw = staked_vex * VEX_SCALE staked_raw = staked_vex * VEX_SCALE
@@ -46,8 +55,8 @@ def derive_last_vote(weight, staked_vex):
return None return None
if ratio <= 0: if ratio <= 0:
return None return None
t_years = math.log(ratio) / math.log(2) t_weeks = round(52 * math.log(ratio) / math.log(2))
return (WEIGHT_EPOCH + timedelta(days=t_years * 365.25)).isoformat(timespec='seconds') return (WEIGHT_EPOCH + timedelta(weeks=t_weeks)).isoformat(timespec='seconds')
def fetch_with_retry(payload, max_retries=3, timeout=30): def fetch_with_retry(payload, max_retries=3, timeout=30):
@@ -105,21 +114,15 @@ def scan_voters():
return rows_all return rows_all
def _is_stale(last_vote, now):
"""V13: apakah revote terakhir sudah lewat ambang basi (VEX_STALE_DAYS)."""
try:
vote_at = datetime.fromisoformat(last_vote)
except (TypeError, ValueError):
return True
return (now - vote_at) > timedelta(days=VEX_STALE_DAYS)
def normalize(row, now=None): def normalize(row, now=None):
"""V4/V5/V6/V13: saring ke baris yang relevan, bidang dinormalisasi. """V4/V5/V6/V13: saring ke baris yang relevan, bidang dinormalisasi.
V13: pemilih dengan last_vote tak diketahui atau basi dibuang di sini — V40: pemilih dengan last_vote basi TIDAK lagi dibuang di sini — semua umur
hanya pemilih segar yang berakhir di DB. Estimasi last_vote yang melewati vote disimpan (tampil/bayar menghitung umur saat query). Yang tetap dibuang:
`now` (unstake tanpa revote → rasio bobot/stake membesar) diklamp ke `now`. sentinel "0", bukan BP target, stake < minimum, dan last_vote yang tak bisa
diverifikasi (bobot nol/kosong → tanggal tak diketahui, tak bisa diklasifikasi).
Estimasi last_vote yang melewati `now` (unstake tanpa revote → rasio
bobot/stake membesar) diklamp ke `now`.
""" """
if now is None: if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None) now = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -134,7 +137,7 @@ def normalize(row, now=None):
last_vote = derive_last_vote(row.get('last_vote_weight', ''), staked_vex) last_vote = derive_last_vote(row.get('last_vote_weight', ''), staked_vex)
if last_vote is not None and last_vote > now.isoformat(timespec='seconds'): if last_vote is not None and last_vote > now.isoformat(timespec='seconds'):
last_vote = now.isoformat(timespec='seconds') # V13: heuristik ⊥ boleh di masa depan last_vote = now.isoformat(timespec='seconds') # V13: heuristik ⊥ boleh di masa depan
if last_vote is None or _is_stale(last_vote, now): # V13: basi ⊥ disimpan if last_vote is None: # V13: tanggal tak diketahui → tak dapat diklasifikasi
return None return None
return { return {
'owner': owner, 'owner': owner,
@@ -145,33 +148,18 @@ def normalize(row, now=None):
def persist(rows): def persist(rows):
"""V7: ganti snapshot — DROP+INSERT tiap skan, ⊥ history/append. """V7: ganti snapshot — tiap skan ⊥ history/append.
WAL: journal_mode=WAL → pembaca (dashboard multi-worker) tak terblokir Delegasi ke `db.replace_snapshot` (backend sqlite/mysql):
saat DROP+INSERT harian. Mode ini persisten setelah diset. sqlite DROP+CREATE dalam WAL → pembaca (dashboard multi-worker) tak
terblokir; mysql DELETE+INSERT dalam satu transaksi (MVCC snapshot).
Lalu `db.record_first_seen` (V41): catat kemunculan pertama tiap owner
(idempoten) utk membedakan pemilih BARU vs REVOTE di dashboard.
""" """
scanned_at = datetime.now().isoformat(timespec='seconds') scanned_at = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(
conn = sqlite3.connect(DB_PATH) timespec='seconds')
try: db.replace_snapshot(rows, scanned_at)
conn.execute('PRAGMA journal_mode=WAL') db.record_first_seen(rows, scanned_at)
conn.execute('DROP TABLE IF EXISTS voters') # snapshot bersih
conn.execute(
'CREATE TABLE voters (' # I.db
'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 (?, ?, ?, ?, ?)',
[(r['owner'], r['weight'], r['staked'], scanned_at, r.get('last_vote'))
for r in rows],
)
conn.commit()
finally:
conn.close()
return scanned_at return scanned_at
@@ -189,6 +177,47 @@ def summarize(rows, scanned_at):
f"{row['staked']:,.4f} VEX - Bobot: {row['weight']}") f"{row['staked']:,.4f} VEX - Bobot: {row['weight']}")
def _notify_warn(now):
"""V66: kirim notifikasi VOTE ULANG SEGERA ke komunitas.
Query pemilih dalam jendela `now − STALE < last_vote ≤ now − (STALE − WARN)`,
hitung urgensi berdasarkan sisa hari (1=⚠️ >2h, 2=🔴 ≤2h, 3=🚨 ≤1h),
filter yang sudah diberi tahu pada level ≥ urgensi saat ini, kirim
via Telegram, catat ke `voter_notify`. Bersihkan baris stale.
"""
stale_cutoff = (now - timedelta(days=VEX_STALE_DAYS)).isoformat(timespec='seconds')
warn_cutoff = (now - timedelta(days=VEX_STALE_DAYS - VEX_WARN_DAYS)).isoformat(
timespec='seconds')
rows = db.query(
'SELECT v.owner, v.staked, v.last_vote FROM voters v '
'WHERE v.last_vote > %s AND v.last_vote <= %s',
(stale_cutoff, warn_cutoff))
if not rows:
db.cleanup_notify((now - timedelta(days=VEX_EXPIRED_DAYS)).isoformat(
timespec='seconds'))
return
to_notify = []
for owner, staked, last_vote in rows:
vote_dt = datetime.fromisoformat(last_vote)
days_left = VEX_STALE_DAYS - (now - vote_dt).days
if days_left <= 1:
urgency = 3
elif days_left <= 2:
urgency = 2
else:
urgency = 1
prev = db.get_notify_urgency(owner)
if prev is not None and prev >= urgency:
continue
to_notify.append((owner, f'{staked:,.4f}', urgency))
if to_notify and telegram.notify_warn(to_notify):
for owner, _, urgency in to_notify:
db.record_notify(owner, urgency,
now.isoformat(timespec='seconds'))
db.cleanup_notify((now - timedelta(days=VEX_EXPIRED_DAYS)).isoformat(
timespec='seconds'))
def main(): def main():
"""Titik masuk CLI.""" """Titik masuk CLI."""
print(f'Mulai memindai voters untuk BP: "{TARGET_BP}"...') print(f'Mulai memindai voters untuk BP: "{TARGET_BP}"...')
@@ -200,6 +229,7 @@ def main():
if item is not None: if item is not None:
filtered.append(item) filtered.append(item)
scanned_at = persist(filtered) scanned_at = persist(filtered)
_notify_warn(now)
summarize(filtered, scanned_at) summarize(filtered, scanned_at)
return 0 return 0
+2
View File
@@ -2,3 +2,5 @@ requests>=2.31
flask>=3.0 flask>=3.0
python-dotenv>=1.0 python-dotenv>=1.0
gunicorn>=22.0 gunicorn>=22.0
PyMySQL>=1.1
pyntelope>=0.1.2
+83
View File
@@ -0,0 +1,83 @@
"""Jadwal skan berkala: jalankan get_voters.py pada jam SCAN_HOURS (waktu lokal).
Menunggu hingga batas jam berikutnya dalam daftar `SCAN_HOURS` (default 0,8,16)
lalu memanggil `get_voters.main()`. Loop tak pernah keluar — service dijaga
hidup oleh restart_policy swarm. Gagal skan dicatat lalu lanjut ke batas
berikutnya (tidak membunuh loop). `TZ` env menentukan jam lokal via
`datetime.now()` (libc); image Dockerfile.scan sudah menyertakan tzdata.
`SCAN_RUN_ON_START` (default 1): skan sekali saat container start setelah
basis data siap — penting agar tabel voters langsung ada (dashboard ⊥ 500
sebelum batas jam pertama).
"""
import os
import time
from datetime import datetime, timedelta
import db
import get_voters
def parse_hours(raw):
"""Daftar jam integer dari string CSV, diurutkan naik."""
hours = sorted({int(h) for h in raw.split(',') if h.strip()})
if not hours or not all(0 <= h <= 23 for h in hours):
raise ValueError(f'SCAN_HOURS tak valid: {raw!r}')
return hours
def next_boundary(now, hours):
"""datetime berikutnya pada jam-jam `hours` yang lebih besar dari `now`."""
for h in hours:
cand = now.replace(hour=h, minute=0, second=0, microsecond=0)
if cand > now:
return cand
# semua jam hari ini sudah lewat → besok jam pertama
nxt = now + timedelta(days=1)
return nxt.replace(hour=hours[0], minute=0, second=0, microsecond=0)
def _wait_db(timeout=120):
"""Tunggu basis data siap (mariadb baru boot) sebelum skan awal."""
start = time.monotonic()
while time.monotonic() - start < timeout:
try:
db.queryone('SELECT 1')
return
except Exception:
time.sleep(3)
raise RuntimeError('basis data tak siap setelah menunggu')
def _run_scan(label):
print(f'[{label}] memulai skan...', flush=True)
try:
rc = get_voters.main()
except Exception as exc:
print(f'[Error] Skan gagal: {exc}', flush=True)
return
if rc != 0:
print(f'[Peringatan] get_voters keluar dengan kode {rc}', flush=True)
def main():
hours = parse_hours(os.getenv('SCAN_HOURS', '0,8,16'))
run_on_start = os.getenv('SCAN_RUN_ON_START', '1').lower() not in ('0', 'false', 'no')
print(f'Jadwal skan: jam {", ".join(str(h) for h in hours)} (waktu lokal); '
f'skan-awal: {"ya" if run_on_start else "tidak"}')
if run_on_start:
_wait_db()
_run_scan('skan awal')
while True:
now = datetime.now()
target = next_boundary(now, hours)
wait = (target - now).total_seconds()
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
f'sampai {target.isoformat(timespec="seconds")}', flush=True)
time.sleep(max(wait, 0))
_run_scan('skan terjadwal')
if __name__ == '__main__':
main()
+38 -2
View File
@@ -14,8 +14,16 @@
var timer = null; var timer = null;
var originalRows = null; var originalRows = null;
var originalPager = null; var originalPager = null;
var originalBands = null;
var DEBOUNCE_MS = 200; var DEBOUNCE_MS = 200;
var GROUP_LABEL = {
baru: 'BARU',
revote: 'REVOTE',
valid: 'VALID',
kadaluarsa: 'KADALUARSA'
};
function escapeHtml(text) { function escapeHtml(text) {
var el = document.createElement('div'); var el = document.createElement('div');
el.textContent = text == null ? '' : String(text); el.textContent = text == null ? '' : String(text);
@@ -36,15 +44,34 @@
return String(value).slice(0, 10); return String(value).slice(0, 10);
} }
function hideBands(on) {
var bands = document.querySelectorAll('.band-list');
if (on && originalBands === null) {
originalBands = Array.prototype.map.call(bands, function (el) {
return el.style.display || '';
});
Array.prototype.forEach.call(bands, function (el) {
el.style.display = 'none';
});
} else if (!on && originalBands !== null) {
Array.prototype.forEach.call(bands, function (el, i) {
el.style.display = originalBands[i] || '';
});
originalBands = null;
}
}
function setSearchMode(on) { function setSearchMode(on) {
if (on && originalRows === null) { if (on && originalRows === null) {
originalRows = rows.innerHTML; originalRows = rows.innerHTML;
originalPager = pager.style.display || ''; originalPager = pager.style.display || '';
hideBands(true);
} else if (!on && originalRows !== null) { } else if (!on && originalRows !== null) {
rows.innerHTML = originalRows; rows.innerHTML = originalRows;
pager.style.display = originalPager; pager.style.display = originalPager;
originalRows = null; originalRows = null;
originalPager = null; originalPager = null;
hideBands(false);
if (result) { if (result) {
result.textContent = ''; result.textContent = '';
} }
@@ -56,10 +83,19 @@
rows.innerHTML = '<div class="empty">TAK ADA AKUN COCOK</div>'; rows.innerHTML = '<div class="empty">TAK ADA AKUN COCOK</div>';
} else { } else {
rows.innerHTML = data.results.map(function (r) { rows.innerHTML = data.results.map(function (r) {
return '<div class="list-row">' + var tag = '<span class="group-tag tag-' + r.group + '">' +
(GROUP_LABEL[r.group] || r.group) + '</span>';
var badge = r.expired ?
'<span class="expired-badge">VOTE ULANG</span>' : '';
var warn = r.expiring ?
'<span class="warn-badge">VOTE ULANG SEGERA</span>' : '';
return '<div class="list-row' + (r.expired ? ' expired' : '') + '">' +
'<span class="rank">' + r.rank + '</span>' + '<span class="rank">' + r.rank + '</span>' +
'<span class="owner">' + escapeHtml(r.owner) + '</span>' + '<span class="owner"><a class="owner-link" href="https://vexascan.com/account/' +
encodeURIComponent(r.owner) + '" target="_blank" rel="noopener">' +
escapeHtml(r.owner) + '</a>' + tag + warn + badge + '</span>' +
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' + '<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' +
'<span class="reward" data-label="TOTAL REWARD (VEX)">' + vex(r.total_reward) + '</span>' +
'<span class="vote-date" data-label="VOTE TERAKHIR">' + shortdate(r.last_vote) + '</span>' + '<span class="vote-date" data-label="VOTE TERAKHIR">' + shortdate(r.last_vote) + '</span>' +
'</div>'; '</div>';
}).join(''); }).join('');
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
<rect width="200" height="200" fill="#000000"/>
<polygon points="100,30 170,70 100,110 30,70" fill="#1f1f1f" stroke="#ffffff" stroke-width="2"/>
<polygon points="30,70 100,110 100,170 30,130" fill="#141414" stroke="#ffffff" stroke-width="2"/>
<polygon points="100,110 170,70 170,130 100,170" fill="#0d0d0d" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+223 -12
View File
@@ -13,6 +13,9 @@
--surface-card: #141414; --surface-card: #141414;
--on-dark: #ffffff; --on-dark: #ffffff;
--link: #c3d9f3; --link: #c3d9f3;
--amber: #d4a017;
--amber-dim: #8a6d1a;
--mint: #7fd4b0;
/* typography — DESIGN.md substitutes: Saira Condensed (Display), /* typography — DESIGN.md substitutes: Saira Condensed (Display),
EB Garamond (Text Regular), JetBrains Mono (Monospace) */ EB Garamond (Text Regular), JetBrains Mono (Monospace) */
@@ -61,6 +64,17 @@ body {
text-transform: uppercase; text-transform: uppercase;
color: var(--on-dark); color: var(--on-dark);
} }
.nav-link {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--on-dark);
text-decoration: none;
border-bottom: 1px solid var(--hairline);
padding-bottom: 2px;
}
.nav-link:hover { color: var(--accent); }
.wordmark { .wordmark {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 14px; font-size: 14px;
@@ -95,14 +109,55 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* ————— news (jadwal distribusi berikutnya, V38) ————— */
.news {
margin: 0 var(--sp-lg) var(--sp-xl);
border: 1px solid var(--hairline-strong);
padding: var(--sp-lg);
display: flex;
flex-direction: column;
gap: var(--sp-xs);
}
.news-tag {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--link);
}
.news-title {
font-family: var(--font-display);
font-size: 24px;
font-weight: 400;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--on-dark);
}
.news-date {
font-family: var(--font-display);
font-size: 32px;
font-weight: 400;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--on-dark);
font-variant-numeric: tabular-nums;
}
.news .caption { margin-top: 0; }
/* ————— stats band ————— */ /* ————— stats band ————— */
.stats { .stats {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(3, 1fr);
border-top: 1px solid var(--hairline); border-top: 1px solid var(--hairline);
border-bottom: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline);
margin: 0 var(--sp-lg) var(--sp-xl); margin: 0 var(--sp-lg) var(--sp-xl);
} }
.stats-note {
margin: calc(-1 * var(--sp-md)) var(--sp-lg) var(--sp-xl);
color: var(--muted-soft);
font-size: 10px;
letter-spacing: 1.5px;
}
.spec-cell { .spec-cell {
padding: var(--sp-lg) var(--sp-md); padding: var(--sp-lg) var(--sp-md);
display: flex; display: flex;
@@ -170,12 +225,28 @@ button.pill {
.list-header, .list-header,
.list-row { .list-row {
display: grid; display: grid;
grid-template-columns: 72px 1fr 200px 170px; grid-template-columns: 72px repeat(3, 1fr);
gap: var(--sp-lg); gap: var(--sp-lg);
align-items: baseline; align-items: baseline;
padding: var(--sp-lg) var(--sp-sm); padding: var(--sp-lg) var(--sp-sm);
border-bottom: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline);
} }
.run-list .list-header,
.run-list .list-row {
grid-template-columns: 64px 150px 180px 110px 180px 1fr;
}
.pay-list .list-header,
.pay-list .list-row {
grid-template-columns: 200px 200px 120px 1fr;
}
.voter-list .list-header,
.voter-list .list-row {
grid-template-columns: 72px repeat(4, 1fr);
}
.band-list .list-header,
.band-list .list-row {
grid-template-columns: 72px repeat(3, 1fr);
}
.list-header { .list-header {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
@@ -185,6 +256,7 @@ button.pill {
padding-top: var(--sp-md); padding-top: var(--sp-md);
} }
.list-row:nth-child(odd) { background: var(--surface-soft); } .list-row:nth-child(odd) { background: var(--surface-soft); }
.list-row > * { min-width: 0; }
.rank, .rank,
.vote-date { .vote-date {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -197,16 +269,136 @@ button.pill {
font-size: 20px; font-size: 20px;
letter-spacing: 1px; letter-spacing: 1px;
color: var(--on-dark); color: var(--on-dark);
overflow-wrap: anywhere; overflow-wrap: break-word;
} }
.stake { .owner-link {
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
}
.owner-link:hover { color: var(--link); }
.maturity {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 1.5px;
text-align: center;
color: var(--muted);
}
.promo {
display: inline-flex;
align-items: center;
gap: var(--sp-md);
text-decoration: none;
color: var(--on-dark);
}
.promo-logo {
width: 56px;
height: 56px;
border-radius: 12px;
border: 1px solid var(--hairline-strong);
}
.promo-cta {
font-family: var(--font-mono);
font-size: 14px;
letter-spacing: 2.5px;
text-transform: uppercase;
color: var(--on-dark);
}
.promo:hover .promo-cta { color: var(--link); }
/* ————— band BARU / KADALUARSA (V40) ————— */
.band-list { margin-top: var(--sp-xl); }
.section-caption {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--muted);
padding: var(--sp-md) var(--sp-sm) 0;
}
.section-legend {
margin-top: var(--sp-xs);
padding: 0 var(--sp-sm);
color: var(--muted-soft);
}
.section-legend { color: var(--amber-dim); }
.section-legend.mint-legend { color: var(--mint); }
.section-legend.warn-legend { color: var(--amber-dim); }
.list-row.expired {
border-left: 2px solid var(--amber);
background: rgba(212, 160, 23, 0.12);
}
.list-row.expired:nth-child(odd) { background: rgba(212, 160, 23, 0.12); }
.list-row.expired .owner,
.list-row.expired .stake,
.list-row.expired .reward { color: var(--amber); }
.expired-badge {
display: inline-block;
margin-left: var(--sp-sm);
padding: 2px 8px;
border: 1px solid var(--amber);
border-radius: 9999px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 2px;
color: var(--amber);
vertical-align: middle;
}
.warn-badge {
display: inline-block;
margin-left: var(--sp-sm);
padding: 2px 8px;
border: 1px dashed var(--amber-dim);
border-radius: 9999px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 2px;
color: var(--amber-dim);
vertical-align: middle;
}
.group-tag {
display: inline-block;
margin-left: var(--sp-sm);
padding: 2px 8px;
border-radius: 9999px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 2px;
color: var(--muted);
border: 1px solid var(--hairline-strong);
vertical-align: middle;
}
.tag-baru { color: var(--link); border-color: var(--link); }
.tag-valid { color: var(--muted); }
.tag-revote { color: var(--mint); border-color: var(--mint); }
.tag-kadaluarsa { color: var(--amber); border-color: var(--amber); }
.stake,
.reward {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 20px; font-size: 20px;
letter-spacing: 1px; letter-spacing: 1px;
text-align: right; text-align: center;
color: var(--on-dark); color: var(--on-dark);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.list-row .vote-date { text-align: center; }
.list-header span:nth-child(n+3) { text-align: center; }
.pay-list .list-header span:nth-child(2),
.pay-list .list-header span:nth-child(3) { text-align: center; }
.pay-list .list-header span:nth-child(4) { text-align: left; }
.txid {
font-size: 12px;
letter-spacing: 1px;
color: var(--muted);
overflow-wrap: anywhere;
}
.txid a {
color: var(--muted);
text-decoration: underline;
}
.txid a:hover { color: var(--on-dark); }
.empty { .empty {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 12px; font-size: 12px;
@@ -266,7 +458,13 @@ button.pill {
/* ————— tablet (768–1024px) ————— */ /* ————— tablet (768–1024px) ————— */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.list-header, .list-header,
.list-row { grid-template-columns: 56px 1fr 160px 150px; gap: var(--sp-md); } .list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
.voter-list .list-header,
.voter-list .list-row { grid-template-columns: 56px 1fr 160px 1fr 1fr; }
.band-list .list-header,
.band-list .list-row { grid-template-columns: 56px 1fr 160px 1fr; }
.pay-list .list-header,
.pay-list .list-row { grid-template-columns: 180px 180px 110px 1fr; }
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); } .spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
.stats { grid-template-columns: repeat(2, 1fr); } .stats { grid-template-columns: repeat(2, 1fr); }
.spec-cell:nth-child(3) { border-left: none; } .spec-cell:nth-child(3) { border-left: none; }
@@ -275,6 +473,8 @@ button.pill {
/* ————— mobile (<768px): list → kartu berlabel ————— */ /* ————— mobile (<768px): list → kartu berlabel ————— */
@media (max-width: 768px) { @media (max-width: 768px) {
.news { margin-left: var(--sp-md); margin-right: var(--sp-md); }
.news-date { font-size: 24px; }
.stats { grid-template-columns: 1fr; } .stats { grid-template-columns: 1fr; }
.spec-cell + .spec-cell { border-left: none; border-top: 1px solid var(--hairline); } .spec-cell + .spec-cell { border-left: none; border-top: 1px solid var(--hairline); }
@@ -282,19 +482,30 @@ button.pill {
.search .pill { justify-content: center; width: 100%; } .search .pill { justify-content: center; width: 100%; }
.list-header { display: none; } .list-header { display: none; }
.list-row { .voter-list .list-row,
grid-template-columns: 48px 1fr; .band-list .list-row {
grid-template-columns: 48px minmax(0, 1fr);
gap: var(--sp-xxs) var(--sp-sm); gap: var(--sp-xxs) var(--sp-sm);
padding: var(--sp-lg) var(--sp-sm); padding: var(--sp-lg) var(--sp-sm);
} }
.list-row .rank { grid-row: 1 / span 3; grid-column: 1; align-self: start; padding-top: 6px; } .list-row .rank { grid-row: 1 / span 3; grid-column: 1; align-self: start; padding-top: 6px; }
.list-row .owner { grid-column: 2; font-size: 18px; } .list-row .owner { grid-column: 2; font-size: 18px; }
.list-row > * { min-width: 0; }
.list-row .owner { overflow-wrap: break-word; }
.list-row .stake, .list-row .stake,
.list-row .vote-date { grid-column: 2; } .list-row .vote-date,
.list-row .stake { text-align: left; } .list-row .txid,
.list-row .stake { overflow-wrap: anywhere; } .list-row .reward { grid-column: 2; }
.list-row .stake,
.list-row .reward { text-align: left; }
.list-row .vote-date { text-align: left; }
.list-row .txid { font-size: 12px; }
.list-row .stake,
.list-row .reward { overflow-wrap: anywhere; }
.list-row .stake::before, .list-row .stake::before,
.list-row .vote-date::before { .list-row .vote-date::before,
.list-row .txid::before,
.list-row .reward::before {
content: attr(data-label); content: attr(data-label);
display: block; display: block;
font-family: var(--font-mono); font-family: var(--font-mono);
+174
View File
@@ -0,0 +1,174 @@
"""Kirim notifikasi status distribusi + klaim + warn via Telegram Bot API (best-effort).
Dua kanal (V30/V36): KOMUNITAS (`TELEGRAM_COMMUNITY_CHAT_IDS`) hanya brief
mulai/selesai distribusi + VOTE ULANG SEGERA; INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`)
detail distribusi + semua kegagalan + klaim reward (⊥ komunitas). Token tunggal
`TELEGRAM_BOT_TOKEN`. Tanpa token/daftar → kanal itu no-op senyap (⊥ ganggu
run). Gagal kirim hanya dicatat — ⊥ pernah crash distribusi/klaim.
V66: semua notifikasi kini punya emoticon prefix.
"""
import requests
from config import (TELEGRAM_BOT_TOKEN, TELEGRAM_COMMUNITY_CHAT_IDS,
TELEGRAM_INTERNAL_CHAT_IDS)
# Batas panjang pesan Telegram per kiriman (karakter)
_MAX_MSG_LEN = 4096
def send_text(text, chat_ids=None):
"""Kirim `text` ke daftar chat id. `chat_ids=None` → kanal komunitas.
Best-effort: tanpa token/daftar → no-op; gagal → log saja.
Return True bila berhasil ke SEMUA chat id (setidaknya satu terkirim),
False bila no-op (token/daftar kosong) atau ada yang gagal.
"""
if chat_ids is None:
chat_ids = TELEGRAM_COMMUNITY_CHAT_IDS
if not TELEGRAM_BOT_TOKEN or not chat_ids:
return False
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
ok = True
for chat_id in chat_ids:
try:
resp = requests.post(url, json={'chat_id': chat_id, 'text': text},
timeout=10)
resp.raise_for_status()
except requests.RequestException as exc:
print(f'[Peringatan] Gagal kirim Telegram ke {chat_id}: {exc}',
flush=True)
ok = False
return ok
def send_internal(text):
"""Kirim ke kanal internal (detail + kegagalan + klaim)."""
send_text(text, TELEGRAM_INTERNAL_CHAT_IDS)
def notify_start(run_date, n_voters, total_share, balance):
"""Run nyata dimulai: komunitas brief + internal detail."""
send_text(f'🚀 DISTRIBUSI MULAI\n📅 Tanggal: {run_date}')
send_internal(
f'🚀 DISTRIBUSI MULAI\n'
f'📅 Tanggal: {run_date}\n'
f'👥 Pemilih: {n_voters}\n'
f'💎 Total share: {total_share} VEX\n'
f'💰 Saldo: {balance} VEX'
)
def notify_finish(status, sent, total, total_share, failed, run_date):
"""Run selesai: komunitas brief + internal detail (ok | partial)."""
if status == 'ok':
emoji = '✅'
label = 'Berhasil'
else:
emoji = '⚠️'
label = 'Sebagian'
send_text(
f'{emoji} DISTRIBUSI SELESAI\n'
f'📅 Tanggal: {run_date}\n'
f'📊 Status: {label}'
)
send_internal(
f'{emoji} DISTRIBUSI SELESAI\n'
f'📅 Tanggal: {run_date}\n'
f'📊 Status: {label}\n'
f'👥 Terkirim: {sent}/{total} pemilih\n'
f'💎 Total share: {total_share} VEX\n'
f'❌ Gagal: {len(failed)}'
)
def notify_failure(exc):
"""Run gagal total — kanal internal saja (ops detail)."""
send_internal(
f'🔴 DISTRIBUSI GAGAL\n'
f'❗ Error: {exc}'
)
def notify_claim(reward, fee, wallet):
"""Klaim reward BP sukses — kanal internal saja, ⊥ komunitas (V36)."""
send_internal(
f'💰 KLAIM REWARD SUKSES\n'
f'💎 Reward: {reward} VEX\n'
f'💸 Fee BP: {fee} VEX → {wallet}'
)
def notify_claim_unmeasured(txid_label):
"""Klaim mendarat tapi reward tak terukur (V45) — internal, ⊥ "sukses 0"."""
send_internal(
f'🔍 KLAIM MENDARAT · REWARD TAK TERUKUR\n'
f'⚠️ Periksa reward & fee BP manual (txid {txid_label})'
)
def notify_claim_failure(exc):
"""Kegagalan siklus klaim — kanal internal saja (V36)."""
send_internal(
f'🔴 KLAIM GAGAL\n'
f'❗ Error: {exc}'
)
# ——————————————————————————————————————————————————————————————————————
# V66: Notifikasi VOTE ULANG SEGERA — komunitas.
# voters = list of (owner, staked, urgency) di mana urgency 1=⚠️ 2=🔴 3=🚨.
# ——————————————————————————————————————————————————————————————————————
_URGENCY_EMOJI = {1: '⚠️', 2: '🔴', 3: '🚨'}
def notify_warn(voters):
"""VOTE ULANG SEGERA — komunitas, best-effort.
Kirim daftar akun yang vote-nya akan kedaluwarsa, diurutkan berdasarkan
urgensi (paling mendesak di atas). Maks 50 entri; sisanya arahkan ke
dashboard. Return True bila SEMUA pesan terkirim, False bila no-op/gagal.
"""
if not voters:
return True
# Urutkan: urgensi tinggi dulu (3 > 2 > 1)
sorted_voters = sorted(voters, key=lambda v: v[2], reverse=True)
truncated = len(sorted_voters) > 50
display = sorted_voters[:50]
# Emoji dari baris paling mendesak
top_emoji = _URGENCY_EMOJI.get(display[0][2], '⚠️')
lines = [f'{top_emoji} VOTE ULANG SEGERA', '',
f'{len(sorted_voters)} akun akan kedaluwarsa:', '']
for owner, staked, urgency in display:
emoji = _URGENCY_EMOJI.get(urgency, '⚠️')
lines.append(f'• {emoji} {owner} — {staked} VEX')
lines.append(f' ↳ https://mining.databisnis.id?q={owner}')
if truncated:
lines.append('')
lines.append('📋 Cek https://mining.databisnis.id untuk daftar lengkap')
lines.append('')
lines.append('Vote ulang sekarang agar tidak kehilangan hak bagian!')
text = '\n'.join(lines)
sent = True
# Telegram batas 4096 karakter — split bila perlu
if len(text) <= _MAX_MSG_LEN:
sent = send_text(text)
else:
# Kirim header dulu, lalu sisa per-chunk
header = f'{top_emoji} VOTE ULANG SEGERA\n\n{len(sorted_voters)} akun akan kedaluwarsa:\n'
sent = send_text(header) and sent
chunk = []
chunk_len = 0
for owner, staked, urgency in display:
emoji = _URGENCY_EMOJI.get(urgency, '⚠️')
line = f'• {emoji} {owner} — {staked} VEX\n ↳ https://mining.databisnis.id?q={owner}\n'
if chunk_len + len(line) > _MAX_MSG_LEN - 100:
sent = send_text('\n'.join(chunk)) and sent
chunk = []
chunk_len = 0
chunk.append(line.rstrip())
chunk_len += len(line)
if chunk:
sent = send_text('\n'.join(chunk)) and sent
sent = send_text('Vote ulang sekarang agar tidak kehilangan hak bagian!') and sent
return sent
+95
View File
@@ -0,0 +1,95 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RIWAYAT DISTRIBUSI — {{ bp }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16.png') }}">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="apple-touch-icon" sizes="192x192" href="{{ url_for('static', filename='icon-192.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=10) }}">
</head>
<body>
<header class="top-nav">
<span class="nav-label">RIWAYAT</span>
<span class="wordmark">DATABISNISID</span>
<span class="nav-label"><a class="nav-link" href="{{ url_for('index') }}">DAFTAR</a></span>
</header>
<main class="page">
<section class="hero">
<p class="caption">DISTRIBUSI PROFIT-SHARE · VEX.TOKEN · PRO-RATA STAKE</p>
<h1 class="display-xl">RIWAYAT DISTRIBUSI</h1>
<p class="caption">BACA-ONLY · DISAJIKAN DARI PENYIMPANAN HARIAN</p>
</section>
<section class="stats">
<div class="spec-cell">
<span class="cell-value">{{ runs|length }}</span>
<span class="cell-label">TOTAL RUN</span>
</div>
<div class="spec-cell">
<span class="cell-value">{{ payments|length }}</span>
<span class="cell-label">TOTAL TRANSFER</span>
</div>
</section>
<section class="spec-list run-list">
<div class="list-header">
<span>RUN</span>
<span>TANGGAL</span>
<span>SALDO AWAL</span>
<span>PEMILIH</span>
<span>TERKIRIM</span>
<span>STATUS</span>
</div>
<div class="list-rows">
{% for run in runs %}
<div class="list-row">
<span class="rank">{{ run[0] }}</span>
<span class="owner">{{ run[1] }}</span>
<span class="stake" data-label="SALDO AWAL">{{ run[2]|vex }}</span>
<span class="vote-date" data-label="PEMILIH">{{ run[3] }}</span>
<span class="stake" data-label="TERKIRIM">{{ run[5]|vex }}</span>
<span class="vote-date" data-label="STATUS">{{ 'OK' if run[6] == 'ok' else 'SEBAGIAN' }}</span>
</div>
{% else %}
<div class="empty">BELUM ADA DISTRIBUSI</div>
{% endfor %}
</div>
</section>
<p class="caption">TRANSFER PER PEMILIH{% if latest_date %} · {{ latest_date }}{% endif %}</p>
<section class="spec-list pay-list">
<div class="list-header">
<span>AKUN</span>
<span>JUMLAH</span>
<span>STATUS</span>
<span>TXID</span>
</div>
<div class="list-rows">
{% for pay in payments %}
<div class="list-row">
<span class="owner"><a class="owner-link" href="https://vexascan.com/account/{{ pay[2] }}" target="_blank" rel="noopener">{{ pay[2] }}</a></span>
<span class="stake" data-label="JUMLAH">{{ pay[3]|vex }}</span>
<span class="vote-date" data-label="STATUS">{{ pay[5]|upper }}</span>
<span class="owner txid" data-label="TXID">{% if pay[4] %}<a href="https://vexascan.com/transaction/{{ pay[4] }}" target="_blank" rel="noopener">{{ pay[4] }}</a>{% else %}—{% endif %}</span>
</div>
{% else %}
<div class="empty">BELUM ADA TRANSFER</div>
{% endfor %}
</div>
</section>
</main>
<footer class="footer">
<span>DATA TERVALIDASI DARI JARINGAN VEXANIUM · VEX.TOKEN · TRANSFER</span>
</footer>
</body>
</html>
+115 -22
View File
@@ -3,45 +3,66 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>DAFTAR PEMILIH — {{ bp }}</title> <title>SIMPLE MINING — {{ bp }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16.png') }}">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="apple-touch-icon" sizes="192x192" href="{{ url_for('static', filename='icon-192.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=20) }}">
</head> </head>
<body> <body>
<header class="top-nav"> <header class="top-nav">
<span class="nav-label">DAFTAR</span> <span class="nav-label">DAFTAR</span>
<span class="wordmark">DATABISNISID</span> <span class="wordmark">DATABISNISID</span>
<span class="nav-label">VEX</span> <span class="nav-label"><a class="nav-link" href="{{ url_for('history') }}">RIWAYAT</a></span>
</header> </header>
<main class="page"> <main class="page">
<section class="hero"> <section class="hero">
<p class="caption">PEMILIH BP · SUARA TUNGGAL · STAKE ≥ 1.000 VEX</p> <p class="caption">PEMILIH BP · SUARA TUNGGAL · STAKE ≥ 1.000 VEX</p>
<h1 class="display-xl">DAFTAR PEMILIH</h1> <h1 class="display-xl">SIMPLE MINING</h1>
<p class="caption">PEMINDAIAN TERAKHIR · <span class="ts">{{ scanned }}</span></p> <p class="caption">PEMINDAIAN TERAKHIR · <span class="ts">{{ scanned }}</span></p>
</section> </section>
<section class="news">
<span class="news-tag">BERITA</span>
<h2 class="news-title">DISTRIBUSI BERIKUTNYA</h2>
<p class="news-date">{{ next_schedule }}</p>
{% if schedule_recur %}
<p class="caption">{{ schedule_recur|safe }}</p>
{% endif %}
</section>
<section class="news">
<span class="news-tag">BY DATABISNISID</span>
<h2 class="news-title">VEXWALLET · ANDROID</h2>
<a class="promo" href="https://play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet&hl=id" target="_blank" rel="noopener">
<img class="promo-logo" src="{{ url_for('static', filename='idrs.png') }}" alt="IDRS">
<span class="promo-cta">UNDUH DI PLAY STORE</span>
</a>
</section>
<section class="stats"> <section class="stats">
<div class="spec-cell">
<span class="cell-value">{{ liquid_vex|vex if liquid_vex is not none else '—' }}</span>
<span class="cell-label">SALDO LIQUID</span>
</div>
<div class="spec-cell"> <div class="spec-cell">
<span class="cell-value">{{ total_voters }}</span> <span class="cell-value">{{ total_voters }}</span>
<span class="cell-label">TOTAL PEMILIH</span> <span class="cell-label">TOTAL PEMILIH</span>
</div> </div>
<div class="spec-cell">
<span class="cell-value">{{ total_staked|vex }}</span>
<span class="cell-label">TOTAL VEX</span>
</div>
<div class="spec-cell"> <div class="spec-cell">
<span class="cell-value">{{ top_staked|vex }}</span> <span class="cell-value">{{ top_staked|vex }}</span>
<span class="cell-label">STAKE TERTINGGI</span> <span class="cell-label">STAKE TERTINGGI</span>
</div> </div>
<div class="spec-cell">
<span class="cell-value">{{ liquid_vex|vex if liquid_vex is not none else '—' }}</span>
<span class="cell-label">SALDO LIQUID</span>
</div>
</section> </section>
<p class="caption stats-note">DAFTAR INI = AKUN SUARA TUNGGAL KE {{ bp }} · STAKE ≥ 1.000 VEX</p>
<form class="search" method="get" action="{{ url_for('index') }}"> <form class="search" method="get" action="{{ url_for('index') }}">
<input class="search-input" type="search" name="q" id="search-input" <input class="search-input" type="search" name="q" id="search-input"
value="{{ q }}" placeholder="CARI AKUN..." autocomplete="off"> value="{{ q }}" placeholder="CARI AKUN..." autocomplete="off">
@@ -55,29 +76,100 @@
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %} {% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
</p> </p>
<section class="spec-list" id="spec-list"> {% if q %}
<section class="spec-list voter-list" id="spec-list">
<div class="list-header"> <div class="list-header">
<span>RANK</span> <span>RANK</span>
<span>AKUN</span> <span>AKUN</span>
<span>STAKE (VEX)</span> <span>STAKE (VEX)</span>
<span>TOTAL REWARD (VEX)</span>
<span>VOTE TERAKHIR</span> <span>VOTE TERAKHIR</span>
</div> </div>
<div class="list-rows" id="rows"> <div class="list-rows" id="rows">
{% for row in rows %} {% for r in search_rows %}
<div class="list-row"> <div class="list-row{% if r.expired %} expired{% endif %}">
<span class="rank">{{ row[4] }}</span> <span class="rank">{{ r.rank }}</span>
<span class="owner">{{ row[0] }}</span> <span class="owner"><a class="owner-link" href="https://vexascan.com/account/{{ r.owner }}" target="_blank" rel="noopener">{{ r.owner }}</a> <span class="group-tag tag-{{ r.group }}">{{ {'baru': 'BARU', 'revote': 'REVOTE', 'valid': 'VALID', 'kadaluarsa': 'KADALUARSA'}[r.group] }}</span>{% if r.expiring %}<span class="warn-badge">VOTE ULANG SEGERA</span>{% endif %}{% if r.expired %}<span class="expired-badge">VOTE ULANG</span>{% endif %}</span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span> <span class="stake" data-label="STAKE (VEX)">{{ r.staked|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[5]|shortdate }}</span> <span class="reward" data-label="TOTAL REWARD (VEX)">{{ r.total_reward|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ r.last_vote|shortdate }}</span>
</div> </div>
{% else %} {% else %}
<div class="empty"> <div class="empty">TAK ADA AKUN COCOK — "{{ q }}"</div>
{% if q %}TAK ADA AKUN COCOK — "{{ q }}"{% else %}BELUM ADA DATA — JALANKAN get_voters.py{% endif %}
</div>
{% endfor %} {% endfor %}
</div> </div>
</section> </section>
{% else %}
{% if new_rows %}
<section class="spec-list band-list news-list">
<p class="caption section-caption">PEMILIH BARU · BELUM MATANG (0-3 HARI) · {{ new_count }}</p>
<div class="list-header">
<span>RANK</span>
<span>AKUN</span>
<span>STAKE (VEX)</span>
<span>MATURITY PERIOD</span>
</div>
<div class="list-rows">
{% for row in new_rows %}
<div class="list-row">
<span class="rank">{{ loop.index }}</span>
<span class="owner"><a class="owner-link" href="https://vexascan.com/account/{{ row[0] }}" target="_blank" rel="noopener">{{ row[0] }}</a> <span class="group-tag tag-baru">BARU</span></span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="maturity" data-label="MATURITY PERIOD">{{ row[4]|countdown }}</span>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if revote_count %}
<p class="caption section-legend mint-legend">TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG · {{ revote_count }} AKUN</p>
{% endif %}
<section class="spec-list voter-list" id="spec-list">
{% if expiring_count %}
<p class="caption section-legend warn-legend">TAG AMBER = KADALUARSA DALAM {{ warn_days }} HARI — VOTE ULANG DATABISNISID · {{ expiring_count }} PEMILIH</p>
{% endif %}
{% if expired_rows %}
<p class="caption section-legend">BARIS AMBER = VOTE LEWAT {{ stale_days }} HARI — VOTE ULANG DATABISNISID UNTUK TETAP MENERIMA PROFIT SHARE · {{ expired_count }} SUARA KADALUARSA</p>
{% endif %}
<div class="list-header">
<span>RANK</span>
<span>AKUN</span>
<span>STAKE (VEX)</span>
<span>TOTAL REWARD (VEX)</span>
<span>VOTE TERAKHIR</span>
</div>
<div class="list-rows" id="rows">
{% for row in expired_rows %}
<div class="list-row expired">
<span class="rank">{{ loop.index }}</span>
<span class="owner"><a class="owner-link" href="https://vexascan.com/account/{{ row[0] }}" target="_blank" rel="noopener">{{ row[0] }}</a> <span class="expired-badge">VOTE ULANG</span></span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[3]|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[2]|shortdate }}</span>
</div>
{% endfor %}
{% for row in rows %}
<div class="list-row">
<span class="rank">{{ row[4] + expired_count }}</span>
<span class="owner"><a class="owner-link" href="https://vexascan.com/account/{{ row[0] }}" target="_blank" rel="noopener">{{ row[0] }}</a>{% if row[8] %} <span class="group-tag tag-revote">REVOTE</span>{% endif %}{% if row[9] %} <span class="warn-badge">VOTE ULANG SEGERA</span>{% endif %}</span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[6]|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[5]|shortdate }}</span>
</div>
{% else %}
{% if not expired_rows %}
<div class="empty">BELUM ADA PEMILIH YANG VALID</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
{% if not q %}
<nav class="pager" id="pager" aria-label="Navigasi halaman"> <nav class="pager" id="pager" aria-label="Navigasi halaman">
{% set qp = q if q else None %} {% set qp = q if q else None %}
<a class="pill {% if page <= 1 %}pill-disabled{% endif %}" <a class="pill {% if page <= 1 %}pill-disabled{% endif %}"
@@ -86,6 +178,7 @@
<a class="pill {% if page >= total_pages %}pill-disabled{% endif %}" <a class="pill {% if page >= total_pages %}pill-disabled{% endif %}"
href="{{ url_for('index', page=page+1, q=qp) }}">BERIKUTNYA →</a> href="{{ url_for('index', page=page+1, q=qp) }}">BERIKUTNYA →</a>
</nav> </nav>
{% endif %}
</main> </main>
<footer class="footer"> <footer class="footer">
+652
View File
@@ -0,0 +1,652 @@
"""Uji verifikasi klaim reward BP (oracle §V32..V34).
`./venv/bin/python test_claim.py` harus exit 0. Memakai DB sementara + mock
jaringan/pyntelope — ⊥ klaim nyata. Verifikasi: jendela klaim (V32), parse
respons `get_table_rows` bentuk asli `{rows:[...]}` + recovery window lewat,
math fee (V33), siklus penuh klaim→fee, retry fee, resume, reward 0 →
skipped, klaim yang mendarat walau timeout (V34).
"""
import os
import sqlite3
import sys
import tempfile
from datetime import datetime, timedelta
from decimal import Decimal
import db
import claim
import telegram
import distribute as dist
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, exc=None):
self._txid = txid
self._exc = exc
def id(self):
return self._txid
def send(self):
if self._exc is not None:
raise self._exc
# V61: bentuk sukses nodeos asli (whitelist `_send_rejected` di
# `_send_fee` butuh `transaction_id` + receipt `executed`).
return {'transaction_id': self._txid,
'processed': {'receipt': {'status': 'executed'}}}
def fresh_db(tag):
"""DB sqlite sementara baru per bagian uji."""
tmp = tempfile.mkdtemp()
db.DB_PATH = os.path.join(tmp, f'claim-{tag}.db')
return db.DB_PATH
def last_claim_row():
row = db.queryone(
'SELECT claim_id, run_date, claim_txid, reward, fee_amount, '
' fee_status, fee_txid FROM claim_runs ORDER BY claim_id DESC '
'LIMIT 1')
return row
def fake_post(data):
return type('R', (), {'raise_for_status': lambda self: None,
'json': lambda self: data})()
def main():
# Matikan sleep nyata (settle/poll) + Telegram selama uji.
claim.time.sleep = lambda s: None
_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
_req_get = dist.requests.get
telegram.TELEGRAM_BOT_TOKEN = ''
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = []
telegram.TELEGRAM_INTERNAL_CHAT_IDS = []
# Default: pengukuran reward via selisih saldo (tx-based diuji terpisah).
_orig_reward_from_tx = claim.reward_from_tx
claim.reward_from_tx = lambda txid: None
try:
# ————— V32: parse_claim_time — format chain —————
check('V32 parse format %f',
claim.parse_claim_time('2026-08-05T07:01:44.500')
== datetime(2026, 8, 5, 7, 1, 44, 500000))
check('V32 parse format detik',
claim.parse_claim_time('2026-08-05T07:01:44')
== datetime(2026, 8, 5, 7, 1, 44))
check('V32 parse kosong → None', claim.parse_claim_time(None) is None)
check('V32 parse sampah → None', claim.parse_claim_time('x') is None)
# ————— V32: fetch_producer_last_claim — bentuk asli get_table_rows —————
claim._post_json = lambda url, payload: {
'rows': [{'last_claim_time': '2026-08-05T07:01:44.500'}],
'more': False, 'next_key': ''}
check('V32 parse respons asli {rows:[...]} → waktu',
claim.fetch_producer_last_claim() == '2026-08-05T07:01:44.500')
claim._post_json = lambda url, payload: {'rows': [], 'more': False,
'next_key': ''}
check('V32 rows kosong → None',
claim.fetch_producer_last_claim() is None)
claim._post_json = lambda url, payload: {}
check('V32 respons tak dikenal → None (⊥ crash)',
claim.fetch_producer_last_claim() is None)
# ————— V32: next_window = last + 24 jam; lewat → klaim segera —————
claim.fetch_producer_last_claim = lambda: '2026-08-05T07:01:44.500'
check('V32 jendela = last + 24 jam',
claim.next_window(datetime(2026, 8, 6, 5, 0, 0))
== datetime(2026, 8, 6, 7, 1, 44, 500000))
check('V32 window lewat (≥24 jam) → now (recovery, ⊥ deadlock)',
claim.next_window(datetime(2026, 8, 6, 12, 0, 0))
== datetime(2026, 8, 6, 12, 0, 0))
claim.fetch_producer_last_claim = lambda: None
try:
claim.next_window(datetime(2026, 8, 6, 12, 0, 0))
raise AssertionError('GAGAL: baca producer gagal harus raise')
except RuntimeError:
pass
check('V46 baca producer gagal → raise (⊥ clamp prematur)',
True)
# BP baru (row ada, last_claim_time epoch/dulu) → jendela lewat → now
claim.fetch_producer_last_claim = lambda: '2000-01-01T00:00:00.000'
check('V32 last epoch (BP baru) → now',
claim.next_window(datetime(2026, 8, 6, 12, 0, 0))
== datetime(2026, 8, 6, 12, 0, 0))
# ————— V49: window math UTC-naive (konsisten dgn last_claim_time chain) —————
now_utc = claim._utcnow()
check('V49 _utcnow UTC-naive (⊥ tzinfo lokal)',
now_utc.tzinfo is None)
# step dengan now jauh sebelum last+24h → sleep (⊥ poll prematur)
fresh_db('prewindow')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=12)
).strftime('%Y-%m-%dT%H:%M:%S')
check('V49 before window → sleep (⊥ poll prematur)',
claim.step() == 'sleep')
# ————— V32/V33: siklus penuh — klaim → catat → fee terkirim —————
fresh_db('cycle')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('100.0000'), Decimal('110.0004')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned('txclaim')
dist.verify_txid = lambda txid: True
r1 = claim.step()
check('V32 step klaim sukses → claimed', r1 == 'claimed')
row = last_claim_row()
check('V32 klaim tercatat (reward 10.0004, fee 1.0000 floor)',
row is not None and row[3] == 10.0004 and row[4] == 1.0
and row[2] == 'txclaim' and row[5] == 'pending')
memos = []
def fake_fee_sign(to, amount, memo):
memos.append((to, amount, memo))
return FakeSigned('txfee')
dist.build_signed_transfer = fake_fee_sign
r2 = claim.step()
check('V34 fee terkirim → fee_sent', r2 == 'fee_sent')
check('V33 fee ke wallet bpdbsjasprod', memos[-1][0] == 'bpdbsjasprod')
check('V33 memo BP FEE + tanggal',
memos[-1][2] == f'BP FEE {claim._utcnow().strftime("%Y-%m-%d")}')
row = last_claim_row()
check('V34 fee ditandai sent + txid',
row[5] == 'sent' and row[6] == 'txfee')
# ————— V33/V34: fee gagal → failed → diulang sampai terkirim —————
fresh_db('retry')
db.record_claim('2026-08-05', 'txold', 10.0, 1.0, 'pending',
'2026-08-05T08:00:00')
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned(
'txfail', exc=RuntimeError('net down'))
dist.verify_txid = lambda txid: False
check('V34 fee gagal → fee_failed', claim.step() == 'fee_failed')
check('V34 status failed',
last_claim_row()[5] == 'failed')
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txok')
check('V34 fee diulang → fee_sent', claim.step() == 'fee_sent')
row = last_claim_row()
check('V34 fee akhirnya sent', row[5] == 'sent' and row[6] == 'txok')
# ————— V33: reward 0 → fee skipped; step lanjut ke jadwal —————
# Reward 0 asli dibuktikan dari ISI TX (executed tanpa transfer bpay/vpay
# → Decimal('0')); selisih saldo 0 TIDAK dianggap reward 0 (B17) — delta
# 0 ambigu → jalur tak-terukur.
fresh_db('zero')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
dist.fetch_balance = lambda: Decimal('100.0000') # ⊥ dipakai (tx prioritas)
claim.build_claim_action = lambda: FakeSigned('txzero')
dist.verify_txid = lambda txid: True
claim.reward_from_tx = lambda txid: Decimal('0') # genuine zero
check('V33 klaim reward 0 → claimed', claim.step() == 'claimed')
row = last_claim_row()
check('V33 fee 0 → skipped', row[4] == 0.0 and row[5] == 'skipped')
# pending_claim_fee ⊥ memuat fee 0 → step lanjut ke jadwal (sleep)
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=20)
).strftime('%Y-%m-%dT%H:%M:%S')
check('V34 ⊥ fee pending → jadwal (sleep)', claim.step() == 'sleep')
claim.reward_from_tx = lambda txid: None
# ————— V34: klaim timeout tapi mendarat (verify True) → diukur —————
fresh_db('landed')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('50.0000'), Decimal('60.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned(
'txL', exc=RuntimeError('broadcast timeout'))
dist.verify_txid = lambda txid: True
check('V34 timeout tapi mendarat → claimed',
claim._try_claim_once() == 'claimed')
row = last_claim_row()
check('V34 reward terukur', row[3] == 10.0 and row[2] == 'txL')
# ————— V34/V45: verify None + last_claim_time benar2 MAJU → mendarat —————
fresh_db('reconcile')
last_state = {'n': 0}
def advancing_last():
last_state['n'] += 1
if last_state['n'] == 1: # baseline sebelum kirim
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S') # maju → mendarat
claim.fetch_producer_last_claim = advancing_last
balances = iter([Decimal('10.0000'), Decimal('12.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned(
'txN', exc=RuntimeError('timeout'))
dist.verify_txid = lambda txid: None
check('V45 verify None + last MAJU → claimed',
claim._try_claim_once() == 'claimed')
check('V45 reward tetap diukur',
last_claim_row()[3] == 2.0)
# ————— V45: verify None + last TIDAK maju → retry (⊥ false success) —————
fresh_db('stale')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S')
dist.fetch_balance = lambda: Decimal('100.0000')
claim.build_claim_action = lambda: FakeSigned(
'txS', exc=RuntimeError('timeout'))
dist.verify_txid = lambda txid: None
check('V45 last tak maju → retry (⊥ sukses-0)',
claim._try_claim_once() == 'retry')
db.ensure_claim_schema()
check('V45 ⊥ catat baris utk yg tak mendarat',
db.queryone('SELECT COUNT(*) FROM claim_runs')[0] == 0)
# ————— V46: send() "sukses" tapi chain MENOLAK (HTTP 500 ⊥ raise) —————
# pyntelope ⊥ raise saat chain reject — send kembali normal dgn body
# error; landing harus diverifikasi ulang → tak mendarat → retry.
fresh_db('rejectsilent')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
dist.fetch_balance = lambda: Decimal('100.0000')
claim.build_claim_action = lambda: FakeSigned('txR2') # send ⊥ raise
dist.verify_txid = lambda txid: False # tak mendarat
notifs = []
_orig_nc2 = telegram.notify_claim
telegram.notify_claim = lambda r, f, w: notifs.append('claimed')
check('V46 send normal + verify False → retry (⊥ sukses-0)',
claim._try_claim_once() == 'retry')
db.ensure_claim_schema()
check('V46 ⊥ catat baris + ⊥ notif sukses',
db.queryone('SELECT COUNT(*) FROM claim_runs')[0] == 0
and notifs == [])
telegram.notify_claim = _orig_nc2
# ————— V46: send() "sukses" + last MAJU → benar-benar mendarat —————
fresh_db('sendsuccess')
last_s = {'n': 0}
def advancing_success():
last_s['n'] += 1
if last_s['n'] == 1:
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S')
claim.fetch_producer_last_claim = advancing_success
balances = iter([Decimal('10.0000'), Decimal('12.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned('txR3') # send ⊥ raise
dist.verify_txid = lambda txid: None # Hyperion tak terjangkau
check('V46 send normal + last MAJU → claimed',
claim._try_claim_once() == 'claimed')
check('V46 reward terukur',
last_claim_row()[3] == 2.0)
# ————— V50: Hyperion `False` (lag indeks) tapi last_claim_time MAJU —————
# tx BARU SAJA mendarat belum terindeks Hyperion → verify False (⊥ None);
# landing tetap dideteksi via kemajuan `last_claim_time` (⊥ retry-loop).
fresh_db('lagindex')
last_l = {'n': 0}
def advancing_lag():
last_l['n'] += 1
if last_l['n'] == 1:
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S')
claim.fetch_producer_last_claim = advancing_lag
balances = iter([Decimal('20.0000'), Decimal('25.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned('txR4') # send ⊥ raise
dist.verify_txid = lambda txid: False # Hyperion belum indeks (lag)
check('V50 verify False + last MAJU → claimed (⊥ retry-loop)',
claim._try_claim_once() == 'claimed')
check('V50 reward terukur + tercatat',
last_claim_row()[3] == 5.0 and last_claim_row()[2] == 'txR4')
# ————— V54/B16: baca last_after basi di attempt 1, maju di attempt 2 —————
# poll_claim menghitung baseline SEKALI; attempt 1 mendarat tapi node
# masih balikin last_claim_time lama → retry; attempt 2 (baseline sama)
# baca last_claim_time yg sudah maju → claimed (⊥ baseline ikut maju).
fresh_db('staleretry')
st = {'n': 0}
def stale_then_advance():
st['n'] += 1
if st['n'] == 1: # baseline (poll_claim awal window)
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
if st['n'] == 2: # attempt 1: baca masih basi (belum ter-catch-up)
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S') # attempt 2: maju
claim.fetch_producer_last_claim = stale_then_advance
balances = iter([Decimal('30.0000'), Decimal('35.0000'),
Decimal('40.0000')])
dist.fetch_balance = lambda: next(balances)
sends = {'n': 0}
def send_once_then_reject():
sends['n'] += 1
if sends['n'] == 1:
return FakeSigned('txR5') # attempt 1: mendarat
return FakeSigned('txR5b', exc=RuntimeError('already claimed'))
claim.build_claim_action = send_once_then_reject
dist.verify_txid = lambda txid: False # Hyperion lag di kedua attempt
deadline = claim._utcnow() + timedelta(seconds=3600)
check('V54 stale lalu maju → poll_claim claimed',
claim.poll_claim(deadline) == 'claimed')
check('V54 reward terukur + tercatat',
last_claim_row()[3] == 5.0 and last_claim_row()[2] == 'txR5b')
# ————— V55/B17: reward_from_tx None + delta 0 → TAK TERUKUR (⊥ sukses-0) —————
# Delta saldo 0 (node belum up-date / saldo lama) ⊥ dianggap reward 0;
# keduanya gagal mengukur → fee pending + alert KLAIM MENDARAT, ⊖ notif
# sukses-0 palsu (kasus Telegram 2026-08-08: klaim mendarat, reward asli
# 1683.3641, tapi app notif `KLAIM REWARD SUKSES 0.0000`).
fresh_db('zerodelta')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
claim.reward_from_tx = lambda txid: None # Hyperion belum indeks
bal_seq = iter([Decimal('100.0000'), Decimal('100.0000'),
Decimal('100.0000'), Decimal('100.0000')])
dist.fetch_balance = lambda: next(bal_seq) # delta selalu 0
claim.build_claim_action = lambda: FakeSigned('txB17')
dist.verify_txid = lambda txid: True
notifs = []
_orig_nc2 = telegram.notify_claim
_orig_ncu2 = telegram.notify_claim_unmeasured
telegram.notify_claim_unmeasured = lambda t: notifs.append('unmeasured')
telegram.notify_claim = lambda r, f, w: notifs.append('claimed')
check('V55 delta 0 → claimed (baris dicatat, ⊖ sukses-0)',
claim._try_claim_once() == 'claimed')
row = last_claim_row()
check('V55 fee pending utk cek manual (⊖ skipped)',
row[4] == 0.0 and row[5] == 'pending')
check('V55 alert cek manual, ⊖ notif sukses-0',
'unmeasured' in notifs and 'claimed' not in notifs)
telegram.notify_claim = _orig_nc2
telegram.notify_claim_unmeasured = _orig_ncu2
# ————— V55/B17: reward_from_tx catch-up (None lalu berhasil) —————
# Hyperion mengejar indeks tx → reward asli terbaca → fee tercatat
# (jalur normal yang menyelamatkan klaim seperti 2026-08-09).
fresh_db('catchup')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
seq = iter([None, None, Decimal('1683.0126')])
claim.reward_from_tx = lambda txid: next(seq)
dist.fetch_balance = lambda: Decimal('100.0000') # ⊥ dipakai
claim.build_claim_action = lambda: FakeSigned('txC')
dist.verify_txid = lambda txid: True
check('V55 catch-up reward → claimed', claim._try_claim_once() == 'claimed')
row = last_claim_row()
check('V55 reward asli 1683.0126 + fee 168.3012 pending',
row[3] == 1683.0126 and row[4] == 168.3012
and row[5] == 'pending' and row[2] == 'txC')
# ————— V56/B18: klaim mendarat attempt 1, attempt 2 ditolak (already
# claimed) — before_balance STABIL per window → reward tetap terukur —————
# Kasus 2026-08-10: attempt 1 (bfa9ab02) mendarat tapi confirm baca
# basi → retry; attempt 2 (b0a8b2f9) ditolak chain tapi last_claim_time
# SUDAH maju → dianggap mendarat; before_balance dibaca SEKALI di awal
# window (belum termasuk reward) → delta fallback mengukur reward asli.
# Tanpa baseline stabil, `before` attempt 2 sudah post-credit → delta 0
# → alert KLAIM MENDARAT palsu.
fresh_db('b18land')
b18 = {'n': 0}
def b18_confirm():
b18['n'] += 1
if b18['n'] <= 4: # baseline + 3 baca attempt 1 → basi (stale)
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S') # attempt 2 → maju
claim.fetch_producer_last_claim = b18_confirm
claim.reward_from_tx = lambda txid: None # tx ditolak → ⊥ terbaca
bal_seq = iter([Decimal('1000.0000'), Decimal('2679.3365')])
dist.fetch_balance = lambda: next(bal_seq) # 1000 = baseline window
sends = {'n': 0}
def send_land_then_reject():
sends['n'] += 1
if sends['n'] == 1:
return FakeSigned('txA1') # attempt 1: mendarat
return FakeSigned('txA2', exc=RuntimeError('already claimed'))
claim.build_claim_action = send_land_then_reject
dist.verify_txid = lambda txid: False # Hyperion lag kedua kali
notifs = []
_orig_ncA = telegram.notify_claim
_orig_ncuA = telegram.notify_claim_unmeasured
telegram.notify_claim_unmeasured = lambda t: notifs.append('unmeasured')
telegram.notify_claim = lambda r, f, w: notifs.append('claimed')
deadline = claim._utcnow() + timedelta(seconds=3600)
check('V56 klaim attempt-1-mendarat → poll_claim claimed',
claim.poll_claim(deadline) == 'claimed')
row = last_claim_row()
check('V56 reward asli terukur via delta (before_balance stabil)',
row[3] == 1679.3365 and row[4] == 167.9336
and row[5] == 'pending' and row[2] == 'txA2')
check('V56 ⊖ alert tak-terukur, notif sukses dipakai',
'unmeasured' not in notifs and 'claimed' in notifs)
telegram.notify_claim = _orig_ncA
telegram.notify_claim_unmeasured = _orig_ncuA
# ————— V45: reward dari isi tx (bpay+vpay) → prioritas —————
fresh_db('txreward')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
dist.fetch_balance = lambda: Decimal('100.0000') # ⊥ dipakai
claim.build_claim_action = lambda: FakeSigned('txR')
dist.verify_txid = lambda txid: True
def fake_hyperion(url, params=None, timeout=None):
return type('R', (), {
'ok': True,
'raise_for_status': lambda self: None,
'json': lambda self: {
'executed': True,
'actions': [
{'act': {'account': 'vexcore', 'name': 'claimrewards',
'data': {'owner': 'databisnisid'}}},
{'act': {'account': 'vex.token', 'name': 'issue',
'data': {'to': 'vexcore',
'quantity': '82744.9200 VEX'}}},
{'act': {'account': 'vex.token', 'name': 'transfer',
'data': {'from': 'vex.bpay', 'to': 'databisnisid',
'quantity': '638.9244 VEX'}}},
{'act': {'account': 'vex.token', 'name': 'transfer',
'data': {'from': 'vex.vpay', 'to': 'databisnisid',
'quantity': '1200.3217 VEX'}}},
{'act': {'account': 'vex.token', 'name': 'transfer',
'data': {'from': 'databisnisid', 'to': 'someone',
'quantity': '5.0000 VEX'}}},
],
},
})()
dist.requests.get = fake_hyperion
claim.reward_from_tx = _orig_reward_from_tx
check('V45 reward_from_tx jumlah bpay+vpay',
claim.reward_from_tx('txR') == Decimal('1839.2461'))
dist.requests.get = fake_hyperion
check('V45 siklus: reward dari tx → claimed',
claim._try_claim_once() == 'claimed')
row = last_claim_row()
check('V45 reward tx 1839.2461 + fee 183.9246 pending',
row[3] == 1839.2461 and row[4] == 183.9246
and row[5] == 'pending' and row[2] == 'txR')
dist.requests.get = _req_get
claim.reward_from_tx = lambda txid: None
# ————— V60: reward_from_tx failover pool Hyperion —————
_orig_hynodes = list(dist.HYPERION_NODES)
claim.reward_from_tx = _orig_reward_from_tx # fungsi asli (bukan stub)
def fake_hyperion_failover(url, params=None, timeout=None):
if url.startswith('http://hyperion-dead'):
raise dist.requests.RequestException('hyperion mati')
return type('R', (), {
'ok': True,
'raise_for_status': lambda self: None,
'json': lambda self: {
'executed': True,
'actions': [
{'act': {'account': 'vex.token', 'name': 'transfer',
'data': {'from': 'vex.bpay', 'to': 'databisnisid',
'quantity': '10.0000 VEX'}}},
],
},
})()
dist.HYPERION_NODES = ['http://hyperion-dead', 'http://hyperion-alive']
dist.requests.get = fake_hyperion_failover
check('V60 reward_from_tx failover → host cadangan',
claim.reward_from_tx('txF') == Decimal('10.0000'))
dist.requests.get = _req_get
dist.HYPERION_NODES = _orig_hynodes
# ————— V45: reward tak terukur (tx gagal + delta gagal) → fee pending —————
fresh_db('unmeasured')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
claim.reward_from_tx = lambda txid: None
bal_seq = iter([Decimal('100.0000'), None, None, None]) # before ok, after ⊥
dist.fetch_balance = lambda: next(bal_seq)
claim.build_claim_action = lambda: FakeSigned('txU')
dist.verify_txid = lambda txid: True
notifs = []
_orig_nc = telegram.notify_claim
_orig_ncu = telegram.notify_claim_unmeasured
def capture_ni(label):
notifs.append('unmeasured')
telegram.notify_claim_unmeasured = capture_ni
telegram.notify_claim = lambda r, f, w: notifs.append('claimed')
check('V45 reward tak terukur → claimed (baris dicatat)',
claim._try_claim_once() == 'claimed')
row = last_claim_row()
check('V45 fee pending utk cek manual (⊥ skipped)',
row[4] == 0.0 and row[5] == 'pending')
check('V45 notif cek manual (⊥ sukses-0)',
'unmeasured' in notifs and 'claimed' not in notifs)
telegram.notify_claim = _orig_nc
telegram.notify_claim_unmeasured = _orig_ncu
# ————— V32: klaim ditolak → retry; poll deadline lewat → timeout —————
fresh_db('timeout')
claim.build_claim_action = lambda: FakeSigned(
'txT', exc=RuntimeError('rejected'))
dist.verify_txid = lambda txid: False
dist.fetch_balance = lambda: Decimal('100.0000')
check('V32 klaim ditolak → retry',
claim._try_claim_once() == 'retry')
check('V32 deadline lewat → timeout',
claim.poll_claim(claim._utcnow() - timedelta(seconds=1))
== 'timeout')
# ————— resume: fee failed dari DB → step mengirimnya —————
fresh_db('resume')
db.record_claim('2026-08-05', 'txr', 10.0, 1.0, 'failed',
'2026-08-05T08:00:00')
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txr1')
dist.verify_txid = lambda txid: True
check('V34 resume fee dari DB → fee_sent', claim.step() == 'fee_sent')
check('V34 resume → sent',
last_claim_row()[5] == 'sent' and last_claim_row()[6] == 'txr1')
# ————— V61: fee ditolak chain (HTTP-500 body, V46/B11) → ⊖ 'sent' —————
# `_send_fee` ⊖ boleh tandai 'sent' utk fee yang chain TOLAK — pyntelope
# mengembalikan 500 sbg body, ⊖ raise (V61-F1). Fee harus tersisa
# 'failed' utk diulang, ⊖ pernah 'sent' dgn txid yg ⊥ mendarat.
fresh_db('feereject')
db.record_claim('2026-08-05', 'txz', 10.0, 1.0, 'failed',
'2026-08-05T08:00:00')
class FakeFeeReject:
def id(self):
return 'txfeej'
def send(self):
return {'code': 500, 'error': {'what': 'duplicate transaction'}}
dist.build_signed_transfer = (lambda to, amount, memo:
FakeFeeReject())
dist.verify_txid = lambda txid: False # txid penolakan ⊥ mendarat
check('V61 fee rejection-500 → fee_failed (⊥ sent)',
claim.step() == 'fee_failed')
row = last_claim_row()
check('V61 fee rejection → status failed (diulang), ⊖ sent',
row[5] == 'failed')
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txr1')
# ————— V36: klaim reward → kanal INTERNAL saja, ⊥ komunitas —————
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
telegram.notify_claim('10.0000', '1.0000', 'bpdbsjasprod')
telegram.notify_claim_failure(RuntimeError('net down'))
check('V36 klaim → internal saja',
len(tg['posts']) == 2
and all(p[1] == '-1002' for p in tg['posts']))
check('V36 ⊥ komunitas utk klaim',
not any(p[1] == '-1001' for p in tg['posts']))
check('V36 isi notif klaim',
'KLAIM REWARD SUKSES' in tg['posts'][0][2]
and 'KLAIM GAGAL' in tg['posts'][1][2])
finally:
claim.time.sleep = time.sleep
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
dist.requests.get = _req_get
print('\nSEMUA UJI PASS')
if __name__ == '__main__':
import time
sys.exit(main())
+392 -16
View File
@@ -13,6 +13,7 @@ from datetime import datetime, timedelta, timezone
import requests import requests
import dashboard import dashboard
import db as dbm
def check(tag, cond): def check(tag, cond):
@@ -41,42 +42,213 @@ def main():
db = os.path.join(tmp, 'voters.db') db = os.path.join(tmp, 'voters.db')
rows = [] rows = []
now = datetime.now(timezone.utc).replace(tzinfo=None) now = datetime.now(timezone.utc).replace(tzinfo=None)
fresh = (now - timedelta(days=5)).isoformat(timespec='seconds')
for i in range(1, 126): # 125 baris -> 3 halaman def _iso(days_ago):
return (now - timedelta(days=days_ago)).isoformat(timespec='seconds')
fresh = _iso(5)
for i in range(1, 126): # 125 baris valid -> 3 halaman
owner = f'acct{i:03d}' owner = f'acct{i:03d}'
rows.append((owner, f'{i}.00000000000000000', float(i) * 10.0, rows.append((owner, f'{i}.00000000000000000', float(i) * 10.0,
'2026-08-04T00:00:00', fresh)) '2026-08-04T00:00:00', fresh))
rows[5] = (rows[5][0], rows[5][1], 5.0 * 10.0, rows[5][3], fresh) # non-monotonic guard rows[5] = (rows[5][0], rows[5][1], 5.0 * 10.0, rows[5][3], fresh) # non-monotonic guard
rows.append(('zzzold', '999.00000000000000000', 2.0,
'2026-08-04T00:00:00', _iso(30))) # V40: kadaluarsa (28-31 hr)
rows.append(('zzhide', '888.00000000000000000', 1.0,
'2026-08-04T00:00:00', _iso(60))) # V40: tersembunyi (>31 hr)
rows.append(('newacct1', '11.00000000000000000', 1300.0,
'2026-08-04T00:00:00', _iso(1))) # V41: BARU (first-seen baru)
rows.append(('revoter1', '12.00000000000000000', 1200.0,
'2026-08-04T00:00:00', _iso(0))) # V41/V43: REVOTE < 1 hari (first-seen lama)
rows.append(('expire1', '13.00000000000000000', 1290.0,
'2026-08-04T00:00:00', _iso(26))) # V43: VALID matang + VOTE ULANG SEGERA (25-28 hr)
make_db(db, rows) make_db(db, rows)
dashboard.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan) # V41: first_seen — newacct1 pertama kali muncul baru-baru ini → BARU;
# revoter1 sudah pernah terlihat lama → REVOTE. acct001 juga ber-first_seen
# (baru) — flag revote harus ⊖ True padanya (guard regresi V42: tag hanya
# utk first_seen lama, ⊥ sekedar first_seen ada). expire1 ber-first_seen
# lama → pemilih matang (V43: badge peringatan tetap tampil padanya).
# V52: semua acct### di-seed first_seen LAMA (matang) agar tetap di band
# VALID (maturity diukur dari first_seen_at, ⊥ last_vote terkuantisasi).
conn = sqlite3.connect(db)
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen '
'(owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
fs_rows = [('newacct1', _iso(1)), ('revoter1', _iso(60)),
('acct001', _iso(1)), ('expire1', _iso(60))]
fs_rows += [(f'acct{i:03d}', _iso(60)) for i in range(1, 126)]
conn.executemany(
'INSERT OR REPLACE INTO voter_first_seen (owner, first_seen_at) '
'VALUES (?, ?)',
fs_rows,
)
conn.commit()
conn.close()
dbm.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan)
real_get_liquid = dashboard._get_liquid_vex real_get_liquid = dashboard._get_liquid_vex
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
# ————— V27: seed riwayat distribusi utk uji /history —————
# run lama di-seed duluan agar run_id-nya lebih kecil (⊥ run terbaru).
today = datetime.now().strftime('%Y-%m-%d')
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
dbm.ensure_distribute_schema()
old_rid = dbm.record_run(yesterday, 50000.0, 1, 1000.0, 80.0, 'ok',
'2026-08-04T10:00:00')
dbm.record_payment(old_rid, 'oldacct', 40.0, 'sent',
'2026-08-04T10:00:01', txid='txold999')
rid = dbm.record_run(today, 52186.0417, 1, 1200.0, 100.0, 'ok',
'2026-08-05T10:00:00')
dbm.record_payment(rid, 'acct001', 50.0, 'sent',
'2026-08-05T10:00:01', txid='txabc123')
client = dashboard.app.test_client() client = dashboard.app.test_client()
# V10: judul halaman & kolom list (bobot suara tak ditampilkan, vote segar ada) # V10: judul halaman & kolom list (bobot suara tak ditampilkan, vote segar ada)
resp = client.get('/?page=1') resp = client.get('/?page=1')
check('V10 halaman 1 → 200', resp.status_code == 200) check('V10 halaman 1 → 200', resp.status_code == 200)
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
check('V10 judul halaman', 'DAFTAR PEMILIH' in html) check('V10 judul halaman', 'SIMPLE MINING' in html)
check('V13 kolom VOTE TERAKHIR', 'VOTE TERAKHIR' in html) check('V13 kolom VOTE TERAKHIR', 'VOTE TERAKHIR' in html)
check('V51 akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' in html
and 'target="_blank"' in html and 'owner-link' in html)
check('V15 kolom BOBOT SUARA tak tampil', 'BOBOT SUARA' not in html) check('V15 kolom BOBOT SUARA tak tampil', 'BOBOT SUARA' not in html)
check('V15 tak ada sel .weight', 'class="weight"' not in html) check('V15 tak ada sel .weight', 'class="weight"' not in html)
check('V15 tak ada penanda basi', 'stale-mark' not in html and 'BASI' not in html) check('V15 tak ada penanda basi', 'stale-mark' not in html and 'BASI' not in html)
check('V16 4 sel stats (3 pemilih + saldo liquid)', check('V47 3 sel stats (2 pemilih + saldo liquid)',
html.count('spec-cell') == 4) html.count('spec-cell') == 3)
check('V47 TOTAL VEX dihapus dari dashboard',
'TOTAL VEX' not in html)
check('V16 sel SALDO LIQUID', 'SALDO LIQUID' in html) check('V16 sel SALDO LIQUID', 'SALDO LIQUID' in html)
check('V16 SALDO LIQUID paling kiri (sblm TOTAL PEMILIH)',
html.index('SALDO LIQUID') < html.index('TOTAL PEMILIH'))
check('V16 nilai saldo diformat id-ID', '50.691,4676' in html) check('V16 nilai saldo diformat id-ID', '50.691,4676' in html)
check('V47 catatan definisi total vs vexascan',
'SUARA TUNGGAL KE' in html
and 'BUKAN TOTAL VOTE-WEIGHT' not in html)
check('V53 promo VEXWALLET link + logo IDRS',
'play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet' in html
and 'BY DATABISNISID' in html
and 'UNDUH DI PLAY STORE' in html
and 'idrs.png' in html)
check('V40 TOTAL PEMILIH = baris tampil (129)',
'<span class="cell-value">129</span>' in html)
# ————— V40/V41: band umur — BARU / REVOTE / daftar utama (kadaluarsa menyatu) —————
check('V40 KADALUARSA menyatu dgn daftar utama (bukan bagian sendiri)',
'expired-list' not in html)
check('V40 baris kadaluarsa ber-sorot .expired',
'class="list-row expired"' in html)
check('V40 badge VOTE ULANG', 'VOTE ULANG' in html)
check('V40 legenda amber di daftar utama',
'BARIS AMBER = VOTE LEWAT' in html and 'SUARA KADALUARSA' in html)
check('V40 pemilih kadaluarsa tampil', 'zzzold' in html)
check('V40 kadaluarsa & valid satu daftar (#rows)',
'zzzold' in _slice_rows(html) and 'acct125' in _slice_rows(html))
check('V40 pemilih tersembunyi (>31 hr) tak tampil', 'zzhide' not in html)
check('V42 REVOTE ⊖ bagian sendiri (menyatu dgn VALID)',
'PEMILIH REVOTE · BELUM MATANG' not in html)
check('V41 caption BARU count', 'PEMILIH BARU · BELUM MATANG (0-3 HARI) · 1' in html)
check('V52 BARU countdown VALID DALAM N HARI',
'maturity" data-label="MATURITY PERIOD">VALID DALAM 2 HARI' in html)
check('V52 BARU band kolom MATURITY PERIOD',
'MATURITY PERIOD' in _slice_band(html))
check('V52 BARU band ⊖ kolom VOTE TERAKHIR & TOTAL REWARD',
'VOTE TERAKHIR' not in _slice_band(html)
and 'TOTAL REWARD' not in _slice_band(html))
check('V42 revoter1 (REVOTE) ada di daftar utama (#rows)',
'revoter1' in _slice_rows(html))
check('V42 newacct1 di BARU, revoter1 di VALID',
'newacct1' in html and 'revoter1' in html)
check('V42 tag REVOTE pada baris valid', 'tag-revote">REVOTE' in html)
check('V42 hanya 1 tag REVOTE di daftar utama hal.1 (revoter1)',
_slice_rows(html).count('tag-revote') == 1)
check('V43 badge VOTE ULANG SEGERA pada baris valid (expire1)',
'warn-badge">VOTE ULANG SEGERA' in _slice_rows(html))
check('V43 legenda mint REVOTE 1-hari',
'TAG MINT = REVOTE KURANG DARI 1 HARI' in html)
check('V43 legenda amber peringatan',
'TAG AMBER = KADALUARSA DALAM 3 HARI' in html and '1 PEMILIH' in html)
staked_vals = [] staked_vals = []
for i in range(1, 4): for i in range(1, 4):
resp = client.get(f'/?page={i}') resp = client.get(f'/?page={i}')
check(f'V10 halaman {i} → 200', resp.status_code == 200) check(f'V10 halaman {i} → 200', resp.status_code == 200)
staked_vals += _extract_stakes(resp.get_data(as_text=True)) staked_vals += _extract_stakes(resp.get_data(as_text=True))
check('V10 total 125 baris ditarik', len(staked_vals) == 125) check('V42 total 127 baris valid ditarik (125 acct + revoter1 + expire1)',
len(staked_vals) == 127)
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1] check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
for i in range(len(staked_vals) - 1))) for i in range(len(staked_vals) - 1)))
# ————— V29: kolom TOTAL REWARD (Σ transfer sent per voter, 0 bila tanpa) —————
resp = client.get('/?page=1')
html = resp.get_data(as_text=True)
check('V29 kolom TOTAL REWARD (VEX)', 'TOTAL REWARD (VEX)' in html)
check('V29 reward 0 utk tanpa transfer',
'<span class="reward" data-label="TOTAL REWARD (VEX)">0,0000</span>'
in html)
resp = client.get('/?page=3')
html = resp.get_data(as_text=True)
check('V29 reward acct001 = total sent (50,0000)',
'<span class="reward" data-label="TOTAL REWARD (VEX)">50,0000</span>'
in html)
check('V42 acct001 (first_seen baru) ⊖ tag REVOTE di hal.3',
'tag-revote">REVOTE' not in _slice_rows(html))
# ————— V38: banner BERITA jadwal distribusi berikutnya —————
# V64/B25: mock `_now` (helper jam) ke tanggal TETAP — ⊖ jam dinding
# nyata (oracle lulus hanya kalau kalender < tanggal launch baked).
_orig_dash_now = dashboard._now
dashboard._now = lambda: datetime(2026, 8, 6, 9, 0) # sebelum launch
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 17, 10, 0)
resp = client.get('/')
html = resp.get_data(as_text=True)
check('V38 tag BERITA', 'BERITA' in html)
check('V38 judul DISTRIBUSI BERIKUTNYA', 'DISTRIBUSI BERIKUTNYA' in html)
check('V38 tanggal id-ID (Senin 17 Agt)',
'SENIN · 17 AGUSTUS 2026 · 10:00' in html)
check('V38 baris berulang TAK tampil saat first-run (17 Agt ≠ Rabu/Sabtu)',
'SETIAP RABU & SABTU · 10:00' not in html)
check('V38 _is_first_run_next sebelum launch = True',
dashboard._is_first_run_next(datetime(2026, 8, 6)) is True)
check('V38 _is_first_run_next setelah launch = False',
dashboard._is_first_run_next(datetime(2026, 8, 18)) is False)
dashboard._now = lambda: datetime(2026, 8, 18, 9, 0) # setelah launch
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 22, 10, 0)
html = client.get('/').get_data(as_text=True)
check('V38 baris berulang tampil utk putaran mingguan',
'SETIAP RABU & SABTU · 10:00' in html)
dashboard._now = _orig_dash_now
# drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor)
import distribute_loop
drift_dts = [datetime(2026, 8, 6, 9, 0), datetime(2026, 8, 17, 11, 0),
datetime(2026, 8, 19, 9, 0), datetime(2026, 8, 22, 11, 0),
datetime(2026, 8, 12, 11, 0), datetime(2026, 8, 15, 9, 0)]
fr = dashboard._parse_first_run('2026-08-17')
check('V38 drift-guard one-off = distribute_loop',
all(dashboard._next_boundary(n, 10, 5, fr)
== distribute_loop.next_boundary(n, 10, 5, fr)
for n in drift_dts))
check('V38 drift-guard mingguan = distribute_loop',
all(dashboard._next_boundary(n, 10, 5)
== distribute_loop.next_boundary(n, 10, 5)
for n in drift_dts))
check('V44 drift-guard multi-hari wed,sat = distribute_loop',
all(dashboard._next_boundary(n, 10, [2, 5], fr)
== distribute_loop.next_boundary(n, 10, [2, 5], fr)
for n in drift_dts))
check('V44 drift-guard skalar = distribute_loop',
all(dashboard._next_boundary(n, 10, 2)
== distribute_loop.next_boundary(n, 10, 2)
for n in drift_dts))
check('V44 parse_weekdays mirror',
dashboard._parse_weekdays('wed,sat') == [2, 5]
and dashboard._parse_weekdays('sat') == [5])
# halaman melebihi total → dibatasi (tidak error) # halaman melebihi total → dibatasi (tidak error)
resp = client.get('/?page=99') resp = client.get('/?page=99')
check('V10 halaman ekstra → 200', resp.status_code == 200) check('V10 halaman ekstra → 200', resp.status_code == 200)
@@ -84,8 +256,28 @@ def main():
# V10: read-only — tidak ada tabel baru / perubahan pada DB asli # V10: read-only — tidak ada tabel baru / perubahan pada DB asli
before = _table_hashes(db) before = _table_hashes(db)
client.get('/') client.get('/')
client.get('/history')
check('V10 read-only (DB tak berubah)', _table_hashes(db) == before) check('V10 read-only (DB tak berubah)', _table_hashes(db) == before)
# ————— V27: /history — riwayat distribusi (read-only) —————
resp = client.get('/history')
check('V27 /history → 200', resp.status_code == 200)
html = resp.get_data(as_text=True)
check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html)
check('V27 run date tampil', today in html)
check('V27 tanggal di judul TRANSFER PER PEMILIH',
f'TRANSFER PER PEMILIH · {today}' in html)
check('V27 transfer per-pemilih tampil', 'acct001' in html)
check('V51 history akun jadi link vexascan',
'href="https://vexascan.com/account/acct001"' in html)
check('V27 hanya run terbaru (transfer lama tak tampil)',
'oldacct' not in html)
check('V27 status SENT tampil', 'SENT' in html)
check('V27 memo tak dirender', f'DATABISNISID PROFIT SHARE {today}' not in html)
check('V27 txid tercatat', 'txabc123' in html)
check('V27 txid jadi link explorer',
'href="https://vexascan.com/transaction/txabc123"' in html)
# ————— V11: search server-side ?q= (substring, case-insensitive) ————— # ————— V11: search server-side ?q= (substring, case-insensitive) —————
resp = client.get('/?q=acct12') resp = client.get('/?q=acct12')
check('V11 search ?q= → 200', resp.status_code == 200) check('V11 search ?q= → 200', resp.status_code == 200)
@@ -93,17 +285,26 @@ def main():
check('V11 hanya akun cocok', 'acct125' in html and 'acct120' in html check('V11 hanya akun cocok', 'acct125' in html and 'acct120' in html
and 'acct119' not in html) and 'acct119' not in html)
check('V11 caption HASIL · 6', 'HASIL · 6 AKUN' in html) check('V11 caption HASIL · 6', 'HASIL · 6 AKUN' in html)
check('V51 search akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' in html)
q_vals = _extract_stakes(html) q_vals = _extract_stakes(html)
check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True) check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True)
and len(q_vals) == 6) and len(q_vals) == 6)
# pager membawa q # mode search = daftar datar ber-tag; input mempertahankan q
check('V11 pager mempertahankan q', 'q=acct12' in html) check('V40 search input mempertahankan q', 'value="acct12"' in html)
# halaman ekstra saat filter → dipatok ke total_pages filter # halaman ekstra saat filter → dipatok ke total_pages filter
resp = client.get('/?q=acct12&page=99') resp = client.get('/?q=acct12&page=99')
check('V11 clamp halaman saat search', resp.status_code == 200) check('V11 clamp halaman saat search', resp.status_code == 200)
# V43: badge peringatan juga tampil di mode search tanpa-JS (?q=)
resp = client.get('/?q=expire1')
html = resp.get_data(as_text=True)
check('V43 search ?q= menampilkan badge VOTE ULANG SEGERA',
'warn-badge">VOTE ULANG SEGERA' in html
and 'expire1' in _slice_rows(html))
# search tanpa hasil → status kosong # search tanpa hasil → status kosong
resp = client.get('/?q=zzzz') resp = client.get('/?q=zzzz')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
@@ -130,15 +331,108 @@ def main():
isinstance(data['results'][0]['weight'], str)) isinstance(data['results'][0]['weight'], str))
check('V11 /api/search last_vote hadir', check('V11 /api/search last_vote hadir',
all(r['last_vote'] for r in data['results'])) all(r['last_vote'] for r in data['results']))
check('V29 search total_reward hadir',
all('total_reward' in r for r in data['results']))
check('V29 search total_reward 0 (tanpa transfer)',
all(r['total_reward'] == 0 for r in data['results']))
check('V40 search group hadir',
all(r['group'] in ('baru', 'revote', 'valid', 'kadaluarsa')
for r in data['results']))
check('V40 search expired flag hadir',
all('expired' in r for r in data['results']))
check('V43 search expiring flag hadir',
all('expiring' in r for r in data['results']))
check('V40 search acct12 semua valid + expired=False',
all(r['group'] == 'valid' and not r['expired']
for r in data['results']))
resp = client.get('/api/search?q=revoter1')
data = resp.get_json()
check('V41 search revoter1 → group revote (tag < 1 hari)',
len(data['results']) == 1 and data['results'][0]['group'] == 'revote'
and data['results'][0]['expired'] is False
and data['results'][0]['expiring'] is False)
resp = client.get('/api/search?q=expire1')
data = resp.get_json()
check('V43 search expire1 → group valid + expiring',
len(data['results']) == 1
and data['results'][0]['group'] == 'valid'
and data['results'][0]['expired'] is False
and data['results'][0]['expiring'] is True)
resp = client.get('/api/search?q=newacct1')
data = resp.get_json()
check('V41 search newacct1 → group baru',
len(data['results']) == 1 and data['results'][0]['group'] == 'baru'
and data['results'][0]['expired'] is False)
resp = client.get('/api/search?q=zzzold')
data = resp.get_json()
check('V40 search kadaluarsa → group + expired',
len(data['results']) == 1
and data['results'][0]['group'] == 'kadaluarsa'
and data['results'][0]['expired'] is True)
resp = client.get('/api/search?q=zzhide')
check('V40 search tersembunyi (>31 hr) → 0 hasil',
resp.get_json()['count'] == 0)
resp = client.get('/api/search?q=acct001')
data = resp.get_json()
check('V29 search total_reward acct001 = 50.0',
len(data['results']) == 1 and data['results'][0]['total_reward'] == 50.0)
resp = client.get('/api/search?q=zzzz') resp = client.get('/api/search?q=zzzz')
check('V11 /api/search kosong', resp.get_json()['count'] == 0) check('V11 /api/search kosong', resp.get_json()['count'] == 0)
# rank HARUS global, bukan dalam-filter: 'acct00' menyentuh baris stake kecil # wildcard `%`/`_` dari pengguna dibuang (escape `!`, bukan pola):
# tak ada owner berisi `_` atau `%` → 0 hasil bila benar-benar literal
resp = client.get('/api/search?q=acct!_')
check('V17 wildcard `_` di-escape (bukan pola)', resp.get_json()['count'] == 0)
resp = client.get('/api/search?q=acct!%')
check('V17 wildcard `%` di-escape (bukan pola)', resp.get_json()['count'] == 0)
# rank ordinal DALAM kelompok: 'acct00' menyentuh 9 baris VALID → 1..9
resp = client.get('/api/search?q=acct00') resp = client.get('/api/search?q=acct00')
data = resp.get_json() data = resp.get_json()
check('V11 rank global (bukan dalam-filter)', check('V40 rank ordinal dalam kelompok (1..9)',
[r['rank'] for r in data['results']] == list(range(117, 126))) [r['rank'] for r in data['results']] == list(range(1, 10)))
# ————— V57: /api/voter/<owner> — cek akun pemilih reward-eligible —————
resp = client.get('/api/voter/acct125')
check('V57 eligible akun VALID → 200', resp.status_code == 200)
check('V57 shape {owner,is_valid_voter}',
set(resp.get_json().keys()) == {'owner', 'is_valid_voter'})
check('V57 acct125 (matang+segar) → true',
resp.get_json() == {'owner': 'acct125', 'is_valid_voter': True})
resp = client.get('/api/voter/revoter1')
check('V57 revoter1 (REVOTE matang) → true',
resp.get_json() == {'owner': 'revoter1', 'is_valid_voter': True})
resp = client.get('/api/voter/expire1')
check('V57 expire1 (VALID, badge peringatan) → true',
resp.get_json() == {'owner': 'expire1', 'is_valid_voter': True})
resp = client.get('/api/voter/newacct1')
check('V57 newacct1 (BARU, belum matang) → false',
resp.get_json() == {'owner': 'newacct1', 'is_valid_voter': False})
resp = client.get('/api/voter/zzzold')
check('V57 zzzold (kadaluarsa) → false',
resp.get_json() == {'owner': 'zzzold', 'is_valid_voter': False})
resp = client.get('/api/voter/zzhide')
check('V57 zzhide (tersembunyi) → false',
resp.get_json() == {'owner': 'zzhide', 'is_valid_voter': False})
resp = client.get('/api/voter/tidakada1')
check('V57 akun tak ada → false',
resp.get_json() == {'owner': 'tidakada1', 'is_valid_voter': False})
# V57: konsisten dgn db.eligible_voters + hasil distribusi (single source)
for who in ('acct125', 'revoter1', 'expire1', 'newacct1',
'zzzold', 'zzhide', 'tidakada1'):
want = dbm.eligible_voters(who) # [] or one row
got = client.get(f'/api/voter/{who}').get_json()['is_valid_voter']
check(f'V57 endpoint == db.eligible_voters({who})',
got == (len(want) > 0))
legit = {r[0] for r in dbm.eligible_voters()}
check('V57 distribusi-eligible == endpoint pool (semua acct + revote + expire)',
legit == {f'acct{i:03d}' for i in range(1, 126)}
| {'revoter1', 'expire1'})
check('V57 pool ⊖ BARU/kadaluarsa/sembunyi/asing',
legit.isdisjoint({'newacct1', 'zzzold', 'zzhide', 'tidakada1'}))
# ————— env config: default = konstanta lama ————— # ————— env config: default = konstanta lama —————
import config import config
@@ -149,13 +443,48 @@ def main():
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db') check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2) check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2)
check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28) check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28)
check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id') check('V40 config default VEX_MATURITY_DAYS', config.VEX_MATURITY_DAYS == 3)
check('V40 config default VEX_EXPIRED_DAYS', config.VEX_EXPIRED_DAYS == 3)
check('V43 config default VEX_REVOTE_TAG_DAYS', config.VEX_REVOTE_TAG_DAYS == 1)
check('V43 config default VEX_WARN_DAYS', config.VEX_WARN_DAYS == 3)
check('V16 config default HYPERION_API', config.HYPERION_API == 'https://api.databisnis.id')
check('V60 config default HYPERION_NODES',
config.HYPERION_NODES == ['https://api.databisnis.id',
'https://v2.vexascan.com:2096'])
check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60) check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60)
check('V17 config default DB_BACKEND', config.DB_BACKEND == 'sqlite')
check('V17 config default DB_HOST/PORT', config.DB_HOST == '127.0.0.1' and config.DB_PORT == 3306)
check('V17 config default DB_USER/PASS/NAME',
(config.DB_USER, config.DB_PASS, config.DB_NAME) == ('', '', ''))
# ————— backward-compat: env lama DATABISNIS_API masih dihormati —————
import importlib
os.environ['DATABISNIS_API'] = 'http://hyperion-lama'
importlib.reload(config)
check('rename fallback env lama DATABISNIS_API dihormati',
config.HYPERION_API == 'http://hyperion-lama')
os.environ.pop('DATABISNIS_API', None)
importlib.reload(config)
check('rename default pulih stlh env lama dibuang',
config.HYPERION_API == 'https://api.databisnis.id')
# ————— V60: env override pool Hyperion —————
os.environ['VEX_HYPERION_NODES'] = 'http://hyperion-a,http://hyperion-b'
importlib.reload(config)
check('V60 override VEX_HYPERION_NODES dipakai',
config.HYPERION_NODES == ['http://hyperion-a', 'http://hyperion-b'])
os.environ.pop('VEX_HYPERION_NODES', None)
importlib.reload(config)
check('V60 default pulih stlh override dibuang',
config.HYPERION_NODES == ['https://api.databisnis.id',
'https://v2.vexascan.com:2096'])
# ————— V16: saldo liquid — cache TTL + cooldown kegagalan ————— # ————— V16: saldo liquid — cache TTL + cooldown kegagalan —————
dashboard._get_liquid_vex = real_get_liquid dashboard._get_liquid_vex = real_get_liquid
dashboard._liquid_cache.update(at=0.0, value=None) dashboard._liquid_cache.update(at=0.0, value=None)
calls = {'n': 0} calls = {'n': 0}
_orig_hynodes = list(dashboard.HYPERION_NODES)
dashboard.HYPERION_NODES = ['http://single'] # hitungan V16 dgn 1 host
def fake_ok(url, params=None, timeout=None): def fake_ok(url, params=None, timeout=None):
calls['n'] += 1 calls['n'] += 1
@@ -186,11 +515,58 @@ def main():
check('V16 gagal tanpa cache → None', check('V16 gagal tanpa cache → None',
dashboard._get_liquid_vex() is None and calls['n'] == 3) dashboard._get_liquid_vex() is None and calls['n'] == 3)
# ————— V60: failover pool Hyperion — host mati → host cadangan —————
dashboard._liquid_cache.update(at=0.0, value=None)
fl_calls = {'n': 0}
def fake_failover(url, params=None, timeout=None):
fl_calls['n'] += 1
if url.startswith('http://dead'):
raise requests.exceptions.ConnectionError('hyperion mati')
return type('R', (), {
'ok': True,
'raise_for_status': lambda self: None,
'json': lambda self: {'account': {'core_liquid_balance': '999.0000 VEX'}},
})()
dashboard.HYPERION_NODES = ['http://dead', 'http://alive']
dashboard.requests.get = fake_failover
check('V60 saldo dari host cadangan',
dashboard._get_liquid_vex() == 999.0 and fl_calls['n'] == 2)
dashboard.HYPERION_NODES = _orig_hynodes
def _extract_stakes(text): def _extract_stakes(text):
"""Ambil nilai stake (dalam urutan kemunculan) dari baris HTML.""" """Ambil nilai stake (dalam urutan kemunculan) dari baris HTML — HANYA dari
return [_parse_vex(m.group(1)) kontainer `id="rows"` (daftar utama VALID / hasil search). Baris sorot
for m in re.finditer(r'<span class="stake"[^>]*>(.*?)</span>', text)] `list-row expired` dilewati (V40: kadaluarsa menyatu di daftar utama, ⊥
hitungan/pengurutan VALID)."""
chunk = _slice_rows(text)
vals = []
for m in re.finditer(r'<div class="list-row(?: expired)?">(.*?)</div>',
chunk, re.S):
if ' expired' in m.group(0):
continue
sm = re.search(r'<span class="stake"[^>]*>(.*?)</span>', m.group(1))
if sm:
vals.append(_parse_vex(sm.group(1)))
return vals
def _slice_rows(text):
start = text.index('id="rows">') + len('id="rows">')
if '<nav class="pager"' in text:
end = text.index('<nav class="pager"', start)
else:
end = text.index('<footer', start)
return text[start:end]
def _slice_band(text):
"""Potong section band BARU (`band-list news-list`) utk cek kolom."""
start = text.index('band-list news-list') + len('band-list news-list')
end = text.index('</section>', start)
return text[start:end]
def _parse_vex(text): def _parse_vex(text):
+660
View File
@@ -0,0 +1,660 @@
"""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)')
# V61: bentuk sukses nodeos asli (whitelist `_send_rejected` butuh
# `transaction_id` + receipt `executed`).
return {'transaction_id': self._txid,
'processed': {'receipt': {'status': 'executed'}}}
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.time.sleep = lambda s: None # V61 `_verify_settled` tidur — mock
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 —————
_real_verify = dist.verify_txid # sandi utk oracle non-dict V61
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 → tetep ditahan (V61)
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')
# ————— V61: verify_txid False (Hyperion bilang tak mendarat) → TAHAN —————
# False bisa lag indeks utk tx yang BARU mendarat (pola V50/B14 di klaim):
# resend = tapos baru = txid baru = ganda. → hold di attempt 1, ⊖ resend.
dist.DISTRIBUTE_MAX_ATTEMPTS = 3 # default; hold harus jalan di attempt 1
dist.time.sleep = lambda s: None
sends_false = {'n': 0}
def counting_sign_false(to, amount, memo):
sends_false['n'] += 1
return FakeSigned('txF' + to, fails=1) # broadcast timeout (mungkin mendarat)
dist.build_signed_transfer = counting_sign_false
dist.verify_txid = lambda txid: False # Hyperion: executed:false
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V61 verify False → ditahan (failed), ⊖ resend',
all(u[1] == 'failed' for u in calls['updates']))
check('V61 hold attempt-1 → build sekali per pemilih (⊥ resend)',
sends_false['n'] == 2)
check('V61 run status partial',
calls['run_updates'][0][1] == 'partial')
# ————— V61: `_send_rejected` whitelist — bentuk tak dikenal = tolak —————
check('V61 sukses nodeos (transaction_id + executed) → ⊖ tolak',
dist._send_rejected({'transaction_id': 'tx1',
'processed': {'receipt': {'status': 'executed'}}})
is False)
check('V61 rejection 500 ber-error → tolak',
dist._send_rejected({'code': 500, 'error': {'what': 'duplicate'}})
is True)
check('V61 rejection 500 TANPA error (bentuk proxy) → tolak',
dist._send_rejected({'code': 500, 'message': 'Internal Error'}) is True)
check('V61 soft_fail receipt → tolak',
dist._send_rejected({'transaction_id': 'tx1',
'processed': {'receipt': {'status': 'soft_fail'}}})
is True)
check('V61 non-dict response → tolak', dist._send_rejected(['x']) is True)
# ————— V61: `_get_json`/`verify_txid` ⊖ crash pada bentuk non-dict —————
_orig_verify = dist.verify_txid # restore dr mock False di uji V61-F2
dist.verify_txid = _real_verify
dist.time.sleep = lambda s: None
dist.HYPERION_NODES = ['http://hyperion-list']
dist.requests.get = lambda url, params=None, timeout=10: (
fake_post(['bukan', 'dict'])) # 200 tapi list (proxy salah)
check('V61 _get_json non-dict 200 → None (⊥ crash)',
dist._get_json('/v2/history/get_transaction') is None)
check('V61 verify_txid non-dict → None',
dist.verify_txid('tx1') is None)
dist.HYPERION_NODES = _orig_hynodes
dist.requests.get = _orig_get
dist.verify_txid = _orig_verify
dist.time.sleep = _orig_sleep
# ————— V63: jeda antar kirim — sleep `DISTRIBUTE_SEND_DELAY` ANTAR
# pemilih; N pemilih → N−1 tidur (⊥ setelah pemilih terakhir) —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 200.0, '2026-08-05T00:00:00',
fresh),
('bbb2', '1.0', 300.0, '2026-08-05T00:00:00',
fresh),
('ccc3', '1.0', 500.0, '2026-08-05T00:00:00',
fresh)])
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txD' + to)
dist.verify_txid = lambda txid: True
sleeps = []
dist.time.sleep = lambda s: sleeps.append(s)
with redirect_stdout(io.StringIO()):
rc = dist.run_distribution(dry_run=False)
check('V63 run nyata exit 0 (jeda antar kirim)', rc == 0)
check('V63 3 pemilih → 2 jeda @ DISTRIBUTE_SEND_DELAY',
sleeps == [dist.DISTRIBUTE_SEND_DELAY] * 2)
dist.time.sleep = _orig_sleep
# ————— 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 'Berhasil' 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', 'Terkirim: 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())
+156
View File
@@ -0,0 +1,156 @@
"""Oracle jadwal distribusi (V37/V44): next_boundary + parser.
Murni logika kalender — ⊥ jaringan/DB. Verifikasi: first_run one-off diambil
bila lebih dekat dari mingguan; batas dihitung ulang tiap iterasi loop
(distribute_loop.main loop tak pernah keluar); first_run yang sudah lewat
dibuang → hanya jadwal mingguan; V44: jadwal multi-hari (CSV `wed,sat`) — event
terdekat di antara hari-hari yang terdaftar, tetap hormati gating first_run.
"""
from datetime import datetime
import distribute_loop as dl
CHECKS = 0
def check(tag, cond):
global CHECKS
CHECKS += 1
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
def main():
# parse_weekday: nama + angka, kasus-tak-acak
check('parse_weekday sat', dl.parse_weekday('sat') == 5)
check('parse_weekday SUN upper', dl.parse_weekday('SUN') == 6)
check('parse_weekday mon', dl.parse_weekday('mon') == 0)
check('parse_weekday angka 0-6', dl.parse_weekday('3') == 3)
check('parse_weekday 6', dl.parse_weekday('6') == 6)
for bad in ('', 'xyz', '7', '-1'):
try:
dl.parse_weekday(bad)
raise AssertionError(f'GAGAL: parse_weekday {bad!r} harus raise')
except ValueError:
pass
check('parse_weekday invalid raise', True)
# parse_weekdays (V44): satu hari atau CSV → list int unik terurut
check('parse_weekdays sat', dl.parse_weekdays('sat') == [5])
check('parse_weekdays wed,sat', dl.parse_weekdays('wed,sat') == [2, 5])
check('parse_weekdays kebalikan', dl.parse_weekdays('sat,wed') == [2, 5])
check('parse_weekdays duplikat', dl.parse_weekdays('wed,sat,wed') == [2, 5])
check('parse_weekdays angka CSV', dl.parse_weekdays('2,5') == [2, 5])
try:
dl.parse_weekdays('')
raise AssertionError('GAGAL: parse_weekdays kosong harus raise')
except ValueError:
pass
try:
dl.parse_weekdays('xyz')
raise AssertionError('GAGAL: parse_weekdays tak valid harus raise')
except ValueError:
pass
check('parse_weekdays invalid raise', True)
# parse_first_run: kosong → None, format → date
check('parse_first_run kosong', dl.parse_first_run('') is None)
check('parse_first_run None', dl.parse_first_run(None) is None)
fr = dl.parse_first_run('2026-08-17')
check('parse_first_run format', fr.year == 2026 and fr.month == 8
and fr.day == 17)
try:
dl.parse_first_run('17/08/2026')
raise AssertionError('GAGAL: format salah harus raise')
except ValueError:
pass
check('parse_first_run format salah raise', True)
# — next_boundary: mingguan Sabtu jam 10 + one-off Senin 17 Agt —
# Awal (Kamis 6 Agt pagi): Sabtu 8 Agt jatuh SEBELUM launch 17 Agt → dilewati,
# event terdekat = one-off Senin 17 Agt
now = datetime(2026, 8, 6, 9, 0) # Kamis
t = dl.next_boundary(now, 10, 5, fr)
check('Sabtu sebelum launch dilewati → Senin 17 Agt',
t == datetime(2026, 8, 17, 10, 0))
# Sama jam sudah lewat di hari one-off → mingguan Sabtu berikutnya
now = datetime(2026, 8, 17, 11, 0) # Senin, jam lewat
t = dl.next_boundary(now, 10, 5, fr)
check('first_run lewat → Sabtu 22 Agt',
t == datetime(2026, 8, 22, 10, 0))
# Mingguan saja (⊥ first_run) → Sabtu terdekat dari sekarang
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5)
check('tanpa first_run → Sabtu 8 Agt',
t == datetime(2026, 8, 8, 10, 0))
# Hari yang sama, jam belum lewat → hari ini
t = dl.next_boundary(datetime(2026, 8, 22, 9, 0), 10, 5)
check('Sabtu jam belum lewat → hari ini',
t == datetime(2026, 8, 22, 10, 0))
# Hari yang sama, jam sudah lewat → minggu depan
t = dl.next_boundary(datetime(2026, 8, 22, 11, 0), 10, 5)
check('Sabtu jam lewat → Sabtu pekan depan',
t == datetime(2026, 8, 29, 10, 0))
# first_run = hari Sabtu (22 Agt) → Sabtu2 sebelum launch dilewati
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5,
dl.parse_first_run('2026-08-22'))
check('first_run Sabtu → Sabtu 8/15 Agt dilewati, event = 22 Agt',
t == datetime(2026, 8, 22, 10, 0))
# Satu hari sebelum launch (Sabtu 15 Agt pagi) → tetap 22 Agt
t = dl.next_boundary(datetime(2026, 8, 15, 9, 0), 10, 5,
dl.parse_first_run('2026-08-22'))
check('sehari sblm launch Sabtu → tetap 22 Agt',
t == datetime(2026, 8, 22, 10, 0))
# Hari lain dalam seminggu: Rabu (weekday 2) → next Rabu 10:00
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 2)
check('Rabu → Rabu 12 Agt', t == datetime(2026, 8, 12, 10, 0))
# — V44: jadwal multi-hari Rabu & Sabtu ([2, 5]) —
# Sebelum launch: Rabu/Sabtu sebelum 17 Agt dilewati → one-off Senin 17 Agt
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, [2, 5], fr)
check('wed,sat sblm launch → one-off 17 Agt',
t == datetime(2026, 8, 17, 10, 0))
# Setelah launch (Senin 17 Agt siang) → Rabu 19 Agt (bukan Sabtu 22)
t = dl.next_boundary(datetime(2026, 8, 17, 11, 0), 10, [2, 5], fr)
check('wed,sat stlh launch → Rabu 19 Agt',
t == datetime(2026, 8, 19, 10, 0))
# Setelah Rabu 19 Agt siang → Sabtu 22 Agt (jeda terdekat berikutnya)
t = dl.next_boundary(datetime(2026, 8, 19, 11, 0), 10, [2, 5], fr)
check('wed,sat stlh Rabu lewat → Sabtu 22 Agt',
t == datetime(2026, 8, 22, 10, 0))
# Tanpa first_run: dari Kamis 6 Agt → Sabtu 8 Agt (lebih dekat dari Rabu 12)
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, [2, 5])
check('wed,sat tanpa fr → Sabtu 8 Agt',
t == datetime(2026, 8, 8, 10, 0))
# Rabu pagi sebelum jam → Rabu hari itu juga
t = dl.next_boundary(datetime(2026, 8, 12, 9, 0), 10, [2, 5])
check('wed,sat Rabu jam sblm → hari ini',
t == datetime(2026, 8, 12, 10, 0))
# Rabu siang setelah jam → Sabtu 15 Agt
t = dl.next_boundary(datetime(2026, 8, 12, 11, 0), 10, [2, 5])
check('wed,sat Rabu jam lewat → Sabtu 15 Agt',
t == datetime(2026, 8, 15, 10, 0))
# Skalar int tetap kompatibel (normalisasi ke list)
t = dl.next_boundary(datetime(2026, 8, 6, 9, 0), 10, 5)
check('skalar int kompatibel → Sabtu 8 Agt',
t == datetime(2026, 8, 8, 10, 0))
print(f'SEMUA UJI PASS ({CHECKS} checks)')
if __name__ == '__main__':
main()
+209 -26
View File
@@ -2,24 +2,28 @@
Oracle: `./venv/bin/python test_get_voters.py` harus exit 0. Oracle: `./venv/bin/python test_get_voters.py` harus exit 0.
Dalam setiap contoh, 'last_vote_weight' berisi 17 desimal (bobot teliti). Dalam setiap contoh, 'last_vote_weight' berisi 17 desimal (bobot teliti).
Pemilih buatan memakai bobot segar (revote ~sekarang) supaya lolos saringan Pemilih buatan memakai bobot segar (revote ~sekarang); sejak V40 baris basi
V13; baris basi ditambahkan eksplisit untuk membuktikan ia dibuang. JUGA disimpan (banding/tampil/bayar menghitung umur saat query) — contoh basi
membuktikan ia lolos ke snapshot, bukan dibuang.
""" """
import os import os
import sqlite3 import sqlite3
import tempfile import tempfile
from datetime import datetime, timedelta from datetime import datetime, timedelta, timezone
import requests import requests
import db
import get_voters as gv import get_voters as gv
import telegram
def _fresh_weight(staked_raw, days_ago=5): def _fresh_weight(staked_raw, days_ago=5):
"""Bobot untuk revote ~sekarang: staked_raw × 2^(tahun sejak 2000).""" """Bobot utk revote ~sekarang: staked_raw × 2^(pekan/52) — kuantisasi
t = ((datetime.now() - gv.WEIGHT_EPOCH).days - days_ago) / 365.25 pekan utuh sejak epoch 2000 (formula stake2vote Vexanium)."""
return f'{staked_raw * 2 ** t:.17f}' weeks = ((datetime.now() - gv.WEIGHT_EPOCH).days - days_ago) // 7
return f'{staked_raw * 2 ** (weeks / 52.0):.17f}'
class FakePayload: class FakePayload:
@@ -69,7 +73,7 @@ def check(tag, cond):
def main(): def main():
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
gv.DB_PATH = os.path.join(tmp, 'test.db') db.DB_PATH = os.path.join(tmp, 'test.db')
# V1: ulang ≥3x saat node timeout → hits, lalu sukses # V1: ulang ≥3x saat node timeout → hits, lalu sukses
calls = {'n': 0} calls = {'n': 0}
@@ -104,7 +108,8 @@ def main():
norm = [n for n in norm if n is not None] norm = [n for n in norm if n is not None]
norm_owners = [n['owner'] for n in norm] norm_owners = [n['owner'] for n in norm]
check('V4 sentinel "0" dilewati', gv.ZERO_OWNER not in norm_owners) check('V4 sentinel "0" dilewati', gv.ZERO_OWNER not in norm_owners)
check('V5 hanya BP target', all(n['owner'] in ('aaa1', 'ccc3') for n in norm)) check('V5 hanya BP target', all(n['owner'] in ('aaa1', 'ccc3', 'zzz9')
for n in norm))
by = {n['owner']: n for n in norm} by = {n['owner']: n for n in norm}
aaa1_w = _fresh_weight(12000000) aaa1_w = _fresh_weight(12000000)
check('V6 bobot verbatim TEXT', by['aaa1']['weight'] == aaa1_w) check('V6 bobot verbatim TEXT', by['aaa1']['weight'] == aaa1_w)
@@ -113,19 +118,27 @@ def main():
check('staked ÷10000', by['aaa1']['staked'] == 1200.0) check('staked ÷10000', by['aaa1']['staked'] == 1200.0)
check('staked batas ≥1000 ÷10000', by['ccc3']['staked'] == 1000.0) check('staked batas ≥1000 ÷10000', by['ccc3']['staked'] == 1000.0)
# V13: last_vote diturunkan di normalize; baris basi dibuang (⊥ disimpan) # V13: last_vote diturunkan di normalize; V40: basi tetap disimpan
check('V13 last_vote ikut dinormalisasi', 'last_vote' in by['aaa1']) check('V13 last_vote ikut dinormalisasi', 'last_vote' in by['aaa1'])
check('V13 last_vote segar (≤ VEX_STALE_DAYS)', check('V13 last_vote segar (≤ VEX_STALE_DAYS)',
datetime.fromisoformat(by['aaa1']['last_vote']) datetime.fromisoformat(by['aaa1']['last_vote'])
>= datetime.now() - timedelta(days=gv.VEX_STALE_DAYS)) >= datetime.now() - timedelta(days=28))
check('V13 basi (zzz9) dibuang di normalize', check('V40 basi (zzz9) tetap disimpan', by['zzz9']['owner'] == 'zzz9')
all(n['owner'] != 'zzz9' for n in norm)) check('V40 last_vote basi zzz9 ~900 hari lalu',
(datetime.now() - datetime.fromisoformat(by['zzz9']['last_vote'])).days
> 800)
# V13: formula — staked_raw × 2^26 → ~2026-01-01 (±2 hari, toleransi 365.25 hari/tahun) # V13: formula — staked_raw × 2^(pekan/52), kuantisasi pekan utuh sejak 2000
lv = gv.derive_last_vote(f'{12000000 * 2 ** 26}.00000000000000000', 1200.0) # 2^1 → 52 pekan setelah 2000-01-01 = 2000-12-30 (52×7 = 364 hari)
lv = gv.derive_last_vote(f'{12000000 * 2 ** 1:.17f}', 1200.0)
lv_dt = datetime.fromisoformat(lv) if lv else None lv_dt = datetime.fromisoformat(lv) if lv else None
check('V13 formula 2^26 → ~2026-01-01', check('V13 formula 2^1 (52 pekan) → 2000-12-30',
lv_dt is not None and abs((lv_dt - datetime(2026, 1, 1)).days) <= 2) lv_dt is not None and lv_dt == datetime(2000, 12, 30))
# 2^26 → 26×52 pekan = 1352 pekan setelah 2000 = 2025-11-29
lv = gv.derive_last_vote(f'{12000000 * 2 ** 26:.17f}', 1200.0)
lv_dt = datetime.fromisoformat(lv) if lv else None
check('V13 formula 2^26 (1352 pekan) → 2025-11-29',
lv_dt is not None and lv_dt == datetime(2025, 11, 29))
check('V13 rasio 1.0 → epoch 2000', check('V13 rasio 1.0 → epoch 2000',
gv.derive_last_vote('12000000.00000000000000000', 1200.0) == '2000-01-01T00:00:00') gv.derive_last_vote('12000000.00000000000000000', 1200.0) == '2000-01-01T00:00:00')
check('V13 bobot nol → None', gv.derive_last_vote('0', 1200.0) is None) check('V13 bobot nol → None', gv.derive_last_vote('0', 1200.0) is None)
@@ -133,12 +146,23 @@ def main():
check('V13 bobot tak valid → None', gv.derive_last_vote('abc', 1200.0) is None) check('V13 bobot tak valid → None', gv.derive_last_vote('abc', 1200.0) is None)
check('V13 stake nol → None', gv.derive_last_vote('1.0', 0.0) is None) check('V13 stake nol → None', gv.derive_last_vote('1.0', 0.0) is None)
# V13: normalize membuang basi / tak dapat diverifikasi, menyimpan segar # V13/B12: regresi formula pekan utuh — baris nyata dari chain
check('V13 normalize basi → None', # ..tg: staked_raw 9000, weight → t×52 = 1291 pekan → 2024-09-28
gv.normalize({'owner': 'old8', 'producers': [gv.TARGET_BP], lv = gv.derive_last_vote('267849632990.26596069335937500', 9000 / 10000)
check('V13 regresi ..tg (1291 pekan) → 2024-09-28',
lv == '2024-09-28T00:00:00')
# 1.crownz: staked_raw 17115095, t×52 = 1314 pekan → 2025-03-08
lv = gv.derive_last_vote(
f'{17115095 * 2 ** (1314 / 52.0):.17f}', 17115095 / 10000)
check('V13 regresi 1.crownz (1314 pekan) → 2025-03-08',
lv == '2025-03-08T00:00:00')
# V13/V40: normalize menyimpan basi, membuang yang tak dapat diverifikasi
old = gv.normalize({'owner': 'old8', 'producers': [gv.TARGET_BP],
'staked': 20000000, 'staked': 20000000,
'last_vote_weight': _fresh_weight(20000000, days_ago=900)}) 'last_vote_weight': _fresh_weight(20000000, days_ago=900)})
is None) check('V40 normalize basi (900 hari) → disimpan',
old is not None and old['owner'] == 'old8')
check('V13 normalize bobot kosong (unverifiable) → None', check('V13 normalize bobot kosong (unverifiable) → None',
gv.normalize({'owner': 'unk7', 'producers': [gv.TARGET_BP], gv.normalize({'owner': 'unk7', 'producers': [gv.TARGET_BP],
'staked': 20000000, 'last_vote_weight': ''}) is None) 'staked': 20000000, 'last_vote_weight': ''}) is None)
@@ -149,10 +173,11 @@ def main():
# V13: estimasi yang melewati now (unstake tanpa revote) diklamp ke now # V13: estimasi yang melewati now (unstake tanpa revote) diklamp ke now
fixed_now = datetime(2026, 8, 5, 12, 0, 0) fixed_now = datetime(2026, 8, 5, 12, 0, 0)
t_future = ((fixed_now - gv.WEIGHT_EPOCH).days + 5) / 365.25 weeks_future = ((fixed_now - gv.WEIGHT_EPOCH).days + 14) // 7 # 2 pekan ke depan
fut = gv.normalize({'owner': 'fut9', 'producers': [gv.TARGET_BP], fut = gv.normalize({'owner': 'fut9', 'producers': [gv.TARGET_BP],
'staked': 20000000, 'staked': 20000000,
'last_vote_weight': f'{20000000 * 2 ** t_future:.17f}'}, 'last_vote_weight':
f'{20000000 * 2 ** (weeks_future / 52.0):.17f}'},
now=fixed_now) now=fixed_now)
check('V13 last_vote masa depan diklamp ke now', check('V13 last_vote masa depan diklamp ke now',
fut is not None fut is not None
@@ -179,8 +204,8 @@ def main():
{'owner': 'aaa1', 'weight': '111.0', 'staked': 1200.0}, {'owner': 'aaa1', 'weight': '111.0', 'staked': 1200.0},
{'owner': 'ccc3', 'weight': '222.0', 'staked': 1000.0}, {'owner': 'ccc3', 'weight': '222.0', 'staked': 1000.0},
] ]
gv.persist(set_a) scanned_a = gv.persist(set_a)
conn = sqlite3.connect(gv.DB_PATH) conn = sqlite3.connect(db.DB_PATH)
cnt = conn.execute('SELECT COUNT(*) FROM voters').fetchone()[0] cnt = conn.execute('SELECT COUNT(*) FROM voters').fetchone()[0]
conn.close() conn.close()
check('V7 snapshot pertama = 2', cnt == 2) check('V7 snapshot pertama = 2', cnt == 2)
@@ -189,8 +214,8 @@ def main():
{'owner': 'aaa1', 'weight': '999.0', 'staked': 5000.0}, {'owner': 'aaa1', 'weight': '999.0', 'staked': 5000.0},
{'owner': 'ddd4', 'weight': '333.0', 'staked': 2000.0}, {'owner': 'ddd4', 'weight': '333.0', 'staked': 2000.0},
] ]
gv.persist(set_b) scanned_b = gv.persist(set_b)
conn = sqlite3.connect(gv.DB_PATH) conn = sqlite3.connect(db.DB_PATH)
rows = conn.execute('SELECT owner, weight, staked, scanned_at FROM voters').fetchall() rows = conn.execute('SELECT owner, weight, staked, scanned_at FROM voters').fetchall()
by = {r[0]: r for r in rows} by = {r[0]: r for r in rows}
cols = [r[1] for r in conn.execute('PRAGMA table_info(voters)').fetchall()] cols = [r[1] for r in conn.execute('PRAGMA table_info(voters)').fetchall()]
@@ -205,6 +230,164 @@ def main():
check('V7 owner PRIMARY KEY', 'owner' in cols and pk == ['owner']) check('V7 owner PRIMARY KEY', 'owner' in cols and pk == ['owner'])
check('V7 kolom last_vote ada', 'last_vote' in cols) check('V7 kolom last_vote ada', 'last_vote' in cols)
# V62: stempel scan UTC-naive (konsisten dgn cutoff umur dashboard/
# distribusi yang pakai `datetime.now(timezone.utc)`). Dulu `datetime.now()`
# (waktu lokal container, TZ=Asia/Jakarta UTC+7) → `first_seen_at` WIB
# dibandingkan sbg string UTC → maturity pemilih BARU bergeser +7 jam
# (B24). Guard: stamp ≈ now-UTC-naive, ⊖ WIB (offset ±2 mnt toleransi).
utc_now = datetime.now(timezone.utc).replace(tzinfo=None)
for s in (scanned_a, scanned_b):
stamp = datetime.fromisoformat(s)
delta = abs((utc_now - stamp).total_seconds())
check(f'V62 stempel scan UTC-naive ({s})',
delta < 120 and stamp.tzinfo is None)
wib_delta = abs((utc_now - timedelta(hours=7) - stamp).total_seconds())
check(f'V62 stempel scan ⊖ WIB ({s})', wib_delta > 60)
# V41: first_seen — kemunculan pertama tiap owner, idempoten (⊥ overwrite)
db.record_first_seen([{'owner': 'aaa1'}], '2000-01-01T00:00:00')
conn = sqlite3.connect(db.DB_PATH)
fs = {r[0]: r[1] for r in conn.execute(
'SELECT owner, first_seen_at FROM voter_first_seen').fetchall()}
conn.close()
check('V41 first_seen tercatat utk semua owner',
set(fs) == {'aaa1', 'ccc3', 'ddd4'})
check('V41 first_seen = kemunculan pertama',
fs['aaa1'] == scanned_a and fs['ccc3'] == scanned_a
and fs['ddd4'] == scanned_b)
check('V41 first_seen idempoten (record ulang ⊥ overwrite)',
fs['aaa1'] == scanned_a and fs['aaa1'] != '2000-01-01T00:00:00')
# V66: voter_notify — tabel lacak notifikasi VOTE ULANG SEGERA
db.ensure_notify_schema()
conn = sqlite3.connect(db.DB_PATH)
cols = [r[1] for r in conn.execute(
'PRAGMA table_info(voter_notify)').fetchall()]
pk = [r[1] for r in conn.execute(
'PRAGMA table_info(voter_notify)').fetchall() if r[5]]
conn.close()
check('V66 voter_notify tabel ada', 'owner' in cols)
check('V66 voter_notify PK = owner', pk == ['owner'])
check('V66 urgency kolom ada', 'urgency' in cols)
check('V66 notified_at kolom ada', 'notified_at' in cols)
# record_notify: insert baru
db.record_notify('aaa1', 1, '2026-08-27T08:00:00')
check('V66 get_notify_urgency(aaa1) = 1', db.get_notify_urgency('aaa1') == 1)
check('V66 get_notify_urgency(unk) = None', db.get_notify_urgency('unk9') is None)
# record_notify: idempoten (INSERT OR IGNORE) — urgency tetap 1
db.record_notify('aaa1', 1, '2026-08-27T08:00:00')
check('V66 record_notify idempoten', db.get_notify_urgency('aaa1') == 1)
# record_notify: urgency naik → update
db.record_notify('aaa1', 2, '2026-08-28T08:00:00')
check('V66 urgency naik 1→2', db.get_notify_urgency('aaa1') == 2)
# urgency turun → ⊥ update
db.record_notify('aaa1', 1, '2026-08-29T08:00:00')
check('V66 urgency turun 2→1 ⊥ update', db.get_notify_urgency('aaa1') == 2)
# cleanup_notify: hapus baris tua
db.record_notify('old1', 1, '2026-08-01T00:00:00')
db.record_notify('new1', 1, '2026-08-27T00:00:00')
db.cleanup_notify('2026-08-15T00:00:00')
check('V66 cleanup hapus baris tua', db.get_notify_urgency('old1') is None)
check('V66 cleanup simpan baris baru', db.get_notify_urgency('new1') == 1)
# V66: _notify_warn — integrasi dengan telegram.notify_warn
# Siapkan pemilih dalam jendela VOTE ULANG SEGERA (last_vote 26 hari lalu)
warn_now = datetime(2026, 8, 27, 8, 0, 0)
warn_vote = (warn_now - timedelta(days=26)).isoformat(timespec='seconds')
safe_vote = (warn_now - timedelta(days=20)).isoformat(timespec='seconds')
# Masukkan pemilih langsung ke DB (bypass scan)
wrn2_vote = (warn_now - timedelta(days=27)).isoformat(timespec='seconds')
db.replace_snapshot([
{'owner': 'wrn1', 'weight': '1.0', 'staked': 5000.0,
'last_vote': warn_vote},
{'owner': 'wrn2', 'weight': '2.0', 'staked': 3000.0,
'last_vote': wrn2_vote},
{'owner': 'safe', 'weight': '3.0', 'staked': 8000.0,
'last_vote': safe_vote},
], warn_now.isoformat(timespec='seconds'))
sent = []
_orig_warn = telegram.notify_warn
telegram.notify_warn = lambda v: sent.append(v) or True
try:
gv._notify_warn(warn_now)
check('V66 _notify_warn dipanggil', len(sent) == 1)
voters_notified = sent[0]
owners_notified = [v[0] for v in voters_notified]
check('V66 wrn2 (27h, ≤2d) masuk', 'wrn2' in owners_notified)
check('V66 wrn1 (26h, >2d) masuk', 'wrn1' in owners_notified)
check('V66 safe (20h, ⊥ window) tak masuk', 'safe' not in owners_notified)
# Cek urgensi
by_owner = {v[0]: v[2] for v in voters_notified}
check('V66 wrn2 urgensi = 3 (≤1d)', by_owner['wrn2'] == 3)
check('V66 wrn1 urgensi = 2 (≤2d)', by_owner['wrn1'] == 2)
# Cek voter_notify tercatat
check('V66 voter_notify wrn2 = 3', db.get_notify_urgency('wrn2') == 3)
check('V66 voter_notify wrn1 = 2', db.get_notify_urgency('wrn1') == 2)
finally:
telegram.notify_warn = _orig_warn
# V66: _notify_warn — skip bila sudah diberi tahu pada urgensi ≥ saat ini
sent.clear()
telegram.notify_warn = lambda v: sent.append(v) or True
try:
gv._notify_warn(warn_now) # wrn1 sudah urgensi 2, wrn2 sudah urgensi 3
check('V66 _notify_warn skip redundancy', len(sent) == 0)
finally:
telegram.notify_warn = _orig_warn
telegram.notify_warn = lambda v: sent.append(v) or True
sent.clear()
# Hapus lacak utk wrn1 supaya bisa test escalation dratch
conn = sqlite3.connect(db.DB_PATH)
conn.execute('DELETE FROM voter_notify WHERE owner = ?', ('wrn1',))
conn.commit()
conn.close()
# Update wrn1 last_vote ke 27 hari lalu (≤1d → urgensi 3)
closer_vote = (warn_now - timedelta(days=27)).isoformat(
timespec='seconds')
db.replace_snapshot([
{'owner': 'wrn1', 'weight': '1.0', 'staked': 5000.0,
'last_vote': closer_vote},
{'owner': 'wrn2', 'weight': '2.0', 'staked': 3000.0,
'last_vote': wrn2_vote},
], warn_now.isoformat(timespec='seconds'))
telegram.notify_warn = lambda v: sent.append(v) or True
try:
gv._notify_warn(warn_now)
check('V66 escalation trigger (2→3)', len(sent) == 1)
by_owner = {v[0]: v[2] for v in sent[0]}
check('V66 wrn1 escalation ke urgensi 3', by_owner['wrn1'] == 3)
check('V66 voter_notify wrn1 updated ke 3',
db.get_notify_urgency('wrn1') == 3)
finally:
telegram.notify_warn = _orig_warn
# V66: _notify_warn — ⊥ catat bila kirim gagal senyap (send_text no-op/Gagal)
# Regresi: token kosong → telegram no-op → record_notify TETAP dicatat dulu
# (dedup palsu memblokir kirim saat scan berikutnya). Kini hanya dicatat
# bila notify_warn berhasil (return True).
sent.clear()
conn = sqlite3.connect(db.DB_PATH)
conn.execute('DELETE FROM voter_notify') # bersihkan semua lacak
conn.commit()
conn.close()
fail_warn = lambda v: sent.append(v) and False # gagal/no-op
telegram.notify_warn = fail_warn
try:
gv._notify_warn(warn_now)
check('V66 kirim gagal → dipanggil', len(sent) == 1)
check('V66 kirim gagal → voter_notify ⊖ dicatat',
db.get_notify_urgency('wrn1') is None)
check('V66 kirim gagal → voter_notify wrn2 ⊖ dicatat',
db.get_notify_urgency('wrn2') is None)
finally:
telegram.notify_warn = _orig_warn
print('\nSEMUA UJI PASS') print('\nSEMUA UJI PASS')
+86
View File
@@ -0,0 +1,86 @@
"""Uji V31: tiap image stack mandiri — daftar COPY memuat semua impor intra-proyek.
`./venv/bin/python test_images.py` harus exit 0. Memindai `Dockerfile`,
`Dockerfile.scan`, `Dockerfile.dist`, `Dockerfile.claim`: untuk tiap modul .py
yang di-COPY, kumpulkan impor intra-proyek (top-level `import X` / `from X
import`) lalu pastikan semuanya ada di daftar COPY image itu. Menangkap kelas
bug B4: modul yang diimpor modul yang di-COPY tapi ⊥ ikut disalin →
ModuleNotFoundError di container (image ⊥ mandiri).
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
PROJECT = {'config', 'db', 'get_voters', 'dashboard', 'gunicorn_conf',
'scan_loop', 'distribute', 'distribute_loop', 'telegram',
'claim', 'claim_loop'}
IMAGES = {
'web': 'Dockerfile',
'scan': 'Dockerfile.scan',
'dist': 'Dockerfile.dist',
'claim': 'Dockerfile.claim',
}
def copied_modules(dfile):
"""Modul .py yang di-COPY oleh `COPY <files...> ./`."""
mods = set()
with open(os.path.join(ROOT, dfile)) as fh:
for line in fh:
if not line.startswith('COPY '):
continue
tokens = line.split()
if tokens[-1] != './':
continue
for tok in tokens[1:-1]:
if tok.endswith('.py'):
mods.add(tok[:-3])
return mods
def project_imports(mod):
"""Impor intra-proyek dari sumber modul `mod`."""
imports = set()
path = os.path.join(ROOT, mod + '.py')
if not os.path.exists(path):
return imports
with open(path) as fh:
src = fh.read()
for m in re.finditer(r'^\s*import\s+([\w.]+)', src, re.M):
name = m.group(1).split('.')[0]
if name in PROJECT:
imports.add(name)
for m in re.finditer(r'^\s*from\s+([\w.]+)\s+import', src, re.M):
name = m.group(1).split('.')[0]
if name in PROJECT:
imports.add(name)
return imports
def check(tag, cond):
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
def main():
for image, dfile in IMAGES.items():
copied = copied_modules(dfile)
check(f'V31 {image}: ada modul .py di COPY',
bool(copied) and 'config' in copied)
for mod in sorted(copied):
missing = project_imports(mod) - copied
if missing:
raise AssertionError(
f'GAGAL: V31 {image}: {mod} impor {sorted(missing)} '
f'yang ⊥ di-COPY')
print(f'ok: V31 {image}: {mod} impor ⊆ COPY')
print('\nSEMUA UJI PASS')
if __name__ == '__main__':
sys.exit(main())
+101
View File
@@ -0,0 +1,101 @@
"""Uji integrasi backend MariaDB/MySQL (opt-in, ⊥ dijalankan otomatis).
Oracle utama (test_get_voters.py / test_dashboard.py) tetap pakai sqlite agar
hermetik. Suite ini hanya jalan saat `VEX_DB_BACKEND=mysql` (dengan VEX_DB_*
yang menunjuk container docker-compose):
VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 \
VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid \
./venv/bin/python test_mariadb.py
Selain itu ia `skip` (exit 0 tanpa menjalankan apa pun). Menyalakan backend
mysql hanya lewat env — jangan pernah menyetel default sqlite ke mysql.
"""
import os
import sys
import get_voters as gv
def check(tag, cond):
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
def main():
if os.getenv('VEX_DB_BACKEND') != 'mysql':
print('skip: VEX_DB_BACKEND != mysql (backend sqlite default, tetap aman)')
return
import db
# ————— snapshot roundtrip: replace_snapshot → query —————
rows_a = [
{'owner': 'aaa1', 'weight': '111.00000000000000000', 'staked': 5000.0, 'last_vote': '2026-07-01T00:00:00'},
{'owner': 'ccc3', 'weight': '222.00000000000000000', 'staked': 3000.0, 'last_vote': '2026-07-02T00:00:00'},
]
scanned_a = gv.persist(rows_a)
got = db.query('SELECT owner, weight, staked, scanned_at, last_vote '
'FROM voters ORDER BY staked DESC')
check('mysql roundtrip: 2 baris sesuai urutan', [r[0] for r in got] == ['aaa1', 'ccc3'])
check('mysql roundtrip: weight verbatim TEXT', got[0][1] == '111.00000000000000000')
check('mysql roundtrip: staked float', isinstance(got[0][2], float) and got[0][2] == 5000.0)
check('mysql roundtrip: scanned_at terisi', got[0][3] == scanned_a)
check('mysql roundtrip: last_vote TEXT', got[0][4] == '2026-07-01T00:00:00')
# ————— snapshot ganti: baris lama hilang, tanpa PK clash —————
rows_b = [
{'owner': 'aaa1', 'weight': '999.00000000000000000', 'staked': 9000.0, 'last_vote': '2026-07-03T00:00:00'},
{'owner': 'ddd4', 'weight': '333.00000000000000000', 'staked': 2000.0, 'last_vote': '2026-07-04T00:00:00'},
]
gv.persist(rows_b)
got = db.query('SELECT owner, weight, staked, last_vote FROM voters ORDER BY owner')
by = {r[0]: r for r in got}
check('mysql ganti snapshot: |set_b|', len(got) == 2)
check('mysql ganti snapshot: ccc3 dihapus', 'ccc3' not in by)
check('mysql ganti snapshot: ddd4 baru', 'ddd4' in by)
check('mysql ganti snapshot: bobot terbaru', by['aaa1'][1] == '999.00000000000000000')
# ————— placeholder `%s` (LIKE) & LIMIT/OFFSET diterjemahkan —————
like = db.query(
"SELECT owner FROM voters WHERE owner LIKE %s ESCAPE '!' "
'ORDER BY owner LIMIT %s OFFSET %s',
['%aa%', 10, 0],
)
check('mysql LIKE %s + LIMIT %s', [r[0] for r in like] == ['aaa1'])
like_none = db.query(
"SELECT owner FROM voters WHERE owner LIKE %s ESCAPE '!'",
['%zzz%'],
)
check('mysql LIKE tak cocok → kosong', like_none == [])
# ————— queryone & COALESCE/agregat —————
cnt = db.queryone('SELECT COUNT(*) FROM voters')[0]
check('mysql COUNT(*)', cnt == 2)
scanned = db.queryone("SELECT COALESCE(MAX(scanned_at), '') FROM voters")[0]
check('mysql MAX(scanned_at) non-empty', isinstance(scanned, str) and scanned != '')
# ————— V41: voter_first_seen — INSERT IGNORE idempoten (⊥ overwrite) —————
db.record_first_seen([{'owner': 'aaa1'}, {'owner': 'ddd4'}], '2026-08-01T00:00:00')
db.record_first_seen([{'owner': 'aaa1'}], '2026-08-02T00:00:00')
fs = {r[0]: r[1] for r in db.query(
'SELECT owner, first_seen_at FROM voter_first_seen ORDER BY owner')}
check('mysql first_seen idempoten',
fs == {'aaa1': '2026-08-01T00:00:00', 'ddd4': '2026-08-01T00:00:00'})
# ————— rank via correlated-count (global, sama semantik sqlite) —————
ranked = db.query(
'SELECT v.owner, (SELECT COUNT(*) FROM voters v2 '
'WHERE v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn '
'FROM voters v ORDER BY v.staked DESC, v.owner ASC'
)
check('mysql rank global', [r[1] for r in ranked] == [1, 2])
print('selesai: semua uji MariaDB lolos')
if __name__ == '__main__':
main()