Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c262b4677c | ||
|
|
8ad7df00f0 | ||
|
|
1e93437a90 | ||
|
|
eaefad6c1d | ||
|
|
9e092ebdbc | ||
|
|
5c1c7572d3 | ||
|
|
efd0e9374b | ||
|
|
56ff265d11 | ||
|
|
d6b5927307 | ||
|
|
69704fa23d | ||
|
|
663770f51b | ||
|
|
fb16eeafaa | ||
|
|
8e80653df9 | ||
|
|
ee4682da00 | ||
|
|
2f043c20bd | ||
|
|
a5e8367363 | ||
|
|
cd1452a353 | ||
|
|
d6a15a07ae | ||
|
|
9b040a8c71 | ||
|
|
5618bad427 | ||
|
|
642822989c | ||
|
|
0e83137b5a | ||
|
|
e9df89aaac | ||
|
|
cc9df71d9e | ||
|
|
a6db3be436 | ||
|
|
bdb5923097 | ||
|
|
81ec6bdfe7 | ||
|
|
af9a0291b9 | ||
|
|
192e27aff3 | ||
|
|
2f1f19c435 | ||
|
|
b1e6e93796 | ||
|
|
d3a0e768a0 | ||
|
|
0b1749a062 | ||
|
|
025f46d399 | ||
|
|
397e20ec3a | ||
|
|
83e294dec8 | ||
|
|
4e02701887 | ||
|
|
f77681793f | ||
|
|
d30dd8b292 | ||
|
|
3ad5eb32d2 | ||
|
|
344de409cd | ||
|
|
a3251eebc4 | ||
|
|
e4fa6bf1dc | ||
|
|
9a269e224c | ||
|
|
8ab8001b34 |
No files matched your search
@@ -7,6 +7,11 @@ 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
|
||||||
|
|
||||||
@@ -34,11 +39,39 @@ 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
|
||||||
@@ -51,6 +84,48 @@ SCAN_HOURS=0,8,16
|
|||||||
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
|
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
|
||||||
SCAN_RUN_ON_START=1
|
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)
|
# Kredensial mariadb stack — wajib konsisten antar-service (mariadb/web/scan)
|
||||||
MARIADB_ROOT_PASSWORD=root
|
MARIADB_ROOT_PASSWORD=root
|
||||||
MARIADB_DATABASE=databisnisid
|
MARIADB_DATABASE=databisnisid
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +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` + `gunicorn` + `pymysql` (mysql backend only) (`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`.
|
||||||
|
- 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.
|
||||||
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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:
|
- 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`
|
`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` — 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 image), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + 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`). `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` 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`.
|
- 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`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
|
- 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_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`, `DATABISNIS_API`, `DASH_LIQUID_TTL`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
|
- 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 test_get_voters.py test_dashboard.py test_mariadb.py`.
|
- 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` 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.
|
- 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
|
||||||
|
|
||||||
@@ -24,22 +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) → `db.replace_snapshot` (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.
|
- `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 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.
|
- `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`, `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`, `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.
|
- `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).
|
||||||
- `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`). Di stack produksi keduanya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
|
- `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).
|
||||||
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan); 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`. `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`).
|
- `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).
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -10,6 +10,6 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY config.py db.py get_voters.py scan_loop.py ./
|
COPY config.py db.py get_voters.py scan_loop.py telegram.py ./
|
||||||
|
|
||||||
CMD ["python", "scan_loop.py"]
|
CMD ["python", "scan_loop.py"]
|
||||||
@@ -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; `pymysql` utk backend mysql (opt-in); 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: `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`
|
- 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,7 +22,28 @@ 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` (⊥ `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 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`
|
- 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`
|
||||||
@@ -30,8 +53,27 @@ db: `db.py` → table `voters` (owner PK, weight, staked, scanned_at, last_vote)
|
|||||||
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_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`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `SCAN_HOURS`, `SCAN_RUN_ON_START`, `TZ` — 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)
|
||||||
@@ -40,17 +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`)
|
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`) di node mana pun, terhubung via overlay `appnet`; image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` (⊥ `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); 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)
|
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
|
||||||
@@ -72,9 +159,72 @@ T15|x|hapus bobot suara + UI basi di web (kolom/stats/penanda/filter `?stale=1`)
|
|||||||
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
|
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
|
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)
|
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)
|
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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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,6 +17,17 @@ 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')
|
||||||
|
|
||||||
@@ -42,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'
|
||||||
@@ -6,21 +6,56 @@ API databisnis dengan cache TTL singkat (V16). Styling per `DESIGN.md`.
|
|||||||
Konfigurasi dari env/`.env`.
|
Konfigurasi dari env/`.env`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
import db
|
import db
|
||||||
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
|
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, HYPERION_NODES,
|
||||||
PAGE_SIZE, TARGET_BP)
|
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,29 +79,171 @@ 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
|
||||||
try:
|
for node in HYPERION_NODES:
|
||||||
resp = requests.get(
|
try:
|
||||||
f'{DATABISNIS_API}/v2/state/get_account',
|
resp = requests.get(
|
||||||
params={'account': TARGET_BP},
|
f'{node}/v2/state/get_account',
|
||||||
timeout=8,
|
params={'account': TARGET_BP},
|
||||||
)
|
timeout=8,
|
||||||
resp.raise_for_status()
|
)
|
||||||
raw = resp.json().get('account', {}).get('core_liquid_balance')
|
resp.raise_for_status()
|
||||||
if raw:
|
data = resp.json()
|
||||||
value = float(raw.split()[0])
|
# V61: bentuk tak dikenal (list/string/proxy salah) → lewati host
|
||||||
except (requests.RequestException, KeyError, TypeError, ValueError):
|
raw = None
|
||||||
value = _liquid_cache['value'] # gagal → nilai lama bila ada
|
if isinstance(data, dict):
|
||||||
|
raw = data.get('account', {}).get('core_liquid_balance')
|
||||||
|
if raw:
|
||||||
|
value = float(raw.split()[0])
|
||||||
|
break
|
||||||
|
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
|
||||||
|
|
||||||
@@ -83,58 +260,287 @@ def _escape_like(q):
|
|||||||
.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 %s 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, '
|
||||||
|
' 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'
|
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
|
||||||
)
|
)
|
||||||
return db.query(sql, params + [PAGE_SIZE, offset])
|
rows = db.query(sql, [exp_cut, new_cut] + rw_params
|
||||||
|
+ [exp_cut, new_cut]
|
||||||
|
+ params + [PAGE_SIZE, offset])
|
||||||
|
return [
|
||||||
|
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`
|
||||||
|
(maturity dari `first_seen_at` — B15); tag REVOTE 1-hari & badge peringatan
|
||||||
|
⊥ ubah cakupan."""
|
||||||
|
new_cut, _warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
|
||||||
|
where, params = _filter_sql(q, 'v.')
|
||||||
|
mature = ('fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s')
|
||||||
|
combined = ('WHERE v.last_vote > %s AND ' + mature
|
||||||
|
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
|
||||||
return db.queryone(
|
return db.queryone(
|
||||||
'SELECT COUNT(*) FROM voters ' + where, params
|
'SELECT COUNT(*) FROM voters v '
|
||||||
)[0]
|
'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).
|
||||||
|
|
||||||
|
V40/V52: cakupan = semua baris TAMPIL (BARU + VALID + KADALUARSA,
|
||||||
|
⊖ yang tersembunyi > 31 hari). V52: maturity dari `first_seen_at`
|
||||||
|
(presisi detik, ⊥ `last_vote` terkuantisasi pekan — B15): 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`.
|
||||||
|
Invariant: n_new + n_valid + n_expired == total_voters (partisi semua
|
||||||
|
baris tampil). V43: `n_revote` (legenda mint) = subset VALID yg revote
|
||||||
|
< `VEX_REVOTE_TAG_DAYS` (`last_vote > revote_cut`); `n_expiring` =
|
||||||
|
subset VALID dgn `exp_cut < last_vote ≤ warn_cut` utk badge peringatan.
|
||||||
|
"""
|
||||||
|
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
|
||||||
|
revote_cut = _revote_cut()
|
||||||
row = db.queryone(
|
row = db.queryone(
|
||||||
'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) '
|
'SELECT COUNT(*), COALESCE(MAX(staked), 0) '
|
||||||
'FROM voters'
|
'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],
|
||||||
)
|
)
|
||||||
total_voters, total_staked, top_staked = row
|
n_new, n_revote, n_expired, n_valid, n_expiring = counts
|
||||||
scanned = db.queryone(
|
scanned = db.queryone(
|
||||||
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters'
|
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters')[0]
|
||||||
)[0]
|
return (total_voters, top_staked, scanned,
|
||||||
return total_voters, total_staked, 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('/')
|
||||||
@@ -144,57 +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()
|
||||||
filtered_count = _count(q)
|
|
||||||
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
|
||||||
page = min(page, total_pages)
|
|
||||||
rows = _db_rows(page, q)
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
|
page = min(page, total_pages)
|
||||||
|
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 %s'
|
|
||||||
)
|
|
||||||
rows = db.query(sql, params + [SEARCH_CAP])
|
|
||||||
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)
|
||||||
@@ -8,7 +8,7 @@ skema `voters` yang sama; query berbagi sintaks ditulis dengan placeholder
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from config import (DB_BACKEND, DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_PATH,
|
from config import (DB_BACKEND, DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_PATH,
|
||||||
DB_USER)
|
DB_USER, VEX_MATURITY_DAYS, VEX_STALE_DAYS)
|
||||||
|
|
||||||
# Skema voters — sama untuk kedua backend (I.db)
|
# Skema voters — sama untuk kedua backend (I.db)
|
||||||
_COLS = (
|
_COLS = (
|
||||||
@@ -95,3 +95,425 @@ def replace_snapshot(rows, scanned_at):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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'))
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
|
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
|
||||||
#
|
#
|
||||||
# 3 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
|
# 5 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5001),
|
||||||
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ).
|
# 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:
|
# Catatan swarm:
|
||||||
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
|
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
|
||||||
@@ -46,10 +48,14 @@ services:
|
|||||||
DASH_HOST: "0.0.0.0" # wajib di container → gunicorn bind terbuka utk ingress
|
DASH_HOST: "0.0.0.0" # wajib di container → gunicorn bind terbuka utk ingress
|
||||||
DASH_PORT: "5000"
|
DASH_PORT: "5000"
|
||||||
DASH_WORKERS: "2"
|
DASH_WORKERS: "2"
|
||||||
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
|
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
|
||||||
DASH_LIQUID_TTL: "60"
|
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:
|
ports:
|
||||||
- published: 5000
|
- published: 5001
|
||||||
target: 5000
|
target: 5000
|
||||||
networks:
|
networks:
|
||||||
- appnet
|
- appnet
|
||||||
@@ -61,6 +67,9 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- node.hostname != server2U
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: any
|
condition: any
|
||||||
|
|
||||||
@@ -77,17 +86,75 @@ services:
|
|||||||
TZ: ${TZ:-Asia/Jakarta}
|
TZ: ${TZ:-Asia/Jakarta}
|
||||||
SCAN_HOURS: ${SCAN_HOURS:-0,8,16}
|
SCAN_HOURS: ${SCAN_HOURS:-0,8,16}
|
||||||
SCAN_RUN_ON_START: ${SCAN_RUN_ON_START:-1}
|
SCAN_RUN_ON_START: ${SCAN_RUN_ON_START:-1}
|
||||||
|
volumes:
|
||||||
|
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
|
||||||
networks:
|
networks:
|
||||||
- appnet
|
- appnet
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
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:
|
restart_policy:
|
||||||
condition: any
|
condition: any
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
appnet:
|
appnet:
|
||||||
driver: overlay
|
driver: overlay
|
||||||
|
|
||||||
# volume khusus stack — jangan pakai mariadb_data (milik docker-compose.dev.yml)
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
"""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
|
||||||
@@ -12,7 +15,9 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
import db
|
import db
|
||||||
from config import API_NODE, MIN_STAKED_VEX, TARGET_BP, VEX_STALE_DAYS
|
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,
|
||||||
@@ -150,9 +153,13 @@ def persist(rows):
|
|||||||
Delegasi ke `db.replace_snapshot` (backend sqlite/mysql):
|
Delegasi ke `db.replace_snapshot` (backend sqlite/mysql):
|
||||||
sqlite DROP+CREATE dalam WAL → pembaca (dashboard multi-worker) tak
|
sqlite DROP+CREATE dalam WAL → pembaca (dashboard multi-worker) tak
|
||||||
terblokir; mysql DELETE+INSERT dalam satu transaksi (MVCC snapshot).
|
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(
|
||||||
|
timespec='seconds')
|
||||||
db.replace_snapshot(rows, scanned_at)
|
db.replace_snapshot(rows, scanned_at)
|
||||||
|
db.record_first_seen(rows, scanned_at)
|
||||||
return scanned_at
|
return scanned_at
|
||||||
|
|
||||||
|
|
||||||
@@ -170,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}"...')
|
||||||
@@ -181,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
|
||||||
|
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ flask>=3.0
|
|||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
gunicorn>=22.0
|
gunicorn>=22.0
|
||||||
PyMySQL>=1.1
|
PyMySQL>=1.1
|
||||||
|
pyntelope>=0.1.2
|
||||||
@@ -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('');
|
||||||
|
|||||||
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 233 B |
@@ -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 |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -42,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)
|
||||||
|
|
||||||
|
# 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)
|
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)
|
||||||
@@ -85,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)
|
||||||
@@ -94,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)
|
||||||
@@ -131,6 +331,53 @@ 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)
|
||||||
@@ -142,11 +389,50 @@ def main():
|
|||||||
resp = client.get('/api/search?q=acct!%')
|
resp = client.get('/api/search?q=acct!%')
|
||||||
check('V17 wildcard `%` di-escape (bukan pola)', resp.get_json()['count'] == 0)
|
check('V17 wildcard `%` di-escape (bukan pola)', resp.get_json()['count'] == 0)
|
||||||
|
|
||||||
# rank HARUS global, bukan dalam-filter: 'acct00' menyentuh baris stake kecil
|
# 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
|
||||||
@@ -157,17 +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_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_HOST/PORT', config.DB_HOST == '127.0.0.1' and config.DB_PORT == 3306)
|
||||||
check('V17 config default DB_USER/PASS/NAME',
|
check('V17 config default DB_USER/PASS/NAME',
|
||||||
(config.DB_USER, config.DB_PASS, config.DB_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
|
||||||
@@ -198,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):
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -2,25 +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 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:
|
||||||
@@ -105,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)
|
||||||
@@ -114,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)
|
||||||
@@ -134,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)
|
||||||
@@ -150,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
|
||||||
@@ -180,7 +204,7 @@ 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(db.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()
|
||||||
@@ -190,7 +214,7 @@ 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(db.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}
|
||||||
@@ -206,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')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -77,6 +77,14 @@ def main():
|
|||||||
scanned = db.queryone("SELECT COALESCE(MAX(scanned_at), '') FROM voters")[0]
|
scanned = db.queryone("SELECT COALESCE(MAX(scanned_at), '') FROM voters")[0]
|
||||||
check('mysql MAX(scanned_at) non-empty', isinstance(scanned, str) and scanned != '')
|
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) —————
|
# ————— rank via correlated-count (global, sama semantik sqlite) —————
|
||||||
ranked = db.query(
|
ranked = db.query(
|
||||||
'SELECT v.owner, (SELECT COUNT(*) FROM voters v2 '
|
'SELECT v.owner, (SELECT COUNT(*) FROM voters v2 '
|
||||||
|
|||||||