Compare commits

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

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

config.py: HYPERION_NODES (CSV VEX_HYPERION_NODES, default HYPERION_API +
API_NODES dedup, utama di depan — kedua host mainnet terverifikasi layani
/v2, probe 2026-08-13) ; distribute._get_json GET mirror _post_json V58
(3 putaran, 2s, coba semua host per putaran, semua gagal → None) dipakai
verify_txid — None kini berarti SEMUA host Hyperion gagal, hold V59 jarang
palsu ; claim.reward_from_tx via dist._get_json (tiap attempt settle V55
pool-robust) ; dashboard._get_liquid_vex loop HYPERION_NODES lokal (web ⊖
impor distribute, V31), SALDO LIQUID tetap live saat host mati

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

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

image web + dist + claim rebuild + push
2026-08-13 21:22:33 +07:00
proitlab fb16eeafaa distribusi hardened vs node mati + send ⊖ pernah menyesatkan (V58/V59)
V58 failover node pool: config += API_NODES (CSV VEX_API_NODES, default
v2.vexascan.com:2096 + https://api.databisnis.id, mainnet terverifikasi) ;
distribute _post_json coba SEMUA node per putaran (semua mati → RuntimeError),
build_signed_transfer coba tiap node utk link+sign (net ter-bind → send ikut
node sama), fetch_balance_with_retry backoff [30..180]s ~10 mnt real-run /
dry-run satu attempt ; scan/claim tetap API_NODE tunggal, fee klaim mewarisi
failover gratis ; oracle failover saldo + semua-mati raise + backoff + mock Net

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

image dist + claim rebuild + push (claim impor distribute)
2026-08-13 20:52:32 +07:00
24 changed files with 1120 additions and 159 deletions

No files matched your search

+17 -2
View File
@@ -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
@@ -58,8 +63,15 @@ VEX_REVOTE_TAG_DAYS=1
# SEGERA` (segera kadaluarsa, masih dibayar). # SEGERA` (segera kadaluarsa, masih dibayar).
VEX_WARN_DAYS=3 VEX_WARN_DAYS=3
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard) # Hyperion API untuk saldo liquid akun + verifikasi txid (balance BP,
DATABISNIS_API=https://api.databisnis.id # 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
@@ -87,6 +99,9 @@ DISTRIBUTE_HOUR=10
DISTRIBUTE_WEEKDAY=wed,sat DISTRIBUTE_WEEKDAY=wed,sat
DISTRIBUTE_FIRST_RUN=2026-08-17 DISTRIBUTE_FIRST_RUN=2026-08-17
DISTRIBUTE_MAX_ATTEMPTS=3 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 # Kill-switch: FALSE (default) = payout off; set TRUE untuk mengaktifkan
# pembayaran. --dry-run tetap jalan walau off. # pembayaran. --dry-run tetap jalan walau off.
DISTRIBUTE_ENABLED=false DISTRIBUTE_ENABLED=false
+13 -13
View File
@@ -6,14 +6,14 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- 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) + `pyntelope` (distribute signing) (`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. - 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. - 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` + `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`. - Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5001`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` twice-weekly at `DISTRIBUTE_WEEKDAY` default `wed,sat` (CSV, multi-day V44) + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42/V52): BARU (new first-time voters, `first_seen_at` within 3 days, own table on top, never paid — maturity is measured from `first_seen_at`, NOT `last_vote` which is week-quantized) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with `VOTE ULANG` badge + legend) then VALID (the paged 50/page list, `staked DESC`, live owner search via `/api/search`); voters older than 31 days are stored by the scan but hidden. Since V42, **REVOTE** (returning voters, `voter_first_seen.first_seen_at ≤ now−3d`) are VALID immediately — they join the main list tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`), no separate band, no maturity wait; since V43 the mint tag shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS` (default 1 day) — after that they're plain VALID (still paid); VALID rows within `VEX_WARN_DAYS` (default 3) of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge (legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH`); only genuinely new voters pass the 3-day maturity in BARU. Data freshness comes from the daily scan run — the dashboard never scans. - 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`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `DATABISNIS_API`, `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_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. - Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_API_NODES` (CSV pool RPC failover distribusi V58, default = `VEX_API_NODE` + `https://api.databisnis.id`), `VEX_HYPERION_NODES` (CSV pool Hyperion failover V60, default = `HYPERION_API` + `API_NODES` dedup), `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `HYPERION_API` (rename dari `DATABISNIS_API`; nama lama masih dibaca utk backward-compat), `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY` (CSV multi-hari V44, default `wed,sat`), `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_SEND_DELAY` (jeda detik ANTAR kirim antar pemilih, default 30, V63), `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`. - Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37/V44) is pure calendar logic — weekly schedule boundary (multi-day CSV) + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. - 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.
@@ -26,31 +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/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. 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. - 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) / 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. - 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.
- 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. - 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 {DATABISNIS_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. - 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. - 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. - 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 {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. - 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-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). - `get_voters.py` — production fetcher: scan → filter (stake-min + BP target, semua umur vote disimpan V40; hanya last_vote tak terverifikasi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history) → `db.record_first_seen` (V41: catat kemunculan pertama tiap owner ke `voter_first_seen`, idempoten). V62: `scanned_at`/`first_seen_at` disimpan UTC-naive (`datetime.now(timezone.utc)`) — konsisten dgn SEMUA cutoff umur (dashboard + distribusi) & `last_vote` (epoch UTC); jangan kembali ke `datetime.now()` lokal (B24: pemilih BARU tertahan +7 jam).
- `db.py` — storage abstraction (sqlite default | mysql via PyMySQL); `connect/query/queryone/replace_snapshot`; `%s` → `?` for sqlite; `query` returns list. Juga tabel distribusi: `distribute_runs` + `distribute_payments` (append-only) + helper `ensure_distribute_schema/record_run/record_payment/update_payment_status/update_run_status/list_runs/list_payments`. Juga tabel klaim: `claim_runs` + helper `ensure_claim_schema/record_claim/update_claim_fee/pending_claim_fee`. Juga tabel V41 `voter_first_seen` (owner PK + `first_seen_at`, append-only) + helper `ensure_first_seen_schema/record_first_seen` — dipakai dashboard membedakan PEMILIH BARU vs REVOTE. Juga helper V57 `eligible_voters(owner=None)` — single-source SQL jendela reward (payout window, V40/V52): `last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY` (cutoff UTC-naive); tanpa `owner` → semua baris `(owner, staked)` utk distribusi; dgn `owner` → baris akun itu (`[]`|one row) utk endpoint cek. - `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.
- `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). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB). - `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`) 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`; 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.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. - `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 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 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`. - `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 `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. - `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`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; 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).
- `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). - `distribute_loop.py` — scheduler dua-kali-minggu dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `wed,sat`, CSV multi-hari V44) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context. - `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`; `distribute` & `claim` bawa `VEX_BP_PRIVATE_KEY` via bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (dibaca `config.py` `load_dotenv`; ⊥ interpolasi). `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`). - `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).
+1 -1
View File
@@ -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"]
+31 -6
View File
@@ -12,7 +12,7 @@ Auto-claim: tiap hari di jendela klaim (`last_claim_time` + 24 jam) → `vexcore
- 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
@@ -43,7 +43,7 @@ Auto-claim: tiap hari di jendela klaim (`last_claim_time` + 24 jam) → `vexcore
- 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 - 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 - 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 - ⊥ 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` + `DATABISNIS_API` (verifikasi txid V22); Telegram notif klaim sukses/gagal (best-effort V30) - 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`
@@ -55,7 +55,7 @@ web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 5
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,total_reward}]}`, 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
web: GET `/api/voter/<owner>` → JSON `200 {owner, is_valid_voter}` (payout-eligible check, V57); selalu 200, ⊖ 404; DB-snapshot saja, ⊖ format-validation 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_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`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `DISTRIBUTE_FIRST_RUN` (dashboard banner V38; web service di stack menerima TZ + jadwal) — via `config.py` (`.env`) 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_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/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/push_transaction` (signed via pyntelope) → txid
@@ -65,7 +65,7 @@ db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, t
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) 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 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 web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile
env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_WEEKDAY` (default `sat`), `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`; env override), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`) 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 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) 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: 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
@@ -90,13 +90,13 @@ V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensit
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` = 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) V13: `last_vote` = epoch 2000-01-01 + `round(52 × log2(last_vote_weight / (staked×10000)))` PEKAN (Vexanium/EOSIO `stake2vote`: eksponen = `int64((now − epoch)/(86400×7)) / 52.0` → bobot dikuantisasi pekan utuh; jadi tanggal kelipatan 7 hari, 00:00:00; regresi oracle baris nyata `..tg` 1291 pekan → 2024-09-28, `1.crownz` 1314 pekan → 2025-03-08); None bila bobot/stake nol; estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` dibuang di `normalize` (bobot tak bisa diverifikasi → tanggal tak diketahui, ⊥ bisa diklasifikasi); V40: vote basi (> VEX_STALE_DAYS) TIDAK lagi dibuang di scan — SEMUA umur disimpan; banding/tampil/bayar menghitung umur saat query (⊥ peran scan)
V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA; stats = SALDO LIQUID/TOTAL PEMILIH/STAKE TERTINGGI atas semua baris TAMPIL (V40: ⊖ yang tersembunyi > 31 hari); TOTAL VEX dihapus (V48: raw single-vote stake mudah disalahartikan); V40/V42/V43: BARU (`PEMILIH BARU · BELUM MATANG`, new saja) + daftar utama — kadaluarsa menyatu DI ATAS baris VALID (dipin, sorot amber + badge `VOTE ULANG` + legenda `BARIS AMBER = VOTE LEWAT …`), lalu VALID (pager) = matang-baru + REVOTE (tag mint `REVOTE` + legenda `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, V43) + badge amber dashed `VOTE ULANG SEGERA` (V43) pada baris `exp_cut < last_vote ≤ warn_cut` + legenda `TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH`; REVOTE ⊖ lagi bagian sendiri (V42); `/api/search` hasil `{owner,staked,weight,rank,last_vote,total_reward,group,expired,expiring}` — `group` = `baru|revote|valid|kadaluarsa` (weight disimpan & di-API, ⊥ dirender); V47: catatan `stats-note` menyatakan TOTAL PEMILIH = akun suara tunggal ke BP stake ≥ `MIN_STAKED_VEX`; V51: nama akun (index + history pay-list + search live) jadi link `https://vexascan.com/account/{owner}` (`.owner-link`, `target=_blank rel=noopener`) — display-only 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`) + 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) 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`) 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 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) 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 {DATABISNIS_API}/v2/history/get_transaction` → `executed` → tandai `sent` (⊥ duplikat) 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) 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 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 V25: pemilih kosong | saldo < 1 unit shareable → no-op senyap, ⊥ tulis DB
@@ -124,6 +124,15 @@ V54: baseline landing klaim STABIL per window (B16): `poll_claim` baca `last_cla
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 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 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 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 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`) 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 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
@@ -182,6 +191,13 @@ T46|x|BARU vs REVOTE (V41): `db.py` += tabel `voter_first_seen` + `ensure_first_
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 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 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 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
@@ -203,3 +219,12 @@ B15|2026-08-08|maturity 3-hari pemilih BARU ⊥ berfungsi utk vote Senin–Kamis
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 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 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 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
+14 -11
View File
@@ -30,8 +30,7 @@ import db
import telegram import telegram
import distribute as dist import distribute as dist
from config import (API_NODE, BP_FEE_PERCENT, BP_FEE_WALLET, BP_PRIVATE_KEY, from config import (API_NODE, BP_FEE_PERCENT, BP_FEE_WALLET, BP_PRIVATE_KEY,
CLAIM_RETRY_SECONDS, DATABISNIS_API, TARGET_BP, CLAIM_RETRY_SECONDS, TARGET_BP, TOKEN_CONTRACT)
TOKEN_CONTRACT)
VEX_PREC = Decimal('0.0001') VEX_PREC = Decimal('0.0001')
SETTLE_SECONDS = 30 # tunggu finalitas sebelum baca saldo setelah klaim SETTLE_SECONDS = 30 # tunggu finalitas sebelum baca saldo setelah klaim
@@ -155,17 +154,14 @@ def reward_from_tx(txid):
"""Reward BP dari isi tx klaim via Hyperion: jumlah transfer `vex.bpay`/ """Reward BP dari isi tx klaim via Hyperion: jumlah transfer `vex.bpay`/
`vex.vpay` → TARGET_BP. → Decimal | None (Hyperion tak terjangkau / tx tak `vex.vpay` → TARGET_BP. → Decimal | None (Hyperion tak terjangkau / tx tak
ada / bentuk tak dikenal). Prioritas pengukuran (V45) — ⊥ selisih saldo ada / bentuk tak dikenal). Prioritas pengukuran (V45) — ⊥ selisih saldo
yang flaky/stale. Tx yang tak `executed` → None.""" yang flaky/stale. Tx yang tak `executed` → None. V60: jalan lewat
`dist._get_json` — failover pool `HYPERION_NODES` (bukan satu host)."""
if not txid: if not txid:
return None return None
try: data = dist._get_json('/v2/history/get_transaction', params={'id': txid})
resp = dist.requests.get(f'{DATABISNIS_API}/v2/history/get_transaction', if not isinstance(data, dict): # V61: None/daftar/bentuk tak dikenal
params={'id': txid}, timeout=10)
resp.raise_for_status()
data = resp.json()
except Exception:
return None return None
if not (data or {}).get('executed'): if not data.get('executed'):
return None return None
total = Decimal('0') total = Decimal('0')
for act in (data.get('actions') or []): for act in (data.get('actions') or []):
@@ -361,7 +357,14 @@ def _send_fee(claim):
try: try:
signed = dist.build_signed_transfer(BP_FEE_WALLET, fee, memo) signed = dist.build_signed_transfer(BP_FEE_WALLET, fee, memo)
txid = signed.id() txid = signed.id()
signed.send() 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: except Exception as exc:
if txid is not None and dist.verify_txid(txid) is True: if txid is not None and dist.verify_txid(txid) is True:
pass # mendarat walau timeout — lanjut tandai sent pass # mendarat walau timeout — lanjut tandai sent
+33 -2
View File
@@ -17,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')
@@ -66,8 +77,24 @@ VEX_REVOTE_TAG_DAYS = int(os.getenv('VEX_REVOTE_TAG_DAYS', '1'))
# SEGERA` (segera kadaluarsa, masih dibayar). # SEGERA` (segera kadaluarsa, masih dibayar).
VEX_WARN_DAYS = int(os.getenv('VEX_WARN_DAYS', '3')) VEX_WARN_DAYS = int(os.getenv('VEX_WARN_DAYS', '3'))
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard) # Hyperion API untuk saldo liquid akun + verifikasi txid (balance BP,
DATABISNIS_API = os.getenv('DATABISNIS_API', 'https://api.databisnis.id') # 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'))
@@ -78,6 +105,10 @@ DASH_LIQUID_TTL = int(os.getenv('DASH_LIQUID_TTL', '60'))
BP_PRIVATE_KEY = os.getenv('VEX_BP_PRIVATE_KEY', '') BP_PRIVATE_KEY = os.getenv('VEX_BP_PRIVATE_KEY', '')
DISTRIBUTE_HOUR = int(os.getenv('DISTRIBUTE_HOUR', '10')) DISTRIBUTE_HOUR = int(os.getenv('DISTRIBUTE_HOUR', '10'))
DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3')) 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 # Kill-switch distribusi (V35): default OFF — payout hanya jalan saat diset
# `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis). # `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis).
DISTRIBUTE_ENABLED = os.getenv( DISTRIBUTE_ENABLED = os.getenv(
+40 -19
View File
@@ -14,7 +14,7 @@ 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,
DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY, DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY,
PAGE_SIZE, TARGET_BP, VEX_EXPIRED_DAYS, VEX_MATURITY_DAYS, PAGE_SIZE, TARGET_BP, VEX_EXPIRED_DAYS, VEX_MATURITY_DAYS,
VEX_REVOTE_TAG_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS) VEX_REVOTE_TAG_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS)
@@ -200,29 +200,50 @@ def _is_first_run_next(now=None):
return start >= now and _next_schedule(now) == start 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
@@ -549,7 +570,7 @@ def index():
rows = _db_rows(page, q) rows = _db_rows(page, q)
new_rows, expired_rows = _edge_rows() new_rows, expired_rows = _edge_rows()
_now = datetime.now() now = _now()
return render_template( return render_template(
'index.html', 'index.html',
bp=TARGET_BP, bp=TARGET_BP,
@@ -572,8 +593,8 @@ def index():
expiring_count=n_expiring, expiring_count=n_expiring,
stale_days=VEX_STALE_DAYS, stale_days=VEX_STALE_DAYS,
warn_days=VEX_WARN_DAYS, warn_days=VEX_WARN_DAYS,
next_schedule=_fmt_schedule(_next_schedule(_now)), next_schedule=_fmt_schedule(_next_schedule(now)),
schedule_recur=_recurrence_label(_now), schedule_recur=_recurrence_label(now),
) )
+92 -4
View File
@@ -206,9 +206,9 @@ def record_payment(run_id, owner, amount, status, created_at,
def update_payment_status(payment_id, status, txid=None, error=None, updated_at=None): def update_payment_status(payment_id, status, txid=None, error=None, updated_at=None):
"""Perbarui status satu transfer (V21): pending → sent|failed.""" """Perbarui status satu transfer (V21): pending → sent|failed."""
from datetime import datetime from datetime import datetime, timezone
if updated_at is None: if updated_at is None:
updated_at = datetime.now().isoformat(timespec='seconds') updated_at = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec='seconds')
conn = connect() conn = connect()
try: try:
cur = conn.cursor() cur = conn.cursor()
@@ -317,12 +317,12 @@ def record_claim(run_date, claim_txid, reward, fee_amount, fee_status,
def update_claim_fee(claim_id, fee_status, fee_txid=None): def update_claim_fee(claim_id, fee_status, fee_txid=None):
"""Perbarui status fee satu klaim (V33): pending → sent|failed|skipped.""" """Perbarui status fee satu klaim (V33): pending → sent|failed|skipped."""
from datetime import datetime from datetime import datetime, timezone
conn = connect() conn = connect()
try: try:
cur = conn.cursor() cur = conn.cursor()
if fee_status == 'sent': if fee_status == 'sent':
now = datetime.now().isoformat(timespec='seconds') now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec='seconds')
cur.execute(_translate( cur.execute(_translate(
'UPDATE claim_runs SET fee_status = %s, fee_txid = %s, ' 'UPDATE claim_runs SET fee_status = %s, fee_txid = %s, '
'fee_sent_at = %s WHERE claim_id = %s'), 'fee_sent_at = %s WHERE claim_id = %s'),
@@ -388,6 +388,94 @@ def record_first_seen(rows, scanned_at):
conn.close() 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): def eligible_voters(owner=None):
"""V57: pemilih yang memenuhi syarat reward (single source, dipakai """V57: pemilih yang memenuhi syarat reward (single source, dipakai
`distribute.py` + endpoint `/api/voter/<owner>`). `distribute.py` + endpoint `/api/voter/<owner>`).
+187 -54
View File
@@ -9,45 +9,62 @@ sebagai `pending` SEBELUM kirim (V21). Retry hanya baris `failed`; sebelum
resend, txid diverifikasi di Hyperion agar transfer yang sempat mendarat tak resend, txid diverifikasi di Hyperion agar transfer yang sempat mendarat tak
terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan
(V26). Tidak ada freezing/carryover (V24): tiap run menghitung ulang segar. (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 sys
import time import time
from datetime import datetime from datetime import datetime, timezone
from decimal import Decimal, ROUND_FLOOR, InvalidOperation from decimal import Decimal, ROUND_FLOOR, InvalidOperation
import requests import requests
import db import db
import telegram import telegram
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API, from config import (API_NODES, BP_PRIVATE_KEY, HYPERION_NODES,
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP, DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS,
DISTRIBUTE_SEND_DELAY, TARGET_BP,
TOKEN_CONTRACT, VEX_SYMBOL) TOKEN_CONTRACT, VEX_SYMBOL)
VEX_PREC = Decimal('0.0001') 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): def _post_json(url, payload, max_retries=3, timeout=30):
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`.""" """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 attempt in range(max_retries):
try: for node in API_NODES:
resp = requests.post(url, json=payload, timeout=timeout) try:
resp.raise_for_status() resp = requests.post(f'{node}{url}', json=payload, timeout=timeout)
return resp.json() resp.raise_for_status()
except requests.RequestException: return resp.json()
if attempt == max_retries - 1: except requests.RequestException:
raise continue
print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... ' if attempt == max_retries - 1:
f'({attempt + 1}/{max_retries})', flush=True) raise RuntimeError(
time.sleep(2) 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(): def fetch_balance():
"""V20: saldo liquid VEX akun `TARGET_BP` dari chain get_currency_balance. """V20: saldo liquid VEX akun `TARGET_BP` dari chain get_currency_balance.
Kembalikan Decimal (`0` bila kosong). Gagal total → None. V58: jalan lewat `_post_json` failover node pool — bila semua node gagal
→ raise (bukan None). Kembalikan Decimal (`0` bila kosong).
""" """
data = _post_json(f'{API_NODE}/v1/chain/get_currency_balance', data = _post_json('/v1/chain/get_currency_balance',
{'code': TOKEN_CONTRACT, 'account': TARGET_BP, {'code': TOKEN_CONTRACT, 'account': TARGET_BP,
'symbol': VEX_SYMBOL}) 'symbol': VEX_SYMBOL})
raw = (data or [''])[0] or '' raw = (data or [''])[0] or ''
@@ -57,6 +74,26 @@ def fetch_balance():
return Decimal('0') 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): def compute_shares(balance, voters):
"""V20: porsi per pemilih = floor(balance × stake ÷ total_stake), 4 desimal. """V20: porsi per pemilih = floor(balance × stake ÷ total_stake), 4 desimal.
@@ -75,47 +112,122 @@ def compute_shares(balance, voters):
return shares 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): def verify_txid(txid):
"""V22: cek tx benar-benar mendarat via Hyperion `get_transaction`. """V22: cek tx benar-benar mendarat via Hyperion `get_transaction`.
→ True bila `executed`, False bila tak ditemukan/diproses, None bila → True bila `executed`, False bila tak ditemukan/diproses, None bila
Hyperion tak terjangkau (⊥ kirim ulang bila tak bisa dipastikan). 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.
""" """
try: data = _get_json('/v2/history/get_transaction', params={'id': txid})
resp = requests.get(f'{DATABISNIS_API}/v2/history/get_transaction', if not isinstance(data, dict): # V61: bentuk tak dikenal → ⊖ asumsi apapun
params={'id': txid}, timeout=10)
resp.raise_for_status()
return bool(resp.json().get('executed'))
except requests.RequestException:
return None 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): def build_signed_transfer(to, amount, memo):
"""Bangun + tanda tangani tx `vex.token::transfer` via pyntelope. """Bangun + tanda tangani tx `vex.token::transfer` via pyntelope.
Kembalikan objek SignedTransaction (⊥ broadcast). `amount` = Decimal. Kembalikan objek SignedTransaction (⊥ broadcast). `amount` = Decimal.
Mengambil ABI + TAPOS dari node saat `link()` (jaringan, sesuai pola Mengambil ABI + TAPOS dari node saat `link()` — V58: coba SETIAP node di
retry di tingkat panggil). `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 from pyntelope import Action, Authorization, Data, Net, Transaction, types
net = Net(host=API_NODE) last_err = None
trx = Transaction(actions=[ for node in API_NODES:
Action( try:
account=TOKEN_CONTRACT, net = Net(host=node)
name='transfer', trx = Transaction(actions=[
authorization=[Authorization(actor=TARGET_BP, permission='active')], Action(
data=[ account=TOKEN_CONTRACT,
Data(name='from', value=types.Name(TARGET_BP)), name='transfer',
Data(name='to', value=types.Name(to)), authorization=[Authorization(actor=TARGET_BP,
Data(name='quantity', permission='active')],
value=types.Asset(f'{amount:.4f} {VEX_SYMBOL}')), data=[
Data(name='memo', value=types.String(memo)), 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}')),
linked = trx.link(net=net) Data(name='memo', value=types.String(memo)),
return linked.sign(key=BP_PRIVATE_KEY) ],
)
])
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): def run_distribution(dry_run=False):
@@ -134,7 +246,7 @@ def run_distribution(dry_run=False):
if not voters: if not voters:
print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True) print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True)
return 0 return 0
balance = fetch_balance() balance = fetch_balance_with_retry(dry_run=dry_run)
if balance is None or balance < VEX_PREC: if balance is None or balance < VEX_PREC:
print('Saldo liquid kosong / di bawah 0.0001 VEX — no-op.', flush=True) print('Saldo liquid kosong / di bawah 0.0001 VEX — no-op.', flush=True)
return 0 return 0
@@ -163,7 +275,7 @@ def run_distribution(dry_run=False):
total_share = sum(share for _, share in shares) total_share = sum(share for _, share in shares)
telegram.notify_start(run_date, len(shares), total_share, balance) telegram.notify_start(run_date, len(shares), total_share, balance)
created_at = datetime.now().isoformat(timespec='seconds') created_at = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec='seconds')
total_stake = sum(float(s) for _, s in voters) total_stake = sum(float(s) for _, s in voters)
run_id = db.record_run(run_date, float(balance), len(voters), total_stake, run_id = db.record_run(run_date, float(balance), len(voters), total_stake,
0.0, 'ok', created_at) 0.0, 'ok', created_at)
@@ -177,34 +289,49 @@ def run_distribution(dry_run=False):
flush=True) flush=True)
failed = [] failed = []
sent_amount = 0.0 sent_amount = 0.0
for pid, owner, share in pending: for idx, (pid, owner, share) in enumerate(pending):
txid = None txid = None
for attempt in range(1, DISTRIBUTE_MAX_ATTEMPTS + 1): for attempt in range(1, DISTRIBUTE_MAX_ATTEMPTS + 1):
try: try:
signed = build_signed_transfer(owner, share, memo) signed = build_signed_transfer(owner, share, memo)
txid = signed.id() # deterministik, sebelum broadcast txid = signed.id() # deterministik, sebelum broadcast
signed.send() 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) db.update_payment_status(pid, 'sent', txid=txid)
sent_amount += float(share) sent_amount += float(share)
print(f'[OK] {owner}: {share} VEX ({txid[:16]}...)', print(f'[OK] {owner}: {share} VEX ({txid[:16]}...)',
flush=True) flush=True)
break break
except Exception as exc: except Exception as exc:
# Timeout setelah broadcast → tx mungkin mendarat. Verifikasi # Timeout/penolakan setelah broadcast → tx mungkin mendarat.
# sebelum resend agar ⊥ ganda (V22). Bila Hyperion tak bisa # Verifikasi SEBELUM resend agar ⊥ ganda (V22). V61: HANYA
# dipastikan → tahan (tandai failed, jangan resend). # `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: if txid is not None:
landed = verify_txid(txid) landed = _verify_settled(txid)
if landed is True: if landed is True:
db.update_payment_status(pid, 'sent', txid=txid) db.update_payment_status(pid, 'sent', txid=txid)
sent_amount += float(share) sent_amount += float(share)
print(f'[OK] {owner}: {share} VEX ' print(f'[OK] {owner}: {share} VEX '
f'(terverifikasi, {txid[:16]}...)', flush=True) f'(terverifikasi, {txid[:16]}...)', flush=True)
break break
if landed is None: print(f'[Peringatan] Verifikasi txid tak tersedia '
print(f'[Peringatan] Verifikasi txid tak tersedia ' f'untuk {owner} — ditahan (⊥ resend)',
f'untuk {owner} — ditahan (⊥ resend)', flush=True)
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: if attempt == DISTRIBUTE_MAX_ATTEMPTS:
db.update_payment_status(pid, 'failed', txid=txid, db.update_payment_status(pid, 'failed', txid=txid,
error=str(exc)) error=str(exc))
@@ -212,6 +339,12 @@ def run_distribution(dry_run=False):
print(f'[GAGAL] {owner}: {share} VEX — {exc}', flush=True) print(f'[GAGAL] {owner}: {share} VEX — {exc}', flush=True)
break break
time.sleep(2) 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' status = 'ok' if not failed else 'partial'
db.update_run_status(run_id, status, round(sent_amount, 4)) db.update_run_status(run_id, status, round(sent_amount, 4))
+6 -3
View File
@@ -48,7 +48,7 @@ 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} TZ: ${TZ:-Asia/Jakarta}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10} DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
@@ -86,6 +86,8 @@ 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:
@@ -111,8 +113,9 @@ services:
DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-wed,sat} DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-wed,sat}
DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17} DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17}
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3} DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
DISTRIBUTE_SEND_DELAY: ${DISTRIBUTE_SEND_DELAY:-30}
DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false} DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false}
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id} HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
volumes: volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro - /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
networks: networks:
@@ -139,7 +142,7 @@ services:
CLAIM_RETRY_SECONDS: ${CLAIM_RETRY_SECONDS:-60} CLAIM_RETRY_SECONDS: ${CLAIM_RETRY_SECONDS:-60}
VEX_BP_FEE_WALLET: ${VEX_BP_FEE_WALLET:-bpdbsjasprod} VEX_BP_FEE_WALLET: ${VEX_BP_FEE_WALLET:-bpdbsjasprod}
VEX_BP_FEE_PERCENT: ${VEX_BP_FEE_PERCENT:-0.10} VEX_BP_FEE_PERCENT: ${VEX_BP_FEE_PERCENT:-0.10}
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id} HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
volumes: volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro - /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
networks: networks:
+47 -2
View File
@@ -15,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 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'
@@ -154,7 +156,8 @@ def persist(rows):
Lalu `db.record_first_seen` (V41): catat kemunculan pertama tiap owner Lalu `db.record_first_seen` (V41): catat kemunculan pertama tiap owner
(idempoten) utk membedakan pemilih BARU vs REVOTE di dashboard. (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) db.record_first_seen(rows, scanned_at)
return scanned_at return scanned_at
@@ -174,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}"...')
@@ -185,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

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

After

Width:  |  Height:  |  Size: 446 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

+103 -29
View File
@@ -1,10 +1,12 @@
"""Kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort). """Kirim notifikasi status distribusi + klaim + warn via Telegram Bot API (best-effort).
Dua kanal (V30/V36): KOMUNITAS (`TELEGRAM_COMMUNITY_CHAT_IDS`) hanya brief Dua kanal (V30/V36): KOMUNITAS (`TELEGRAM_COMMUNITY_CHAT_IDS`) hanya brief
mulai/selesai distribusi; INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`) detail mulai/selesai distribusi + VOTE ULANG SEGERA; INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`)
distribusi + semua kegagalan + klaim reward (⊥ komunitas). Token tunggal detail distribusi + semua kegagalan + klaim reward (⊥ komunitas). Token tunggal
`TELEGRAM_BOT_TOKEN`. Tanpa token/daftar → kanal itu no-op senyap (⊥ ganggu `TELEGRAM_BOT_TOKEN`. Tanpa token/daftar → kanal itu no-op senyap (⊥ ganggu
run). Gagal kirim hanya dicatat — ⊥ pernah crash distribusi/klaim. run). Gagal kirim hanya dicatat — ⊥ pernah crash distribusi/klaim.
V66: semua notifikasi kini punya emoticon prefix.
""" """
import requests import requests
@@ -12,17 +14,23 @@ import requests
from config import (TELEGRAM_BOT_TOKEN, TELEGRAM_COMMUNITY_CHAT_IDS, from config import (TELEGRAM_BOT_TOKEN, TELEGRAM_COMMUNITY_CHAT_IDS,
TELEGRAM_INTERNAL_CHAT_IDS) TELEGRAM_INTERNAL_CHAT_IDS)
# Batas panjang pesan Telegram per kiriman (karakter)
_MAX_MSG_LEN = 4096
def send_text(text, chat_ids=None): def send_text(text, chat_ids=None):
"""Kirim `text` ke daftar chat id. `chat_ids=None` → kanal komunitas. """Kirim `text` ke daftar chat id. `chat_ids=None` → kanal komunitas.
Best-effort: tanpa token/daftar → no-op; gagal → log saja. 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: if chat_ids is None:
chat_ids = TELEGRAM_COMMUNITY_CHAT_IDS chat_ids = TELEGRAM_COMMUNITY_CHAT_IDS
if not TELEGRAM_BOT_TOKEN or not chat_ids: if not TELEGRAM_BOT_TOKEN or not chat_ids:
return return False
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage' url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
ok = True
for chat_id in chat_ids: for chat_id in chat_ids:
try: try:
resp = requests.post(url, json={'chat_id': chat_id, 'text': text}, resp = requests.post(url, json={'chat_id': chat_id, 'text': text},
@@ -31,7 +39,8 @@ def send_text(text, chat_ids=None):
except requests.RequestException as exc: except requests.RequestException as exc:
print(f'[Peringatan] Gagal kirim Telegram ke {chat_id}: {exc}', print(f'[Peringatan] Gagal kirim Telegram ke {chat_id}: {exc}',
flush=True) flush=True)
ok = False
return ok
def send_internal(text): def send_internal(text):
"""Kirim ke kanal internal (detail + kegagalan + klaim).""" """Kirim ke kanal internal (detail + kegagalan + klaim)."""
@@ -40,61 +49,126 @@ def send_internal(text):
def notify_start(run_date, n_voters, total_share, balance): def notify_start(run_date, n_voters, total_share, balance):
"""Run nyata dimulai: komunitas brief + internal detail.""" """Run nyata dimulai: komunitas brief + internal detail."""
send_text(f'DISTRIBUSI MULAI\nTanggal: {run_date}') send_text(f'🚀 DISTRIBUSI MULAI\n📅 Tanggal: {run_date}')
send_internal( send_internal(
f'DISTRIBUSI MULAI\n' f'🚀 DISTRIBUSI MULAI\n'
f'Tanggal: {run_date}\n' f'📅 Tanggal: {run_date}\n'
f'Pemilih: {n_voters}\n' f'👥 Pemilih: {n_voters}\n'
f'Total share: {total_share} VEX\n' f'💎 Total share: {total_share} VEX\n'
f'Saldo: {balance} VEX' f'💰 Saldo: {balance} VEX'
) )
def notify_finish(status, sent, total, total_share, failed, run_date): def notify_finish(status, sent, total, total_share, failed, run_date):
"""Run selesai: komunitas brief + internal detail (ok | partial).""" """Run selesai: komunitas brief + internal detail (ok | partial)."""
if status == 'ok':
emoji = '✅'
label = 'Berhasil'
else:
emoji = '⚠️'
label = 'Sebagian'
send_text( send_text(
f'DISTRIBUSI SELESAI\n' f'{emoji} DISTRIBUSI SELESAI\n'
f'Tanggal: {run_date}\n' f'📅 Tanggal: {run_date}\n'
f'Status: {status}' f'📊 Status: {label}'
) )
send_internal( send_internal(
f'DISTRIBUSI SELESAI\n' f'{emoji} DISTRIBUSI SELESAI\n'
f'Tanggal: {run_date}\n' f'📅 Tanggal: {run_date}\n'
f'Status: {status}\n' f'📊 Status: {label}\n'
f'Terkirim: {sent}/{total} pemilih\n' f'👥 Terkirim: {sent}/{total} pemilih\n'
f'Total share: {total_share} VEX\n' f'💎 Total share: {total_share} VEX\n'
f'Gagal: {len(failed)}' f'❌ Gagal: {len(failed)}'
) )
def notify_failure(exc): def notify_failure(exc):
"""Run gagal total — kanal internal saja (ops detail).""" """Run gagal total — kanal internal saja (ops detail)."""
send_internal( send_internal(
f'DISTRIBUSI GAGAL\n' f'🔴 DISTRIBUSI GAGAL\n'
f'Error: {exc}' f'❗ Error: {exc}'
) )
def notify_claim(reward, fee, wallet): def notify_claim(reward, fee, wallet):
"""Klaim reward BP sukses — kanal internal saja, ⊥ komunitas (V36).""" """Klaim reward BP sukses — kanal internal saja, ⊥ komunitas (V36)."""
send_internal( send_internal(
f'KLAIM REWARD SUKSES\n' f'💰 KLAIM REWARD SUKSES\n'
f'Reward: {reward} VEX\n' f'💎 Reward: {reward} VEX\n'
f'Fee BP: {fee} VEX → {wallet}' f'💸 Fee BP: {fee} VEX → {wallet}'
) )
def notify_claim_unmeasured(txid_label): def notify_claim_unmeasured(txid_label):
"""Klaim mendarat tapi reward tak terukur (V45) — internal, ⊥ "sukses 0".""" """Klaim mendarat tapi reward tak terukur (V45) — internal, ⊥ "sukses 0"."""
send_internal( send_internal(
f'KLAIM MENDARAT · REWARD TAK TERUKUR\n' f'🔍 KLAIM MENDARAT · REWARD TAK TERUKUR\n'
f'Periksa reward & fee BP manual (txid {txid_label})' f'⚠️ Periksa reward & fee BP manual (txid {txid_label})'
) )
def notify_claim_failure(exc): def notify_claim_failure(exc):
"""Kegagalan siklus klaim — kanal internal saja (V36).""" """Kegagalan siklus klaim — kanal internal saja (V36)."""
send_internal( send_internal(
f'KLAIM GAGAL\n' f'🔴 KLAIM GAGAL\n'
f'Error: {exc}' 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
+5
View File
@@ -4,6 +4,11 @@
<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>RIWAYAT DISTRIBUSI — {{ bp }}</title> <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.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">
+7 -2
View File
@@ -3,7 +3,12 @@
<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">
@@ -19,7 +24,7 @@
<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>
+56 -1
View File
@@ -39,7 +39,10 @@ class FakeSigned:
def send(self): def send(self):
if self._exc is not None: if self._exc is not None:
raise self._exc raise self._exc
return {'processed': True} # 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): def fresh_db(tag):
@@ -509,6 +512,33 @@ def main():
dist.requests.get = _req_get dist.requests.get = _req_get
claim.reward_from_tx = lambda txid: None 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 ————— # ————— V45: reward tak terukur (tx gagal + delta gagal) → fee pending —————
fresh_db('unmeasured') fresh_db('unmeasured')
claim.fetch_producer_last_claim = lambda: ( claim.fetch_producer_last_claim = lambda: (
@@ -560,6 +590,31 @@ def main():
check('V34 resume → sent', check('V34 resume → sent',
last_claim_row()[5] == 'sent' and last_claim_row()[6] == 'txr1') 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 ————— # ————— V36: klaim reward → kanal INTERNAL saja, ⊥ komunitas —————
tg = {'posts': []} tg = {'posts': []}
+55 -2
View File
@@ -109,7 +109,7 @@ def main():
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', check('V51 akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' in html 'href="https://vexascan.com/account/acct125"' in html
@@ -198,6 +198,10 @@ def main():
'tag-revote">REVOTE' not in _slice_rows(html)) 'tag-revote">REVOTE' not in _slice_rows(html))
# ————— V38: banner BERITA jadwal distribusi berikutnya ————— # ————— 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) dashboard._next_schedule = lambda now=None: datetime(2026, 8, 17, 10, 0)
resp = client.get('/') resp = client.get('/')
html = resp.get_data(as_text=True) html = resp.get_data(as_text=True)
@@ -212,10 +216,12 @@ def main():
check('V38 _is_first_run_next setelah launch = False', check('V38 _is_first_run_next setelah launch = False',
dashboard._is_first_run_next(datetime(2026, 8, 18)) is 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) dashboard._next_schedule = lambda now=None: datetime(2026, 8, 22, 10, 0)
html = client.get('/').get_data(as_text=True) html = client.get('/').get_data(as_text=True)
check('V38 baris berulang tampil utk putaran mingguan', check('V38 baris berulang tampil utk putaran mingguan',
'SETIAP RABU & SABTU · 10:00' in html) 'SETIAP RABU & SABTU · 10:00' in html)
dashboard._now = _orig_dash_now
# drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor) # drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor)
import distribute_loop import distribute_loop
@@ -441,17 +447,44 @@ def main():
check('V40 config default VEX_EXPIRED_DAYS', config.VEX_EXPIRED_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_REVOTE_TAG_DAYS', config.VEX_REVOTE_TAG_DAYS == 1)
check('V43 config default VEX_WARN_DAYS', config.VEX_WARN_DAYS == 3) check('V43 config default VEX_WARN_DAYS', config.VEX_WARN_DAYS == 3)
check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id') 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
@@ -482,6 +515,26 @@ 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 — HANYA dari """Ambil nilai stake (dalam urutan kemunculan) dari baris HTML — HANYA dari
+261 -7
View File
@@ -40,7 +40,10 @@ class FakeSigned:
if self._fails > 0: if self._fails > 0:
self._fails -= 1 self._fails -= 1
raise RuntimeError('broadcast timeout (mock)') raise RuntimeError('broadcast timeout (mock)')
return {'processed': True} # 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): def make_voters_db(path, rows):
@@ -84,11 +87,16 @@ def fake_post(data):
'json': lambda self: data})() 'json': lambda self: data})()
def raise_request_exc():
raise dist.requests.RequestException('node mati')
def main(): def main():
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
db.DB_PATH = os.path.join(tmp, 'test.db') db.DB_PATH = os.path.join(tmp, 'test.db')
_orig_query = db.query _orig_query = db.query
_orig_eligible = db.eligible_voters _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 # V40: stempel last_vote SEJAK SEKARANG (relatif) agar umur jatuh di band
# VALID (3..28 hari) — stempel hardcode berumur <3 hari justru dibuang. # VALID (3..28 hari) — stempel hardcode berumur <3 hari justru dibuang.
fresh = (datetime.now() - timedelta(days=5)).isoformat(timespec='seconds') fresh = (datetime.now() - timedelta(days=5)).isoformat(timespec='seconds')
@@ -113,6 +121,135 @@ def main():
dist.requests.post = lambda url, json=None, timeout=30: fake_post([]) dist.requests.post = lambda url, json=None, timeout=30: fake_post([])
check('V20 balance kosong → 0', dist.fetch_balance() == Decimal('0')) 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 ————— # ————— V20: compute_shares — floor 4 desimal, sisa tetap di akun —————
shares = dist.compute_shares(Decimal('100.0000'), shares = dist.compute_shares(Decimal('100.0000'),
[('a', 300.0), ('b', 700.0)]) [('a', 300.0), ('b', 700.0)])
@@ -179,6 +316,7 @@ def main():
fresh), fresh),
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00', ('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
fresh)]) fresh)])
dist.time.sleep = lambda s: None # V61 `_verify_settled` tidur — mock
dist.fetch_balance = lambda: Decimal('100.0000') dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest' dist.BP_PRIVATE_KEY = '5Ktest'
dist.DISTRIBUTE_MAX_ATTEMPTS = 3 dist.DISTRIBUTE_MAX_ATTEMPTS = 3
@@ -227,6 +365,7 @@ def main():
calls['run_updates'] == [(7, 'ok', 100.0)]) calls['run_updates'] == [(7, 'ok', 100.0)])
# ————— V22: guard anti-duplikat — timeout lalu terverifikasi mendarat ————— # ————— 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.build_signed_transfer = lambda to, amount, memo: FakeSigned('txL' + to, fails=1)
dist.verify_txid = lambda txid: True # tx sebenarnya mendarat dist.verify_txid = lambda txid: True # tx sebenarnya mendarat
calls['updates'] = [] calls['updates'] = []
@@ -240,19 +379,134 @@ def main():
check('V22 run status ok', check('V22 run status ok',
calls['run_updates'][0][1] == 'ok') calls['run_updates'][0][1] == 'ok')
# ————— V22: verifikasi gagal (None) → tahan, jangan resend ————— # ————— V22/V59: verifikasi None → TAHAN (jangan resend), berapa pun attempt —————
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txH' + to, fails=1) 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 dist.verify_txid = lambda txid: None # Hyperion tak terjangkau
calls['updates'] = [] calls['updates'] = []
calls['run_updates'] = [] calls['run_updates'] = []
dist.DISTRIBUTE_MAX_ATTEMPTS = 1 # sekali coba → ditahan
with redirect_stdout(io.StringIO()): with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False) dist.run_distribution(dry_run=False)
check('V22 verifikasi None → ditahan (failed)', check('V59 verifikasi None → ditahan (failed), ⊖ resend',
all(u[1] == 'failed' for u in calls['updates'])) 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', check('V22 run status partial',
calls['run_updates'][0][1] == '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 ————— # ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00', make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
fresh)]) fresh)])
@@ -366,12 +620,12 @@ def main():
check('V30 komunitas brief MULAI', 'DISTRIBUSI MULAI' in comm[0][2] check('V30 komunitas brief MULAI', 'DISTRIBUSI MULAI' in comm[0][2]
and 'Pemilih' not in comm[0][2]) and 'Pemilih' not in comm[0][2])
check('V30 komunitas brief SELESAI + status', 'DISTRIBUSI SELESAI' check('V30 komunitas brief SELESAI + status', 'DISTRIBUSI SELESAI'
in comm[1][2] and 'Status: ok' in comm[1][2] in comm[1][2] and 'Berhasil' in comm[1][2]
and 'Terkirim' not in comm[1][2]) and 'Terkirim' not in comm[1][2])
check('V30 internal 2 notif detail', len(intern) == 2) check('V30 internal 2 notif detail', len(intern) == 2)
check('V30 internal detail MULAI', 'DISTRIBUSI MULAI' in intern[0][2] check('V30 internal detail MULAI', 'DISTRIBUSI MULAI' in intern[0][2]
and 'Pemilih: 2' in intern[0][2]) and 'Pemilih: 2' in intern[0][2])
check('V30 internal detail SELESAI', '2/2 pemilih' in intern[1][2] check('V30 internal detail SELESAI', 'Terkirim: 2/2 pemilih' in intern[1][2]
and 'Gagal: 0' in intern[1][2]) and 'Gagal: 0' in intern[1][2])
# no-op (pemilih kosong) → ⊥ notif baru # no-op (pemilih kosong) → ⊥ notif baru
+146 -1
View File
@@ -10,12 +10,13 @@ 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):
@@ -229,6 +230,20 @@ 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) # V41: first_seen — kemunculan pertama tiap owner, idempoten (⊥ overwrite)
db.record_first_seen([{'owner': 'aaa1'}], '2000-01-01T00:00:00') db.record_first_seen([{'owner': 'aaa1'}], '2000-01-01T00:00:00')
conn = sqlite3.connect(db.DB_PATH) conn = sqlite3.connect(db.DB_PATH)
@@ -243,6 +258,136 @@ def main():
check('V41 first_seen idempoten (record ulang ⊥ overwrite)', check('V41 first_seen idempoten (record ulang ⊥ overwrite)',
fs['aaa1'] == scanned_a and fs['aaa1'] != '2000-01-01T00:00:00') 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')