Compare commits

...
8 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
22 changed files with 497 additions and 60 deletions

No files matched your search

+3
View File
@@ -99,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
+10 -10
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. **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. 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 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_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_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,7 +26,7 @@ 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 {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. - 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.
@@ -37,20 +37,20 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
## 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). **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). `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` 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.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`, `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`, `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"]
+13 -1
View File
@@ -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
@@ -128,6 +128,11 @@ V58: hardening distribusi vs node RPC mati: `config` += `API_NODES` (CSV `VEX_AP
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 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 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 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
@@ -190,6 +195,9 @@ T51|x|hardening distribusi vs node RPC mati V58: `config.py` += `API_NODES` (CSV
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 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 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 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
@@ -216,3 +224,7 @@ B20|2026-08-13|fee klaim (claim.py `_send_fee`) ⊖ punya guard `_send_rejected`
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 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) 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 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
+4
View File
@@ -105,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(
+13 -3
View File
@@ -200,6 +200,16 @@ 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.
@@ -560,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,
@@ -583,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>`).
+13 -4
View File
@@ -9,11 +9,13 @@ 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
@@ -21,7 +23,8 @@ import requests
import db import db
import telegram import telegram
from config import (API_NODES, BP_PRIVATE_KEY, HYPERION_NODES, 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')
@@ -272,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)
@@ -286,7 +289,7 @@ 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:
@@ -336,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))
+3
View File
@@ -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,6 +113,7 @@ 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}
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id} HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
volumes: volumes:
+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>
+7 -1
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
+24 -2
View File
@@ -485,6 +485,28 @@ def main():
dist.verify_txid = _orig_verify dist.verify_txid = _orig_verify
dist.time.sleep = _orig_sleep 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)])
@@ -598,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')