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_FIRST_RUN=2026-08-17
DISTRIBUTE_MAX_ATTEMPTS=3
# Jeda (detik) ANTAR kirim transfer antar pemilih — beri node/Hyperion waktu
# mengejar indeks + meratakan beban broadcast. N pemilih → N−1 jeda per run.
DISTRIBUTE_SEND_DELAY=30
# Kill-switch: FALSE (default) = payout off; set TRUE untuk mengaktifkan
# pembayaran. --dry-run tetap jalan walau off.
DISTRIBUTE_ENABLED=false
+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+).
- 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.
- 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:
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5001`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` twice-weekly at `DISTRIBUTE_WEEKDAY` default `wed,sat` (CSV, multi-day V44) + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42/V52): BARU (new first-time voters, `first_seen_at` within 3 days, own table on top, never paid — maturity is measured from `first_seen_at`, NOT `last_vote` which is week-quantized) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with `VOTE ULANG` badge + legend) then VALID (the paged 50/page list, `staked DESC`, live owner search via `/api/search`); voters older than 31 days are stored by the scan but hidden. Since V42, **REVOTE** (returning voters, `voter_first_seen.first_seen_at ≤ now−3d`) are VALID immediately — they join the main list tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`), no separate band, no maturity wait; since V43 the mint tag shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS` (default 1 day) — after that they're plain VALID (still paid); VALID rows within `VEX_WARN_DAYS` (default 3) of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge (legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH`); only genuinely new voters pass the 3-day maturity in BARU. Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_API_NODES` (CSV pool RPC failover distribusi V58, default = `VEX_API_NODE` + `https://api.databisnis.id`), `VEX_HYPERION_NODES` (CSV pool Hyperion failover V60, default = `HYPERION_API` + `API_NODES` dedup), `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `HYPERION_API` (rename dari `DATABISNIS_API`; nama lama masih dibaca utk backward-compat), `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY` (CSV multi-hari V44, default `wed,sat`), `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_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`.
- 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 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).
- 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.
- 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.
@@ -37,20 +37,20 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
## Layout
- `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.
- `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_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`.
- `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.
- `telegram.py` — kirim notifikasi status distribusi + klaim + peringatan VOTE ULANG SEGERA via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi + VOTE ULANG SEGERA saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`. V66a: `send_text`/`notify_warn` return bool pengiriman nyata (⊥ false-positive utk dedup warn, B27).
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40/V52): the voters list is split into BARU (top, `PEMILIH BARU · BELUM MATANG`, new accounts by `first_seen_at` within 3 days) and the main list (`voter-list` section, `.reward` cells, V29) where KADALUARSA rows are pinned at the top with amber styling + `VOTE ULANG` badge + legend before the paged VALID rows, which LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column; since V42 the VALID list also holds REVOTE (returning re-voters, `voter_first_seen.first_seen_at ≤ now−3d`) tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS`) — no separate band, no maturity wait; only genuinely new voters sit in BARU tagged `BARU`; since V43 VALID rows within `VEX_WARN_DAYS` of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge + legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH` (display-only); search (`?q=` and `/api/search`) covers all bands and tags each result (`group` = `baru|revote|valid|kadaluarsa` + `expired` + `expiring`); `/api/voter/<owner>` (V57) returns `{"owner", "is_valid_voter"}` — payout-eligibility check via the SAME `db.eligible_voters(owner)` window distribute pays from (never a second SQL copy), DB-snapshot only, always 200; Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard; the banner clock is injectable via `dashboard._now()` — the route calls the helper instead of `datetime.now()` inline so the oracle fixes a date instead of depending on the real wall clock, V64/B25); the `SETIAP ... · HH:00` recurrence line renders only for regular weekly rounds — hidden while the next event is the one-off first-run (`_is_first_run_next`, date ≠ weekly day). A `stats-note` caption states the list is single-vote-to-BP accounts ≥ min stake (V47); account names link to `https://vexascan.com/account/{owner}` in the index list, `/history` pay-list, and live search (`.owner-link`, V51); a promo card (`BY DATABISNISID · VEXWALLET`) links the VexWallet Android app on the index page via the IDRS logo (`static/idrs.png`) + `UNDUH DI PLAY STORE` (V53); BARU rows show a `MATURITY PERIOD` countdown column (`VALID DALAM N HARI`) instead of TOTAL REWARD. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
- `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.
- `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.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `API_NODES` (pool RPC distribusi, CSV `VEX_API_NODES`), `HYPERION_API`, `HYPERION_NODES` (pool Hyperion V60, CSV `VEX_HYPERION_NODES`, default `HYPERION_API` + `API_NODES` dedup), `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_SEND_DELAY`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar. Setelah persist, `get_voters.main()` memanggil `_notify_warn(now)` — VOTE ULANG SEGERA ke kanal komunitas (V66), butuh `TELEGRAM_*` dari bind `config.env` (V66a/B27).
- `distribute_loop.py` — scheduler dua-kali-minggu dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `wed,sat`, CSV multi-hari V44) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute + claim); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `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).
- `voters.db` — SQLite output (daily snapshot, gitignored in spirit).
+1 -1
View File
@@ -10,6 +10,6 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py get_voters.py scan_loop.py ./
COPY config.py db.py get_voters.py scan_loop.py telegram.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)
cmd: `./venv/bin/python distribute.py` → stdout id-ID (rencana + hasil); `--dry-run` → rencana tanpa sign
web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile
env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_WEEKDAY` (default `sat`), `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`; env override), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`)
env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_WEEKDAY` (default `sat`), `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`; env override), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_SEND_DELAY` (default 30, V63), `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`)
cmd: `./venv/bin/python telegram.py` — modul notifikasi (⊥ CLI); `TELEGRAM_BOT_TOKEN` + daftar chat kosong → kanal no-op senyap; `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal
db: table `claim_runs` (claim_id PK, run_date, claim_txid, reward, fee_amount, fee_status[pending|sent|failed|skipped], fee_txid, claimed_at, fee_sent_at, created_at) via `db.py`; append; ⊖ kolom memo (memo fee on-chain saja, V23 pola)
api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows` → body {json:true, code:`vexcore`, scope:`vexcore`, table:`producers`, limit:1, lower_bound, upper_bound} → rows[owner, last_claim_time, unpaid_blocks] — sumber jendela klaim
@@ -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
V59: kirim per-pemilih ⊖ pernah menyesatkan: (1) TAHAN yang sungguhan saat `verify_txid` → None (Hyperion tak terjangkau): break + tandai `failed` di ATTEMPT PERTAMA (bukan hanya di attempt terakhir — B19: kode lama print `ditahan` tapi lanjut retry → re-sign dgn tapos/expiration baru → txid BARU → re-broadcast berpotensi ganda walau tx pertama mendarat; oracle lama lolos karena set `DISTRIBUTE_MAX_ATTEMPTS=1`); (2) deteksi penolakan chain dari body HTTP-500: pyntelope `send()` ⊥ raise pada 500 (pola V46/B11), respons `{"code":500,"error":...}` datang sbg dict — `_send_rejected(resp)` mengenali bentuk error → raise RuntimeError → jalur verify/retry, ⊖ tandai `sent` utk tx yang ⊥ pernah mendarat (B19 juga: tanpa deteksi, penolakan duplicate/insufficient-balance dicatat `sent` dan ⊥ pernah dikejar). Oracle test_distribute: V59 hold-atau-retry di attempt 1 (3 attempt default, build dipanggil sekali per pemilih), V59 rejection-500 → `failed` + partial (⊖ `sent`) | V59,V22,V21
V61: red-team send-path hardening (audit V58/V59/V60, B20–B23): (1) `_send_rejected(resp)` = WHITELIST, bukan blacklist: sukses HANYA bila resp dict ber-`transaction_id` DAN receipt `executed`/tanpa-status; body HTTP-500 apa pun (ber-`error` ATAU bentuk proxy lain) → dianggap DITOLAK → jalur verify/retry, ⊖ pernah `sent` utk tx yang ⊥ mendarat (F3/B22; B19-b hanya mengejar bentuk `{"code":500,"error":...}`, bentuk lain lolos sbg sukses); (2) `_verify_settled(txid, retries=3, delay=2)`: retry `verify_txid` beri Hyperion waktu mengejar indeks (pola B14), True → mendarat; None (pool mati SEMUA) → False langsung (⊥ tidur sia-sia); False konsisten → False — jalur exception send-loop sekarang hold (`failed`, break) pada True→sent / None|False→hold, ⊖ RESEND apa pun hasil verify selain True — tx yang `executed:false` (lag indeks) ⊖ boleh re-sign dgn tapos baru → txid baru → ganda (B21, perluasan V59-b: V59 hold hanya saat `verify_txid → None`, False FALLTHROUGH ke resend); (3) `_send_fee` (claim fee) pakai `_send_rejected(resp)` — penolakan chain fee (duplicate/insufficient-balance, V46/B11) → `failed` + retry siklus berikutnya, ⊖ `sent` untuk fee yang ⊖ pernah mendarat (F1/B20); (4) SEMUA respons `/v2/*` di-guard `isinstance(data, dict)` — `_get_json`, `verify_txid`, `reward_from_tx`, `dashboard._get_liquid_vex` → bentuk non-dict (200-an list/string dari proxy) → skip/skip-node/None, ⊖ `AttributeError` di dalam `except` yang membatalkan run (F5/B23). Oracle: test_distribute V61 (verify-False → hold attempt-1 dgn MAX_ATTEMPTS=3 default, `_send_rejected` whitelist 5 bentuk, `_get_json`/`verify_txid` non-dict → None), test_claim V61 (fee rejection-500 → `failed` ⊖ `sent`), test_dashboard V60 (override `VEX_HYPERION_NODES`). F4 (`_get_json` first-200-wins: primary basi menaungi cadangan — hari ini kedua host = backend vexascan.com sama, efek nol) & F7 (hold latency ~64 s/pembayaran saat SEMUA Hyperion mati; jalur claim chain node tunggal) — DITERIMA, ⊖ diubah | V61,V60,V59,V56,V46
V62: stempel scan UTC-naive (B24): `get_voters.persist` menulis `scanned_at`/`first_seen_at` dgn `datetime.now(timezone.utc).replace(tzinfo=None)` — sama basis waktu dgn SEMUA cutoff umur (dashboard `_freshness_cutoffs`/`_revote_cut`/countdown + `db.eligible_voters` + `distribute` semuanya `datetime.now(timezone.utc)`) dan dgn `last_vote` (diturunkan dari epoch 2000 UTC). Dulu `datetime.now()` (waktu LOKAL container `TZ=Asia/Jakarta`, UTC+7) → `first_seen_at` tersimpan WIB tapi dibandingkan sbg string UTC → semua ambang maturity BARU/REVOTE bergeser +7 jam: pemilih baru tertahan di band BARU ~7 jam lebih lama dari seharusnya (B24). Migrasi data existing: UPDATE `voter_first_seen`/`voters` kurangi 7 jam (WIB→UTC), backup tabel `*_bak_wib` di prod. Oracle test_get_voters V62: stempel ≈ now-UTC-naive (selisih <2 mnt) DAN ≠ now−7h (guard anti-WIB) | V62,V52,V40,V41
V63: jeda antar kirim distribusi: send-loop `run_distribution` tidur `DISTRIBUTE_SEND_DELAY` (int, default 30) detik ANTAR pemilih — setelah pemilih ke-i selesai diproses (sent/hold/failed), sebelum pemilih i+1; N pemilih → N−1 tidur, ⊖ tidur setelah pemilih TERAKHIR; real-run saja (dry-run ⊖ tidur — tak ada kirim). Tujuan: beri node/Hyperion waktu mengejar indeks + meratakan beban broadcast run panjang; jeda seragam terlepas hasil pemilih sebelumnya (pacing deterministik utk oracle). Oracle test_distribute V63: 3 pemilih → sleep dipanggil 2× dgn nilai `DISTRIBUTE_SEND_DELAY` | V63,V21,V22
V64: banner BERITA hermetik utk oracle (B25): route index menghitung jam via helper modul `_now()` (isi = `datetime.now()` lokal, ⊖ inline di route) & mengoper ke `_next_schedule(now)`/`_recurrence_label(now)` — test mock `dashboard._now` ke tanggal TETAP (sebelum/sesudah launch) sehingga asersi baris-berulang ⊖ bergantung jam dinding nyata; tanpa ini, setelah tanggal first-run baked (2026-08-17) LEWAT di kalender, `_is_first_run_next(real_now)` ⊖ pernah True (`start >= now` gagal) → baris berulang tampil → asersi "TAK tampil saat first-run" gagal walau `_next_schedule` sudah di-mock (B25; test lulus hanya kalau kalender < tanggal launch). Oracle test_dashboard V38: mock `_now` sebelum launch → baris hilang; sesudah launch → baris muncul | V64,V38,V44
V65: semua stempel data (audit trail, pembayaran, klaim) UTC-naive konsisten — `distribute.py` `created_at`, `db.py` `updated_at` (update_payment_status), `db.py` `now` (update_claim_fee) memakai `datetime.now(timezone.utc).replace(tzinfo=None)` — sama basis dengan `get_voters.persist` (V62), `eligible_voters` (V57), semua cutoff umur dashboard; ⊖ `datetime.now()` lokal yang menghasilkan WIB (UTC+7) dan merusak perbandingan string UTC. Oracle: test_distribute + test_claim tetap hijau | V65,V62,V57,V40,V52
V66: notifikasi VOTE ULANG SEGERA via Telegram + emoticon pada SEMUA notifikasi: `get_voters._notify_warn(now)` setelah `persist()` — query pemilih dalam jendela `now−STALE < last_vote ≤ now−(STALE−WARN)` dari tabel `voters`, hitung urgensi dari sisa hari (≤1d=🚨/3, ≤2d=🔴/2, >2d=⚠️/1), filter yg sdh diberi tahu pada level ≥ urgensi saat ini (tabel `voter_notify`, state-change-only → max 3 notif/pemilih per jendela), kirim via `telegram.notify_warn` ke KOMUNITAS (daftar max 50 + link per-akun `mining.databisnis.id?q={owner}`), catat ke `voter_notify`, bersihkan baris stale. Tabel `voter_notify` (owner PK, urgency INT, notified_at TEXT) — `ensure_notify_schema`/`get_notify_urgency`/`record_notify`/`cleanup_notify` di `db.py`. `Dockerfile.scan` salin `telegram.py`. SEMUA notifikasi Telegram (`notify_start`/`notify_finish`/`notify_failure`/`notify_claim`/`notify_claim_unmeasured`/`notify_claim_failure`) kini punya emoticon prefix (🚀/✅/⚠️/🔴/💰/🔍). Oracle test_get_voters V66: voter_notify schema + record/get/cleanup + _notify_warn integrasi (mock telegram) + skip redundancy + escalation trigger | V66,V62,V40 V66a: kirim dikunci ke pengiriman NYATA — `send_text` return bool (True bila SEMUA chat terkirim), `notify_warn` return bool (True bila semua chunk terkirim), `_notify_warn` catat `voter_notify` HANYA bila `notify_warn` True — gagal/no-op (token kosong, Telegram down) ⊖ pernah mencatat → dedup palsu (B27). `docker-compose.yml` service `scan` bind NFS `config.env:/app/.env:ro` (token+chat id) — sebelum ini scan container ⊖ punya TELEGRAM_* → notif senyap (B27)
V35: kill-switch distribusi `DISTRIBUTE_ENABLED` default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; `--dry-run` tetap menampilkan rencana (read-only); nilai `true`/`1`/`yes` → normal
V36: notifikasi klaim reward (sukses `notify_claim` & gagal `notify_claim_failure`) → kanal INTERNAL saja, ⊥ pernah ke KOMUNITAS (V30); fee gagal ⊥ spam — satu notif per klaim (saat transisi ke `failed`)
V37: jadwal distribusi MINGGUAN (⊥ harian): `distribute_loop` menunggu `DISTRIBUTE_WEEKDAY` — SATU hari (`sat`) atau CSV beberapa hari (`wed,sat`), nama `mon`..`sun` atau 0-6 (V44: multi-hari, event terdekat di antara hari-hari terdaftar) — jam `DISTRIBUTE_HOUR` (lokal TZ); plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`; default baked `2026-08-17`) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila `DISTRIBUTE_ENABLED=false` (V35) — jadwal tetap maju, run jadi no-op; `distribute_loop.main` baca jadwal via `config` (⊥ `os.getenv` sendiri) agar konsisten dgn dashboard V38
@@ -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
T53|x|failover pool Hyperion V60: `config.py` += `HYPERION_NODES` (CSV `VEX_HYPERION_NODES`, default `HYPERION_API` + `API_NODES` dedup); `distribute.py` `_get_json(url, params, max_retries=3, timeout=10)` GET mirror `_post_json` — coba semua `HYPERION_NODES` per putaran, semua gagal → None; `verify_txid` + `claim.reward_from_tx` (via `dist._get_json`) + `dashboard._get_liquid_vex` (loop lokal, web ⊥ impor distribute V31) pindah ke pool; docs §I/§V/V60 + T53 + AGENTS; oracle test_distribute (failover verify_txid + semua-mati None), test_claim (reward_from_tx failover), test_dashboard (saldo cadangan + config default)|V60,V22,V45,V16
T54|x|red-team send-path hardening V61 (B20–B23): `distribute.py` — `_send_rejected` jadi WHITELIST (sukses = dict + transaction_id + receipt executed/tanpa-status), `_verify_settled(txid)` retry verify utk lag indeks, jalur exception send-loop hold pada None|False (⊖ resend, perluasan V59-b), guard `isinstance(data, dict)` di `_get_json`+`verify_txid`; `claim.py` `_send_fee` + `_send_rejected` (rejection-500 → failed ⊖ sent) + guard non-dict di `reward_from_tx`; `dashboard._get_liquid_vex` guard non-dict; docs §V/V61 + §B/B20-B23 + T54 + AGENTS; oracle test_distribute (verify-False hold attempt-1 MAX_ATTEMPTS=3, whitelist 5 bentuk, non-dict → None), test_claim (fee rejection → failed), test_dashboard (override VEX_HYPERION_NODES); F4/F7 diterima (dokumentasi)|V61,V60,V59,V56,V46
T55|x|stempel scan UTC-naive V62 (B24): `get_voters.persist` ganti `datetime.now()` → `datetime.now(timezone.utc).replace(tzinfo=None)` utk `scanned_at`/`first_seen_at` (konsisten dgn semua cutoff umur UTC + `last_vote`); migrasi data existing DB prod WIB→UTC (−7 jam, backup `*_bak_wib`); docs §V/V62 + §B/B24 + T55 + AGENTS; oracle test_get_voters V62 (stempel ≈ now-UTC-naive, ⊖ WIB)|V62,V52,V41,V7
T56|x|jeda antar kirim V63: `config.py` += `DISTRIBUTE_SEND_DELAY` (int, default 30); `distribute.py` send-loop `enumerate(pending)` + `time.sleep(DISTRIBUTE_SEND_DELAY)` ANTAR pemilih (N pemilih → N−1 tidur, ⊖ setelah terakhir; dry-run ⊖); env compose distribute += `DISTRIBUTE_SEND_DELAY` + `.env.example` + docs §V/V63 + §I/env + T56 + AGENTS; oracle test_distribute V63 (3 pemilih → 2 sleep @ nilai config)|V63,V21,V22,I.env
T57|x|banner hermetik utk oracle V64 (B25): `dashboard.py` route index `now = _now()` (helper modul, ⊖ `datetime.now()` inline) utk `_next_schedule`/`_recurrence_label`; `test_dashboard.py` V38 mock `dashboard._now` ke tanggal tetap sebelum/sesudah launch; docs §V/V64 + §B/B25 + T57 + AGENTS; oracle test_dashboard V38|V64,V38,V44
## §B — Bug log
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
B22|2026-08-13|`_send_rejected` BLACKLIST (deteksi `error` di body) — body 500 bentuk lain (proxy tanpa `error`, `{"code":500,"message":...}`) atau 200 `soft_fail`/`delayed` receipt dianggap SUKSES → dicatat `sent` walau tx gagal/ditolak|whitelist: sukses HANYA dict ber-`transaction_id` + receipt `executed`/tanpa-status; sisanya → tolak (V61, F3); oracle `_send_rejected` 5 bentuk (sukses nodeos, 500 ber-error, 500 tanpa error, soft_fail, non-dict)
B23|2026-08-13|respons `/v2/*` bentuk non-dict (200-an `[...]`/`"..."` dari proxy salah) → `AttributeError: 'list' object has no attribute 'get'` DII DALAM `except RequestException` → run distribusi BEBENTI total; sama di `verify_txid`/`reward_from_tx`/`dashboard._get_liquid_vex`|guard `isinstance(data, dict)` di semua pembaca `/v2/*` → non-dict → skip/skip-node/None (V61, F5); oracle `_get_json`/`verify_txid` non-dict → None
B24|2026-08-17|`first_seen_at`/`scanned_at` tersimpan WIB (UTC+7) tapi dibandingkan sbg string UTC di SEMUA cutoff umur (dashboard + `db.eligible_voters` + distribusi): `get_voters.persist` memakai `datetime.now()` (waktu lokal container `TZ=Asia/Jakarta`) sedangkan cutoff pakai `datetime.now(timezone.utc)` — offset +7 jam membuat pemilih BARU tampak belum matang ~7 jam lebih lama (kasus nyata: idrsaku33351/m4dhanchocil/danuprasetya first_seen 13 Agt 17:39 WIB, seharusnya matang 16 Agt 17:39 WIB, kode melepas dari BARU baru 17 Agt 00:39 WIB)|`persist` stempel UTC-naive (`datetime.now(timezone.utc).replace(tzinfo=None)`); migrasi data existing WIB→UTC (−7 jam) dgn backup `*_bak_wib` (V62); oracle V62 guard stempel ≈ now-UTC ⊖ WIB
B25|2026-08-19|oracle V38 HTML-render bergantung jam dinding nyata: route index `_now = datetime.now()` inline → `_recurrence_label(real_now)` → `_is_first_run_next` cek `start >= now` — setelah tanggal first-run baked (2026-08-17) LEWAT di kalender nyata, `start >= now` ⊖ pernah True → baris berulang `SETIAP RABU & SABTU` tampil → asersi "baris berulang TAK tampil saat first-run" GAGAL walau `_next_schedule` sudah di-mock ke 2026-08-17 (test lulus hanya kalau kalender nyata < tanggal launch)|route pakai helper modul `_now()` injectable + oracle mock `dashboard._now` ke tanggal tetap sebelum/sesudah launch (V64)
B26|2026-08-27|tidak ada notifikasi Telegram untuk pemilih yang masuk jendela VOTE ULANG SEGERA — pemilih hanya terlihat di dashboard dgn badge amber, ⊥ alert proaktif ke komunitas → vote kedaluwarsa tanpa peringatan|`get_voters._notify_warn` + `telegram.notify_warn` + tabel `voter_notify` utk state-change dedup + emoticon pada SEMUA notifikasi (V66)
B27|2026-09-02|notifikasi VOTE ULANG SEGERA ⊖ pernah sampai ke kanal komunitas walau dashboard tampil badge: (1) service `scan` di `docker-compose.yml` ⊖ bind NFS `config.env` (hanya distribute/claim) → container scan `TELEGRAM_BOT_TOKEN`/`TELEGRAM_COMMUNITY_CHAT_IDS` kosong → `send_text` no-op senyap (2) walau no-op, `_notify_warn` TETAP `record_notify` → ledgers `voter_notify` terisi dedup palsu urgent 1→2→3 tiap scan → setelah token dibenahi, dedup `prev >= urgency` memblokir kirim (test manual `_notify_warn` senyap)|(1) compose bind `config.env:/app/.env:ro` utk scan (2) V66a: `send_text`/`notify_warn` return bool pengiriman nyata; `_notify_warn` catat `voter_notify` HANYA bila `True` (V66a); oracle V66 kirim-gagal → ⊖ catat
+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', '')
DISTRIBUTE_HOUR = int(os.getenv('DISTRIBUTE_HOUR', '10'))
DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3'))
# Jeda (detik) ANTAR kirim transfer antar pemilih (V63): send-loop distribusi
# tidur sebelum lanjut ke pemilih berikutnya — beri node/Hyperion waktu
# mengejar indeks + meratakan beban broadcast run panjang. Default 30.
DISTRIBUTE_SEND_DELAY = int(os.getenv('DISTRIBUTE_SEND_DELAY', '30'))
# Kill-switch distribusi (V35): default OFF — payout hanya jalan saat diset
# `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis).
DISTRIBUTE_ENABLED = os.getenv(
+13 -3
View File
@@ -200,6 +200,16 @@ def _is_first_run_next(now=None):
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():
"""V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan.
@@ -560,7 +570,7 @@ def index():
rows = _db_rows(page, q)
new_rows, expired_rows = _edge_rows()
_now = datetime.now()
now = _now()
return render_template(
'index.html',
bp=TARGET_BP,
@@ -583,8 +593,8 @@ def index():
expiring_count=n_expiring,
stale_days=VEX_STALE_DAYS,
warn_days=VEX_WARN_DAYS,
next_schedule=_fmt_schedule(_next_schedule(_now)),
schedule_recur=_recurrence_label(_now),
next_schedule=_fmt_schedule(_next_schedule(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):
"""Perbarui status satu transfer (V21): pending → sent|failed."""
from datetime import datetime
from datetime import datetime, timezone
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()
try:
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):
"""Perbarui status fee satu klaim (V33): pending → sent|failed|skipped."""
from datetime import datetime
from datetime import datetime, timezone
conn = connect()
try:
cur = conn.cursor()
if fee_status == 'sent':
now = datetime.now().isoformat(timespec='seconds')
now = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(timespec='seconds')
cur.execute(_translate(
'UPDATE claim_runs SET fee_status = %s, fee_txid = %s, '
'fee_sent_at = %s WHERE claim_id = %s'),
@@ -388,6 +388,94 @@ def record_first_seen(rows, scanned_at):
conn.close()
# ——————————————————————————————————————————————————————————————————————
# V66: Notifikasi VOTE ULANG SEGERA — tabel lacak siapa sudah diberi tahu
# pada level urgensi berapa. Satu baris per owner (PK), di-update saat
# urgensi naik. Bersihkan otomatis saat pemilih keluar dari jendela warn.
# ——————————————————————————————————————————————————————————————————————
_NOTIFY_COLS = (
'owner VARCHAR(13) PRIMARY KEY, '
'urgency INTEGER NOT NULL, '
'notified_at TEXT NOT NULL'
)
_NOTIFY_DDL_SQLITE = (f'CREATE TABLE IF NOT EXISTS voter_notify '
f'({_NOTIFY_COLS})')
_NOTIFY_DDL_MYSQL = (f'CREATE TABLE IF NOT EXISTS voter_notify '
f'({_NOTIFY_COLS})')
def ensure_notify_schema():
"""Buat tabel lacak notifikasi VOTE ULANG SEGERA bila belum ada (idempoten)."""
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(_NOTIFY_DDL_SQLITE)
else:
cur.execute(_NOTIFY_DDL_MYSQL)
conn.commit()
finally:
conn.close()
def get_notify_urgency(owner):
"""V66: ambil level urgensi terakhir yang sudah diberi tahu utk `owner`.
Kembalikan integer (1/2/3) atau None bila belum pernah diberi tahu.
"""
ensure_notify_schema()
row = queryone(
'SELECT urgency FROM voter_notify WHERE owner = %s', (owner,))
return row[0] if row else None
def record_notify(owner, urgency, notified_at):
"""V66: catat bahwa `owner` sudah diberi tahu pada level `urgency`.
INSERT OR IGNORE (baru) + UPDATE bila urgensi naik (tidak turun).
Idempoten — safe dipanggil berulang.
"""
ensure_notify_schema()
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(
'INSERT OR IGNORE INTO voter_notify '
'(owner, urgency, notified_at) VALUES (?, ?, ?)',
(owner, urgency, notified_at))
cur.execute(
'UPDATE voter_notify SET urgency = ?, notified_at = ? '
'WHERE owner = ? AND urgency < ?',
(urgency, notified_at, owner, urgency))
else:
cur.execute(
'INSERT INTO voter_notify '
'(owner, urgency, notified_at) VALUES (%s, %s, %s) '
'ON DUPLICATE KEY UPDATE '
'urgency = IF(%s > urgency, %s, urgency), '
'notified_at = IF(%s > urgency, %s, notified_at)',
(owner, urgency, notified_at, urgency, urgency,
urgency, notified_at))
conn.commit()
finally:
conn.close()
def cleanup_notify(cutoff):
"""V66: hapus baris notifikasi yang sudah lewat jendela warn (stale)."""
ensure_notify_schema()
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'DELETE FROM voter_notify WHERE notified_at < %s'), (cutoff,))
conn.commit()
finally:
conn.close()
def eligible_voters(owner=None):
"""V57: pemilih yang memenuhi syarat reward (single source, dipakai
`distribute.py` + endpoint `/api/voter/<owner>`).
+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
terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan
(V26). Tidak ada freezing/carryover (V24): tiap run menghitung ulang segar.
V63: send-loop tidur `DISTRIBUTE_SEND_DELAY` (default 30s) ANTAR pemilih —
beri node/Hyperion waktu mengejar indeks + meratakan beban broadcast.
"""
import sys
import time
from datetime import datetime
from datetime import datetime, timezone
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
import requests
@@ -21,7 +23,8 @@ import requests
import db
import telegram
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)
VEX_PREC = Decimal('0.0001')
@@ -272,7 +275,7 @@ def run_distribution(dry_run=False):
total_share = sum(share for _, share in shares)
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)
run_id = db.record_run(run_date, float(balance), len(voters), total_stake,
0.0, 'ok', created_at)
@@ -286,7 +289,7 @@ def run_distribution(dry_run=False):
flush=True)
failed = []
sent_amount = 0.0
for pid, owner, share in pending:
for idx, (pid, owner, share) in enumerate(pending):
txid = None
for attempt in range(1, DISTRIBUTE_MAX_ATTEMPTS + 1):
try:
@@ -336,6 +339,12 @@ def run_distribution(dry_run=False):
print(f'[GAGAL] {owner}: {share} VEX — {exc}', flush=True)
break
time.sleep(2)
# V63: jeda ANTAR pemilih — beri node/Hyperion waktu mengejar indeks
# + meratakan beban broadcast run panjang. N pemilih → N−1 tidur
# (⊥ setelah pemilih terakhir); tetap jalan walau pemilih ini
# ditahan/gagal (pacing seragam). Dry-run ⊖ pernah sampai sini.
if idx < len(pending) - 1:
time.sleep(DISTRIBUTE_SEND_DELAY)
status = 'ok' if not failed else 'partial'
db.update_run_status(run_id, status, round(sent_amount, 4))
+3
View File
@@ -86,6 +86,8 @@ services:
TZ: ${TZ:-Asia/Jakarta}
SCAN_HOURS: ${SCAN_HOURS:-0,8,16}
SCAN_RUN_ON_START: ${SCAN_RUN_ON_START:-1}
volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
networks:
- appnet
deploy:
@@ -111,6 +113,7 @@ services:
DISTRIBUTE_WEEKDAY: ${DISTRIBUTE_WEEKDAY:-wed,sat}
DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17}
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
DISTRIBUTE_SEND_DELAY: ${DISTRIBUTE_SEND_DELAY:-30}
DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false}
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
volumes:
+47 -2
View File
@@ -15,7 +15,9 @@ from datetime import datetime, timedelta, timezone
import requests
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)
CODE = 'vexcore'
@@ -154,7 +156,8 @@ def persist(rows):
Lalu `db.record_first_seen` (V41): catat kemunculan pertama tiap owner
(idempoten) utk membedakan pemilih BARU vs REVOTE di dashboard.
"""
scanned_at = datetime.now().isoformat(timespec='seconds')
scanned_at = datetime.now(timezone.utc).replace(tzinfo=None).isoformat(
timespec='seconds')
db.replace_snapshot(rows, scanned_at)
db.record_first_seen(rows, scanned_at)
return scanned_at
@@ -174,6 +177,47 @@ def summarize(rows, scanned_at):
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():
"""Titik masuk CLI."""
print(f'Mulai memindai voters untuk BP: "{TARGET_BP}"...')
@@ -185,6 +229,7 @@ def main():
if item is not None:
filtered.append(item)
scanned_at = persist(filtered)
_notify_warn(now)
summarize(filtered, scanned_at)
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
mulai/selesai distribusi; INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`) detail
distribusi + semua kegagalan + klaim reward (⊥ komunitas). Token tunggal
mulai/selesai distribusi + VOTE ULANG SEGERA; INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`)
detail distribusi + semua kegagalan + klaim reward (⊥ komunitas). Token tunggal
`TELEGRAM_BOT_TOKEN`. Tanpa token/daftar → kanal itu no-op senyap (⊥ ganggu
run). Gagal kirim hanya dicatat — ⊥ pernah crash distribusi/klaim.
V66: semua notifikasi kini punya emoticon prefix.
"""
import requests
@@ -12,17 +14,23 @@ import requests
from config import (TELEGRAM_BOT_TOKEN, TELEGRAM_COMMUNITY_CHAT_IDS,
TELEGRAM_INTERNAL_CHAT_IDS)
# Batas panjang pesan Telegram per kiriman (karakter)
_MAX_MSG_LEN = 4096
def send_text(text, chat_ids=None):
"""Kirim `text` ke daftar chat id. `chat_ids=None` → kanal komunitas.
Best-effort: tanpa token/daftar → no-op; gagal → log saja.
Return True bila berhasil ke SEMUA chat id (setidaknya satu terkirim),
False bila no-op (token/daftar kosong) atau ada yang gagal.
"""
if chat_ids is None:
chat_ids = TELEGRAM_COMMUNITY_CHAT_IDS
if not TELEGRAM_BOT_TOKEN or not chat_ids:
return
return False
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
ok = True
for chat_id in chat_ids:
try:
resp = requests.post(url, json={'chat_id': chat_id, 'text': text},
@@ -31,7 +39,8 @@ def send_text(text, chat_ids=None):
except requests.RequestException as exc:
print(f'[Peringatan] Gagal kirim Telegram ke {chat_id}: {exc}',
flush=True)
ok = False
return ok
def send_internal(text):
"""Kirim ke kanal internal (detail + kegagalan + klaim)."""
@@ -40,61 +49,126 @@ def send_internal(text):
def notify_start(run_date, n_voters, total_share, balance):
"""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(
f'DISTRIBUSI MULAI\n'
f'Tanggal: {run_date}\n'
f'Pemilih: {n_voters}\n'
f'Total share: {total_share} VEX\n'
f'Saldo: {balance} VEX'
f'🚀 DISTRIBUSI MULAI\n'
f'📅 Tanggal: {run_date}\n'
f'👥 Pemilih: {n_voters}\n'
f'💎 Total share: {total_share} VEX\n'
f'💰 Saldo: {balance} VEX'
)
def notify_finish(status, sent, total, total_share, failed, run_date):
"""Run selesai: komunitas brief + internal detail (ok | partial)."""
if status == 'ok':
emoji = '✅'
label = 'Berhasil'
else:
emoji = '⚠️'
label = 'Sebagian'
send_text(
f'DISTRIBUSI SELESAI\n'
f'Tanggal: {run_date}\n'
f'Status: {status}'
f'{emoji} DISTRIBUSI SELESAI\n'
f'📅 Tanggal: {run_date}\n'
f'📊 Status: {label}'
)
send_internal(
f'DISTRIBUSI SELESAI\n'
f'Tanggal: {run_date}\n'
f'Status: {status}\n'
f'Terkirim: {sent}/{total} pemilih\n'
f'Total share: {total_share} VEX\n'
f'Gagal: {len(failed)}'
f'{emoji} DISTRIBUSI SELESAI\n'
f'📅 Tanggal: {run_date}\n'
f'📊 Status: {label}\n'
f'👥 Terkirim: {sent}/{total} pemilih\n'
f'💎 Total share: {total_share} VEX\n'
f'❌ Gagal: {len(failed)}'
)
def notify_failure(exc):
"""Run gagal total — kanal internal saja (ops detail)."""
send_internal(
f'DISTRIBUSI GAGAL\n'
f'Error: {exc}'
f'🔴 DISTRIBUSI GAGAL\n'
f'❗ Error: {exc}'
)
def notify_claim(reward, fee, wallet):
"""Klaim reward BP sukses — kanal internal saja, ⊥ komunitas (V36)."""
send_internal(
f'KLAIM REWARD SUKSES\n'
f'Reward: {reward} VEX\n'
f'Fee BP: {fee} VEX → {wallet}'
f'💰 KLAIM REWARD SUKSES\n'
f'💎 Reward: {reward} VEX\n'
f'💸 Fee BP: {fee} VEX → {wallet}'
)
def notify_claim_unmeasured(txid_label):
"""Klaim mendarat tapi reward tak terukur (V45) — internal, ⊥ "sukses 0"."""
send_internal(
f'KLAIM MENDARAT · REWARD TAK TERUKUR\n'
f'Periksa reward & fee BP manual (txid {txid_label})'
f'🔍 KLAIM MENDARAT · REWARD TAK TERUKUR\n'
f'⚠️ Periksa reward & fee BP manual (txid {txid_label})'
)
def notify_claim_failure(exc):
"""Kegagalan siklus klaim — kanal internal saja (V36)."""
send_internal(
f'KLAIM GAGAL\n'
f'Error: {exc}'
f'🔴 KLAIM GAGAL\n'
f'❗ Error: {exc}'
)
# ——————————————————————————————————————————————————————————————————————
# V66: Notifikasi VOTE ULANG SEGERA — komunitas.
# voters = list of (owner, staked, urgency) di mana urgency 1=⚠️ 2=🔴 3=🚨.
# ——————————————————————————————————————————————————————————————————————
_URGENCY_EMOJI = {1: '⚠️', 2: '🔴', 3: '🚨'}
def notify_warn(voters):
"""VOTE ULANG SEGERA — komunitas, best-effort.
Kirim daftar akun yang vote-nya akan kedaluwarsa, diurutkan berdasarkan
urgensi (paling mendesak di atas). Maks 50 entri; sisanya arahkan ke
dashboard. Return True bila SEMUA pesan terkirim, False bila no-op/gagal.
"""
if not voters:
return True
# Urutkan: urgensi tinggi dulu (3 > 2 > 1)
sorted_voters = sorted(voters, key=lambda v: v[2], reverse=True)
truncated = len(sorted_voters) > 50
display = sorted_voters[:50]
# Emoji dari baris paling mendesak
top_emoji = _URGENCY_EMOJI.get(display[0][2], '⚠️')
lines = [f'{top_emoji} VOTE ULANG SEGERA', '',
f'{len(sorted_voters)} akun akan kedaluwarsa:', '']
for owner, staked, urgency in display:
emoji = _URGENCY_EMOJI.get(urgency, '⚠️')
lines.append(f'• {emoji} {owner} — {staked} VEX')
lines.append(f' ↳ https://mining.databisnis.id?q={owner}')
if truncated:
lines.append('')
lines.append('📋 Cek https://mining.databisnis.id untuk daftar lengkap')
lines.append('')
lines.append('Vote ulang sekarang agar tidak kehilangan hak bagian!')
text = '\n'.join(lines)
sent = True
# Telegram batas 4096 karakter — split bila perlu
if len(text) <= _MAX_MSG_LEN:
sent = send_text(text)
else:
# Kirim header dulu, lalu sisa per-chunk
header = f'{top_emoji} VOTE ULANG SEGERA\n\n{len(sorted_voters)} akun akan kedaluwarsa:\n'
sent = send_text(header) and sent
chunk = []
chunk_len = 0
for owner, staked, urgency in display:
emoji = _URGENCY_EMOJI.get(urgency, '⚠️')
line = f'• {emoji} {owner} — {staked} VEX\n ↳ https://mining.databisnis.id?q={owner}\n'
if chunk_len + len(line) > _MAX_MSG_LEN - 100:
sent = send_text('\n'.join(chunk)) and sent
chunk = []
chunk_len = 0
chunk.append(line.rstrip())
chunk_len += len(line)
if chunk:
sent = send_text('\n'.join(chunk)) and sent
sent = send_text('Vote ulang sekarang agar tidak kehilangan hak bagian!') and sent
return sent
+5
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RIWAYAT DISTRIBUSI — {{ bp }}</title>
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicon-32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicon-16.png') }}">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<link rel="apple-touch-icon" sizes="192x192" href="{{ url_for('static', filename='icon-192.png') }}">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
+7 -2
View File
@@ -3,7 +3,12 @@
<head>
<meta charset="utf-8">
<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.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">
@@ -19,7 +24,7 @@
<main class="page">
<section class="hero">
<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>
</section>
+7 -1
View File
@@ -109,7 +109,7 @@ def main():
resp = client.get('/?page=1')
check('V10 halaman 1 → 200', resp.status_code == 200)
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('V51 akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' in html
@@ -198,6 +198,10 @@ def main():
'tag-revote">REVOTE' not in _slice_rows(html))
# ————— V38: banner BERITA jadwal distribusi berikutnya —————
# V64/B25: mock `_now` (helper jam) ke tanggal TETAP — ⊖ jam dinding
# nyata (oracle lulus hanya kalau kalender < tanggal launch baked).
_orig_dash_now = dashboard._now
dashboard._now = lambda: datetime(2026, 8, 6, 9, 0) # sebelum launch
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 17, 10, 0)
resp = client.get('/')
html = resp.get_data(as_text=True)
@@ -212,10 +216,12 @@ def main():
check('V38 _is_first_run_next setelah launch = False',
dashboard._is_first_run_next(datetime(2026, 8, 18)) is False)
dashboard._now = lambda: datetime(2026, 8, 18, 9, 0) # setelah launch
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 22, 10, 0)
html = client.get('/').get_data(as_text=True)
check('V38 baris berulang tampil utk putaran mingguan',
'SETIAP RABU & SABTU · 10:00' in html)
dashboard._now = _orig_dash_now
# drift-guard: logika boundary mirror distribute_loop (V31 ⊥ impor)
import distribute_loop
+24 -2
View File
@@ -485,6 +485,28 @@ def main():
dist.verify_txid = _orig_verify
dist.time.sleep = _orig_sleep
# ————— V63: jeda antar kirim — sleep `DISTRIBUTE_SEND_DELAY` ANTAR
# pemilih; N pemilih → N−1 tidur (⊥ setelah pemilih terakhir) —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 200.0, '2026-08-05T00:00:00',
fresh),
('bbb2', '1.0', 300.0, '2026-08-05T00:00:00',
fresh),
('ccc3', '1.0', 500.0, '2026-08-05T00:00:00',
fresh)])
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txD' + to)
dist.verify_txid = lambda txid: True
sleeps = []
dist.time.sleep = lambda s: sleeps.append(s)
with redirect_stdout(io.StringIO()):
rc = dist.run_distribution(dry_run=False)
check('V63 run nyata exit 0 (jeda antar kirim)', rc == 0)
check('V63 3 pemilih → 2 jeda @ DISTRIBUTE_SEND_DELAY',
sleeps == [dist.DISTRIBUTE_SEND_DELAY] * 2)
dist.time.sleep = _orig_sleep
# ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
fresh)])
@@ -598,12 +620,12 @@ def main():
check('V30 komunitas brief MULAI', 'DISTRIBUSI MULAI' in comm[0][2]
and 'Pemilih' not in comm[0][2])
check('V30 komunitas brief SELESAI + status', 'DISTRIBUSI SELESAI'
in comm[1][2] and 'Status: ok' in comm[1][2]
in comm[1][2] and 'Berhasil' in comm[1][2]
and 'Terkirim' not in comm[1][2])
check('V30 internal 2 notif detail', len(intern) == 2)
check('V30 internal detail MULAI', 'DISTRIBUSI MULAI' in intern[0][2]
and 'Pemilih: 2' in intern[0][2])
check('V30 internal detail SELESAI', '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])
# no-op (pemilih kosong) → ⊥ notif baru
+146 -1
View File
@@ -10,12 +10,13 @@ membuktikan ia lolos ke snapshot, bukan dibuang.
import os
import sqlite3
import tempfile
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
import requests
import db
import get_voters as gv
import telegram
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 kolom last_vote ada', 'last_vote' in cols)
# V62: stempel scan UTC-naive (konsisten dgn cutoff umur dashboard/
# distribusi yang pakai `datetime.now(timezone.utc)`). Dulu `datetime.now()`
# (waktu lokal container, TZ=Asia/Jakarta UTC+7) → `first_seen_at` WIB
# dibandingkan sbg string UTC → maturity pemilih BARU bergeser +7 jam
# (B24). Guard: stamp ≈ now-UTC-naive, ⊖ WIB (offset ±2 mnt toleransi).
utc_now = datetime.now(timezone.utc).replace(tzinfo=None)
for s in (scanned_a, scanned_b):
stamp = datetime.fromisoformat(s)
delta = abs((utc_now - stamp).total_seconds())
check(f'V62 stempel scan UTC-naive ({s})',
delta < 120 and stamp.tzinfo is None)
wib_delta = abs((utc_now - timedelta(hours=7) - stamp).total_seconds())
check(f'V62 stempel scan ⊖ WIB ({s})', wib_delta > 60)
# V41: first_seen — kemunculan pertama tiap owner, idempoten (⊥ overwrite)
db.record_first_seen([{'owner': 'aaa1'}], '2000-01-01T00:00:00')
conn = sqlite3.connect(db.DB_PATH)
@@ -243,6 +258,136 @@ def main():
check('V41 first_seen idempoten (record ulang ⊥ overwrite)',
fs['aaa1'] == scanned_a and fs['aaa1'] != '2000-01-01T00:00:00')
# V66: voter_notify — tabel lacak notifikasi VOTE ULANG SEGERA
db.ensure_notify_schema()
conn = sqlite3.connect(db.DB_PATH)
cols = [r[1] for r in conn.execute(
'PRAGMA table_info(voter_notify)').fetchall()]
pk = [r[1] for r in conn.execute(
'PRAGMA table_info(voter_notify)').fetchall() if r[5]]
conn.close()
check('V66 voter_notify tabel ada', 'owner' in cols)
check('V66 voter_notify PK = owner', pk == ['owner'])
check('V66 urgency kolom ada', 'urgency' in cols)
check('V66 notified_at kolom ada', 'notified_at' in cols)
# record_notify: insert baru
db.record_notify('aaa1', 1, '2026-08-27T08:00:00')
check('V66 get_notify_urgency(aaa1) = 1', db.get_notify_urgency('aaa1') == 1)
check('V66 get_notify_urgency(unk) = None', db.get_notify_urgency('unk9') is None)
# record_notify: idempoten (INSERT OR IGNORE) — urgency tetap 1
db.record_notify('aaa1', 1, '2026-08-27T08:00:00')
check('V66 record_notify idempoten', db.get_notify_urgency('aaa1') == 1)
# record_notify: urgency naik → update
db.record_notify('aaa1', 2, '2026-08-28T08:00:00')
check('V66 urgency naik 1→2', db.get_notify_urgency('aaa1') == 2)
# urgency turun → ⊥ update
db.record_notify('aaa1', 1, '2026-08-29T08:00:00')
check('V66 urgency turun 2→1 ⊥ update', db.get_notify_urgency('aaa1') == 2)
# cleanup_notify: hapus baris tua
db.record_notify('old1', 1, '2026-08-01T00:00:00')
db.record_notify('new1', 1, '2026-08-27T00:00:00')
db.cleanup_notify('2026-08-15T00:00:00')
check('V66 cleanup hapus baris tua', db.get_notify_urgency('old1') is None)
check('V66 cleanup simpan baris baru', db.get_notify_urgency('new1') == 1)
# V66: _notify_warn — integrasi dengan telegram.notify_warn
# Siapkan pemilih dalam jendela VOTE ULANG SEGERA (last_vote 26 hari lalu)
warn_now = datetime(2026, 8, 27, 8, 0, 0)
warn_vote = (warn_now - timedelta(days=26)).isoformat(timespec='seconds')
safe_vote = (warn_now - timedelta(days=20)).isoformat(timespec='seconds')
# Masukkan pemilih langsung ke DB (bypass scan)
wrn2_vote = (warn_now - timedelta(days=27)).isoformat(timespec='seconds')
db.replace_snapshot([
{'owner': 'wrn1', 'weight': '1.0', 'staked': 5000.0,
'last_vote': warn_vote},
{'owner': 'wrn2', 'weight': '2.0', 'staked': 3000.0,
'last_vote': wrn2_vote},
{'owner': 'safe', 'weight': '3.0', 'staked': 8000.0,
'last_vote': safe_vote},
], warn_now.isoformat(timespec='seconds'))
sent = []
_orig_warn = telegram.notify_warn
telegram.notify_warn = lambda v: sent.append(v) or True
try:
gv._notify_warn(warn_now)
check('V66 _notify_warn dipanggil', len(sent) == 1)
voters_notified = sent[0]
owners_notified = [v[0] for v in voters_notified]
check('V66 wrn2 (27h, ≤2d) masuk', 'wrn2' in owners_notified)
check('V66 wrn1 (26h, >2d) masuk', 'wrn1' in owners_notified)
check('V66 safe (20h, ⊥ window) tak masuk', 'safe' not in owners_notified)
# Cek urgensi
by_owner = {v[0]: v[2] for v in voters_notified}
check('V66 wrn2 urgensi = 3 (≤1d)', by_owner['wrn2'] == 3)
check('V66 wrn1 urgensi = 2 (≤2d)', by_owner['wrn1'] == 2)
# Cek voter_notify tercatat
check('V66 voter_notify wrn2 = 3', db.get_notify_urgency('wrn2') == 3)
check('V66 voter_notify wrn1 = 2', db.get_notify_urgency('wrn1') == 2)
finally:
telegram.notify_warn = _orig_warn
# V66: _notify_warn — skip bila sudah diberi tahu pada urgensi ≥ saat ini
sent.clear()
telegram.notify_warn = lambda v: sent.append(v) or True
try:
gv._notify_warn(warn_now) # wrn1 sudah urgensi 2, wrn2 sudah urgensi 3
check('V66 _notify_warn skip redundancy', len(sent) == 0)
finally:
telegram.notify_warn = _orig_warn
telegram.notify_warn = lambda v: sent.append(v) or True
sent.clear()
# Hapus lacak utk wrn1 supaya bisa test escalation dratch
conn = sqlite3.connect(db.DB_PATH)
conn.execute('DELETE FROM voter_notify WHERE owner = ?', ('wrn1',))
conn.commit()
conn.close()
# Update wrn1 last_vote ke 27 hari lalu (≤1d → urgensi 3)
closer_vote = (warn_now - timedelta(days=27)).isoformat(
timespec='seconds')
db.replace_snapshot([
{'owner': 'wrn1', 'weight': '1.0', 'staked': 5000.0,
'last_vote': closer_vote},
{'owner': 'wrn2', 'weight': '2.0', 'staked': 3000.0,
'last_vote': wrn2_vote},
], warn_now.isoformat(timespec='seconds'))
telegram.notify_warn = lambda v: sent.append(v) or True
try:
gv._notify_warn(warn_now)
check('V66 escalation trigger (2→3)', len(sent) == 1)
by_owner = {v[0]: v[2] for v in sent[0]}
check('V66 wrn1 escalation ke urgensi 3', by_owner['wrn1'] == 3)
check('V66 voter_notify wrn1 updated ke 3',
db.get_notify_urgency('wrn1') == 3)
finally:
telegram.notify_warn = _orig_warn
# V66: _notify_warn — ⊥ catat bila kirim gagal senyap (send_text no-op/Gagal)
# Regresi: token kosong → telegram no-op → record_notify TETAP dicatat dulu
# (dedup palsu memblokir kirim saat scan berikutnya). Kini hanya dicatat
# bila notify_warn berhasil (return True).
sent.clear()
conn = sqlite3.connect(db.DB_PATH)
conn.execute('DELETE FROM voter_notify') # bersihkan semua lacak
conn.commit()
conn.close()
fail_warn = lambda v: sent.append(v) and False # gagal/no-op
telegram.notify_warn = fail_warn
try:
gv._notify_warn(warn_now)
check('V66 kirim gagal → dipanggil', len(sent) == 1)
check('V66 kirim gagal → voter_notify ⊖ dicatat',
db.get_notify_urgency('wrn1') is None)
check('V66 kirim gagal → voter_notify wrn2 ⊖ dicatat',
db.get_notify_urgency('wrn2') is None)
finally:
telegram.notify_warn = _orig_warn
print('\nSEMUA UJI PASS')