V40-V43: band umur vote (BARU/VALID/KADALUARSA, scan simpan semua umur) → REVOTE = VALID langsung (V42) → tag mint 1-hari + badge VOTE ULANG SEGERA (V43) ; klaim recovery jendela lewat + parse respons dict rows (B7/B8) ; backend mysql test_mariadb ; oracle test_dashboard/distribute/get_voters/mariadb ; images web/scan/dist/claim rebuild + push
This commit is contained in:
1 parent
af9a0291b9
commit
81ec6bdfe7
17 files changed
+910
-144
No files matched your search
+22
-1
@@ -34,9 +34,30 @@ DASH_PORT=5000
|
|||||||
# Dashboard: jumlah worker gunicorn (produksi)
|
# Dashboard: jumlah worker gunicorn (produksi)
|
||||||
DASH_WORKERS=2
|
DASH_WORKERS=2
|
||||||
|
|
||||||
# Ambang batas vote "basi" (hari sejak revote terakhir) untuk penanda staleness
|
# Ambang batas vote "basi" (hari sejak revote terakhir). Pemilih lewat batas
|
||||||
|
# ini disimpan scan tapi keluar band VALID (bagian KADALUARSA 28..31 hari,
|
||||||
|
# >31 hari disembunyikan dari dashboard).
|
||||||
VEX_STALE_DAYS=28
|
VEX_STALE_DAYS=28
|
||||||
|
|
||||||
|
# Masa matang (hari): pemilih baru (revote < VEX_MATURITY_DAYS lalu) BELUM
|
||||||
|
# berhak reward — dibayar mulai siklus berikutnya setelah votenya berumur ≥
|
||||||
|
# ambang ini (band VALID).
|
||||||
|
VEX_MATURITY_DAYS=3
|
||||||
|
|
||||||
|
# Jendela "kadaluarsa" (hari SETELAH VEX_STALE_DAYS) yang masih tampil di
|
||||||
|
# dashboard dengan sorot VOTE ULANG; lewat 28+3=31 hari → disembunyikan.
|
||||||
|
VEX_EXPIRED_DAYS=3
|
||||||
|
|
||||||
|
# Tag REVOTE (mint) bertahan (hari) sejak revote terakhir: hanya pemilih lama
|
||||||
|
# yang revote dalam VEX_REVOTE_TAG_DAYS terakhir diberi tag; setelah itu tampil
|
||||||
|
# sbg VALID biasa (tetap dibayar langsung — V42, ⊥ tunggu matang).
|
||||||
|
VEX_REVOTE_TAG_DAYS=1
|
||||||
|
|
||||||
|
# Jendela peringatan (hari SEBELUM VEX_STALE_DAYS): pemilih VALID yang
|
||||||
|
# `now − STALE < last_vote ≤ now − (STALE − WARN)` diberi badge `VOTE ULANG
|
||||||
|
# SEGERA` (segera kadaluarsa, masih dibayar).
|
||||||
|
VEX_WARN_DAYS=3
|
||||||
|
|
||||||
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard)
|
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard)
|
||||||
DATABISNIS_API=https://api.databisnis.id
|
DATABISNIS_API=https://api.databisnis.id
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
|
|||||||
|
|
||||||
- Node reference script: `node get_voters.js` (no deps, Node 18+).
|
- Node reference script: `node get_voters.js` (no deps, Node 18+).
|
||||||
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) + `pyntelope` (distribute signing) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
|
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) + `pyntelope` (distribute signing) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
|
||||||
- Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot, fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed.
|
- Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot **restricted to the reward window** (mature + fresh, `now−28d < last_vote ≤ now−3d`, plus REVOTE immediately, §V40/V42: only new voters under `VEX_MATURITY_DAYS` and stale voters get nothing — returning re-voters skip maturity), fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed.
|
||||||
- Daily 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), sleeps until near it, then polls `vexcore::claimrewards` every `CLAIM_RETRY_SECONDS` (default 60) until the chain accepts. Reward is measured as the liquid-balance delta around the claim; 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
|
- Daily reward claim (production): `./venv/bin/python claim_loop.py` — the `claim` service scheduler. `claim.py` computes the claim window as `last_claim_time + 24h` (from the `vexcore` `producers` table), 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). Reward is measured as the liquid-balance delta around the claim; 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
|
||||||
- Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
|
- Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
|
||||||
- Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container:
|
- Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container:
|
||||||
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
|
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
|
||||||
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`; 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` weekly at `DISTRIBUTE_WEEKDAY` default `sat` + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
|
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`; 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` weekly at `DISTRIBUTE_WEEKDAY` default `sat` + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
|
||||||
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
|
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42): BARU (new first-time voters, vote < 3 days, own table on top, never paid) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with `VOTE ULANG` badge + legend) then VALID (the paged 50/page list, `staked DESC`, live owner search via `/api/search`); voters older than 31 days are stored by the scan but hidden. Since V42, **REVOTE** (returning voters, `voter_first_seen.first_seen_at ≤ now−3d`) are VALID immediately — they join the main list tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`), no separate band, no maturity wait; since V43 the mint tag shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS` (default 1 day) — after that they're plain VALID (still paid); VALID rows within `VEX_WARN_DAYS` (default 3) of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge (legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH`); only genuinely new voters pass the 3-day maturity in BARU. Data freshness comes from the daily scan run — the dashboard never scans.
|
||||||
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `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_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `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.
|
||||||
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`.
|
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`.
|
||||||
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37) is pure calendar logic — weekly schedule boundary + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
|
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37) is pure calendar logic — weekly schedule boundary + 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,27 +26,27 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
|
|||||||
- The node sometimes returns `staked` as a string instead of a number — normalize coerces to float (spec §V8).
|
- The node sometimes returns `staked` as a string instead of a number — normalize coerces to float (spec §V8).
|
||||||
- The voters table has a sentinel first row (`owner="...........q"`, uint64 0) that must be skipped.
|
- The voters table has a sentinel first row (`owner="...........q"`, uint64 0) that must be skipped.
|
||||||
- Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6).
|
- Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6).
|
||||||
- Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`.
|
- Freshness filter (spec §V13/V40): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. 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 from `last_vote` age at query time (dashboard cuts 3/28/31 days; distribution pays only `now−28d < last_vote ≤ now−3d`, plus REVOTE immediately, V42). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`. Since V41, `voter_first_seen.first_seen_at` separates PEMILIH BARU (new, first-seen within the 3-day maturity) from REVOTE (returning re-voters); since V42 REVOTE are VALID immediately (paid + shown in the main list, mint tag), only new voters wait out maturity; since V43 the mint tag lasts only `VEX_REVOTE_TAG_DAYS` (1) and VALID rows nearing the KADALUARSA cutoff (within `VEX_WARN_DAYS`, 3) get a `VOTE ULANG SEGERA` badge — display-only.
|
||||||
- The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of `distribute_payments.amount` where `status='sent'` (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
|
- The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of `distribute_payments.amount` where `status='sent'` (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
|
||||||
- Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key.
|
- Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key.
|
||||||
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next run. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
|
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next run. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
|
||||||
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam. Reward = liquid-balance delta (after − before the claim, settle delay); 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run.
|
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam; a window already ≥24h past clamps to now so a missed claim is caught up, never spin-timeout (B8). `get_table_rows` responses are always `{"rows":[...]}` — parse `data.get('rows')`, never `data[0]` (B7, V39). Reward = liquid-balance delta (after − before the claim, settle delay); 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run.
|
||||||
- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr); `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to `https://vexascan.com/transaction/{txid}` — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
|
- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr); `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to `https://vexascan.com/transaction/{txid}` — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
|
||||||
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=<BP>`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
|
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=<BP>`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.
|
- `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.
|
||||||
- `get_voters.py` — production fetcher: scan → filter (stake + freshness, basi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history).
|
- `get_voters.py` — production fetcher: scan → filter (stake-min + BP target, semua umur vote disimpan V40; hanya last_vote tak terverifikasi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history) → `db.record_first_seen` (V41: catat kemunculan pertama tiap owner ke `voter_first_seen`, idempoten).
|
||||||
- `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`.
|
- `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.
|
||||||
- `get_voters.js` — reference implementation only.
|
- `get_voters.js` — reference implementation only.
|
||||||
- `distribute.py` — pembayaran harian: fetch liquid balance → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
|
- `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V42** (`last_vote > now−28d AND (≤ now−3d OR REVOTE)` via LEFT JOIN `voter_first_seen` — pemilih baru < `VEX_MATURITY_DAYS` & basi ⊥ dibayar, tapi pemilih lama yang REVOTE dibayar langsung) → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
|
||||||
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`) → `next_window` = +24 jam; tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; reward = selisih saldo (setelah−sebelum, settle); fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; `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, temp DB).
|
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`) → `next_window` = +24 jam (≥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`; reward = selisih saldo (setelah−sebelum, settle); fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; parse `get_table_rows` pakai `data.get('rows')` (bentuk asli dict, ⊥ `data[0]` — B7/V39); `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, bentuk respons asli, temp DB).
|
||||||
- `claim_loop.py` — scheduler harian dalam container stack (service `claim`): `_wait_db` (duplikat scan_loop) lalu `claim.step()` terus, loop tak pernah keluar.
|
- `claim_loop.py` — scheduler harian dalam container stack (service `claim`): `_wait_db` (duplikat scan_loop) lalu `claim.step()` terus, loop tak pernah keluar.
|
||||||
- `telegram.py` — kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`.
|
- `telegram.py` — kirim notifikasi status distribusi + klaim 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. The voters list (`voter-list` section, `.reward` cells, V29) LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column. 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). `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
|
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40): the voters list is split into BARU (top, `PEMILIH BARU · BELUM MATANG`) 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`). 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). `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
|
||||||
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
|
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
|
||||||
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
|
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
|
||||||
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar.
|
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar.
|
||||||
- `distribute_loop.py` — scheduler mingguan dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `sat`) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
|
- `distribute_loop.py` — scheduler mingguan dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `sat`) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
|
||||||
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
|
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
|
||||||
|
|||||||
@@ -81,14 +81,14 @@ V3: ∀ page after first → shift 1st row (dup guard)
|
|||||||
V4: skip `owner="...........q"` (uint64 0 sentinel) rows
|
V4: skip `owner="...........q"` (uint64 0 sentinel) rows
|
||||||
V5: persist ∃ row where producers length 1 & [0]==TARGET_BP
|
V5: persist ∃ row where producers length 1 & [0]==TARGET_BP
|
||||||
V6: weight stored string verbatim, ⊥ float conversion
|
V6: weight stored string verbatim, ⊥ float conversion
|
||||||
V7: each scan replaces table; ∀ row → latest snapshot; ⊥ append/duplicate owner
|
V7: each scan replaces table; ∀ row → latest snapshot; ⊥ append/duplicate owner; V41: scan juga `record_first_seen` tiap owner ke tabel `voter_first_seen` (INSERT OR IGNORE/IGNORE, append-only, PK owner) — `first_seen_at` = kemunculan pertama, ⊥ overwrite; `voters` tetap snapshot murni
|
||||||
V8: normalize coerces `staked` → float (node kadang memberi string)
|
V8: normalize coerces `staked` → float (node kadang memberi string)
|
||||||
V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000)
|
V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000)
|
||||||
V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB
|
V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB
|
||||||
V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensitive); urut+paging terjaga; `rank` global (⊥ rank-dalam-filter); `?q=` & `/api/search` read-only
|
V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensitive) di SEMUA band tampil (V40); urut+paging terjaga; `?q=` (daftar datar ber-tag) & `/api/search` read-only; tiap hasil membawa `group` (`baru`|`valid`|`kadaluarsa`) + `expired`; rank = ordinal DALAM kelompok (⊥ rank global lintas band, V40)
|
||||||
V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config`
|
V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config`
|
||||||
V13: `last_vote` = `2000-01-01 + log2(last_vote_weight / (staked×10000)) × 365.25 hari` (None bila bobot/stake nol); estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` atau `< now − VEX_STALE_DAYS` dibuang di `normalize` (⊥ disimpan) → DB hanya berisi pemilih segar
|
V13: `last_vote` = `2000-01-01 + log2(last_vote_weight / (staked×10000)) × 365.25 hari` (None bila bobot/stake nol); estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` dibuang di `normalize` (bobot tak bisa diverifikasi → tanggal tak diketahui, ⊥ bisa diklasifikasi); V40: vote basi (> VEX_STALE_DAYS) TIDAK lagi dibuang di scan — SEMUA umur disimpan; banding/tampil/bayar menghitung umur saat query (⊥ peran scan)
|
||||||
V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA, ⊥ penanda/stat/filter basi; stats = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI); `/api/search` hasil `{owner,staked,weight,rank,last_vote,total_reward}` (weight disimpan & di-API, ⊥ dirender)
|
V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA; stats = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI) atas semua baris TAMPIL (V40: ⊖ yang tersembunyi > 31 hari); V40/V42/V43: BARU (`PEMILIH BARU · BELUM MATANG`, new saja) + daftar utama — kadaluarsa menyatu DI ATAS baris VALID (dipin, sorot amber + badge `VOTE ULANG` + legenda `BARIS AMBER = VOTE LEWAT …`), lalu VALID (pager) = matang-baru + REVOTE (tag mint `REVOTE` + legenda `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, V43) + badge amber dashed `VOTE ULANG SEGERA` (V43) pada baris `exp_cut < last_vote ≤ warn_cut` + legenda `TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH`; REVOTE ⊖ lagi bagian sendiri (V42); `/api/search` hasil `{owner,staked,weight,rank,last_vote,total_reward,group,expired,expiring}` — `group` = `baru|revote|valid|kadaluarsa` (weight disimpan & di-API, ⊥ dirender)
|
||||||
V16: saldo liquid akun `TARGET_BP` dari `GET {DATABISNIS_API}/v2/state/get_account` (parse `account.core_liquid_balance`) → sel stat SALDO LIQUID; cache in-memory per worker TTL `DASH_LIQUID_TTL`; gagal fetch → nilai lama (atau `—` bila belum pernah sukses) + `at` ikut diset (cooldown, ⊥ pukulan berulang); read-only, ⊥ tulis DB
|
V16: saldo liquid akun `TARGET_BP` dari `GET {DATABISNIS_API}/v2/state/get_account` (parse `account.core_liquid_balance`) → sel stat SALDO LIQUID; cache in-memory per worker TTL `DASH_LIQUID_TTL`; gagal fetch → nilai lama (atau `—` bila belum pernah sukses) + `at` ikut diset (cooldown, ⊥ pukulan berulang); read-only, ⊥ tulis DB
|
||||||
V17: penyimpanan lewat `db.py` (backend `VEX_DB_BACKEND` = `sqlite` default | `mysql`); query berbagi sintaks pakai placeholder `%s` (diterjemahkan `?` utk sqlite); LIKE escape pakai `ESCAPE '!'` — backslash memutus literal string MySQL (B2); `db.query` → list (fetchall sqlite=list / PyMySQL=tuple diseragamkan, B3); replace snapshot mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi (MVCC → pembaca dapat snapshot konsisten saat ganti harian, tak kena torn read); oracle utama (test_get_voters/test_dashboard) tetap sqlite hermetik; `test_mariadb.py` opt-in (skip exit 0 bila `VEX_DB_BACKEND != mysql`)
|
V17: penyimpanan lewat `db.py` (backend `VEX_DB_BACKEND` = `sqlite` default | `mysql`); query berbagi sintaks pakai placeholder `%s` (diterjemahkan `?` utk sqlite); LIKE escape pakai `ESCAPE '!'` — backslash memutus literal string MySQL (B2); `db.query` → list (fetchall sqlite=list / PyMySQL=tuple diseragamkan, B3); replace snapshot mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi (MVCC → pembaca dapat snapshot konsisten saat ganti harian, tak kena torn read); oracle utama (test_get_voters/test_dashboard) tetap sqlite hermetik; `test_mariadb.py` opt-in (skip exit 0 bila `VEX_DB_BACKEND != mysql`)
|
||||||
V18: stack produksi = Docker Swarm (`docker-compose.yml`, `docker stack deploy -c docker-compose.yml databisnisid`): mariadb bind mount `/data/db/mariadb/databisnisid/data` pd node `server5.saltis.id` + placement constraint `node.hostname == server5.saltis.id` (data node-lokal → mariadb harus selalu di node itu; di swarm yg ⊥ punya node itu task-nya Pending, bukan bug); web (gunicorn, publik `:5000`) + scan (`scan_loop.py`) + distribute (`distribute_loop.py`, image `databisnisid-dist`) di node mana pun, terhubung via overlay `appnet`; image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` & `-dist` (⊥ `build:`); scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, lokal `TZ` default Asia/Jakarta via `datetime.now()`) + skan-awal `SCAN_RUN_ON_START` (setelah DB siap) → tabel voters langsung ada (dashboard ⊥ 500); distribute loop harian `DISTRIBUTE_HOUR` (default 10, lokal) — ⊥ distribusi-awal saat start (snapshot bisa basi); key `VEX_BP_PRIVATE_KEY` dari bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` dibaca `config.py` `load_dotenv` (⊥ interpolasi env compose); loop tak pernah keluar; `stack deploy` ⊥ `env_file` (env inline via `${VAR}` interpolasi `.env`); image web set `DASH_HOST=0.0.0.0` agar ingress menjangkau; healthcheck web = cek socket TCP (⊥ ketergantungan isi DB)
|
V18: stack produksi = Docker Swarm (`docker-compose.yml`, `docker stack deploy -c docker-compose.yml databisnisid`): mariadb bind mount `/data/db/mariadb/databisnisid/data` pd node `server5.saltis.id` + placement constraint `node.hostname == server5.saltis.id` (data node-lokal → mariadb harus selalu di node itu; di swarm yg ⊥ punya node itu task-nya Pending, bukan bug); web (gunicorn, publik `:5000`) + scan (`scan_loop.py`) + distribute (`distribute_loop.py`, image `databisnisid-dist`) di node mana pun, terhubung via overlay `appnet`; image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` & `-dist` (⊥ `build:`); scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, lokal `TZ` default Asia/Jakarta via `datetime.now()`) + skan-awal `SCAN_RUN_ON_START` (setelah DB siap) → tabel voters langsung ada (dashboard ⊥ 500); distribute loop harian `DISTRIBUTE_HOUR` (default 10, lokal) — ⊥ distribusi-awal saat start (snapshot bisa basi); key `VEX_BP_PRIVATE_KEY` dari bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` dibaca `config.py` `load_dotenv` (⊥ interpolasi env compose); loop tak pernah keluar; `stack deploy` ⊥ `env_file` (env inline via `${VAR}` interpolasi `.env`); image web set `DASH_HOST=0.0.0.0` agar ingress menjangkau; healthcheck web = cek socket TCP (⊥ ketergantungan isi DB)
|
||||||
@@ -105,7 +105,12 @@ V28: web list desktop → kolom AKUN/STAKE/REWARD/VOTE sama lebar (RANK tetap 72
|
|||||||
V29: kolom TOTAL REWARD (VEX) per pemilih = Σ `amount` di `distribute_payments` status=`sent` (semua run, sepanjang masa); voter tanpa transfer → `0,0000`; `failed`/`pending` ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); `/api/search` menyertakan `total_reward`; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker)
|
V29: kolom TOTAL REWARD (VEX) per pemilih = Σ `amount` di `distribute_payments` status=`sent` (semua run, sepanjang masa); voter tanpa transfer → `0,0000`; `failed`/`pending` ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); `/api/search` menyertakan `total_reward`; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker)
|
||||||
V30: notifikasi Telegram dua kanal (best-effort): `notify_start` saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), `notify_finish` setelah run tutup (status ok/partial), `notify_failure` saat exception di `main` (lalu re-raise); KOMUNITAS (`TELEGRAM_COMMUNITY_CHAT_IDS`) hanya brief mulai/selesai (tanggal + status, ⊥ detail); INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`) detail (pemilih/total share/saldo/terkirim/gagal) + semua kegagalan; no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa `TELEGRAM_BOT_TOKEN`/daftar → kanal senyap; gagal kirim → log warning, ⊥ pernah crash distribusi
|
V30: notifikasi Telegram dua kanal (best-effort): `notify_start` saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), `notify_finish` setelah run tutup (status ok/partial), `notify_failure` saat exception di `main` (lalu re-raise); KOMUNITAS (`TELEGRAM_COMMUNITY_CHAT_IDS`) hanya brief mulai/selesai (tanggal + status, ⊥ detail); INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`) detail (pemilih/total share/saldo/terkirim/gagal) + semua kegagalan; no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa `TELEGRAM_BOT_TOKEN`/daftar → kanal senyap; gagal kirim → log warning, ⊥ pernah crash distribusi
|
||||||
V31: ∀ image stack (web/scan/dist/claim) → tiap modul .py yang diimpor modul yang di-COPY ikut disalin (⊥ impor intra-proyek yang ⊥ ada di image); oracle `test_images.py` memindai import tiap image → daftar COPY memuat semua impor intra-proyek
|
V31: ∀ image stack (web/scan/dist/claim) → tiap modul .py yang diimpor modul yang di-COPY ikut disalin (⊥ impor intra-proyek yang ⊥ ada di image); oracle `test_images.py` memindai import tiap image → daftar COPY memuat semua impor intra-proyek
|
||||||
V32: klaim reward BP via aksi `vexcore::claimrewards` (owner=TARGET_BP), sign pyntelope `VEX_BP_PRIVATE_KEY`; jendela klaim = `last_claim_time` (tabel `producers`) + 24 jam; `claim_loop.py` tidur sampai mendekati jendela lalu poll tiap `CLAIM_RETRY_SECONDS` (default 60) sampai diterima chain; ⊥ klaim di luar jendela (ditolak chain → retry); setelah sukses jendela dihitung ulang dari `last_claim_time` segar; loop tak pernah keluar
|
V32: klaim reward BP via aksi `vexcore::claimrewards` (owner=TARGET_BP), sign pyntelope `VEX_BP_PRIVATE_KEY`; jendela klaim = `last_claim_time` (tabel `producers`) + 24 jam; `claim_loop.py` tidur sampai mendekati jendela lalu poll tiap `CLAIM_RETRY_SECONDS` (default 60) sampai diterima chain; ⊥ klaim di luar jendela (ditolak chain → retry); setelah sukses jendela dihitung ulang dari `last_claim_time` segar; RECOVERY: bila ≥24 jam sudah lewat sejak last → jendela = `now` (klaim dikejar segera, ⊥ spin-timeout utk jendela yang terlewat / container down); loop tak pernah keluar
|
||||||
|
V39: respons chain `get_table_rows` SELALU dict `{rows:[...]}` (⊥ list); semua parser memakai `data.get('rows')`; oracle memakai bentuk respons asli (mock `_post_json`, ⊥ mock fungsi parse) agar bentuk tak dikenal/tak terduga tertangkap; `get_currency_balance` memang list string — jangan 'diperbaiki'
|
||||||
|
V40: band umur vote (dashboard + distribusi, UTC-naive konsisten stempel scan): `age = now − last_vote`; BARU `age < VEX_MATURITY_DAYS` (default 3) → bagian sendiri (paling atas), ⊖ reward; VALID `VEX_MATURITY_DAYS ≤ age ≤ VEX_STALE_DAYS` (28) → bagian utama + pager, satu-satunya penerima reward; KADALUARSA `STALE < age ≤ STALE + VEX_EXPIRED_DAYS` (31) → baris menyatu di daftar utama, DIPIN di atas baris VALID, sorot amber + badge `VOTE ULANG` + legenda; HIDDEN `age > 31` → disimpan scan tapi ⊖ tampil; scan menyimpan semua umur (V13); stats/count/rank/search dibatasi band yang tampil (rank ordinal per band; jendela VALID utk pager); distribusi query `last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE)` (V42) — pemilih baru (< `VEX_MATURITY_DAYS`) ⊥ dibayar siklus itu (anti-gaming, kena lagi siklus berikutnya); REVOTE (sudah pernah terlihat) dibayar langsung, ⊖ menunggu matang
|
||||||
|
V41: pembagian pemilih belum-matang menjadi PEMILIH BARU (new) vs REVOTE (pemilih lama yang revote/perpanjang): `first_seen_at` (tabel `voter_first_seen`, V7) dibandingkan dgn `new_cut = now − MATURITY` — `first_seen > new_cut` (atau NULL/DB lama) → BARU; `first_seen ≤ new_cut` → REVOTE; klasifikasi tampilan/search (V15); cold-start: skan pertama mencatat semua owner sbg first-seen-now → semua BARU tampak new satu siklus, klasifikasi benar mulai siklus revote berikutnya
|
||||||
|
V42: REVOTE dianggap VALID LANGSUNG (dashboard + distribusi, V41): pemilih dgn `first_seen_at ≤ now − MATURITY` ⊖ perlu masa matang — memenuhi syarat reward begitu `last_vote > now − STALE`; hanya pemilih BARU (first_seen NULL atau `> now − MATURITY`) yang harus lewat masa matang (`last_vote ≤ now − MATURITY`); jendela VALID dashboard (`_db_rows`/`_count`/rank) = jendela eligibilitas distribusi = `last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE)` — konsisten; baris REVOTE menyatu di daftar utama dgn tag mint `REVOTE` (⊥ bagian sendiri, V15); `_stats` partisi `n_new + n_revote_window + n_expired + n_valid == total_voters` (`n_revote_window` = REVOTE jendela VALID; `n_revote` yang dirender = subset tag); distribusi LEFT JOIN `voter_first_seen` (`ensure_first_seen_schema`); oracle test_distribute (rev7 umur-1hr first_seen-60hr → dibayar; new1 umur-1hr → ⊖) + test_dashboard (revoter1 di #rows bertag)
|
||||||
|
V43: tag & badge display (display-only, ⊥ ubah eligibilitas/payout V40/V42): tag mint `REVOTE` HANYA utk pemilih lama yang revote dalam `VEX_REVOTE_TAG_DAYS` (default 1) terakhir (`last_vote > revote_cut = now − VEX_REVOTE_TAG_DAYS` AND `first_seen ≤ now − MATURITY`) — setelah itu tampil VALID polos (tetap dibayar langsung); badge amber `VOTE ULANG SEGERA` (outline dashed, elemen ke-9) utk baris VALID dgn `expired_cut < last_vote ≤ warn_cut = now − (VEX_STALE_DAYS − VEX_WARN_DAYS)` (default 3 → umur 25..28 hr, overlay band VALID, ⊖ pin/tinting); `_freshness_cutoffs` → 4-tuple `(new_cut, warn_cut, exp_cut, hid_cut)`; `_search_rows` tag `revote` HANYA bila `last_vote > revote_cut` (revoter lebih lama → `valid`), + field `expiring` (main list, `?q=`, `/api/search`); legenda mint `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG · N AKUN` + legenda amber `TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH`; `_stats` 9-tuple (+`n_expiring`, `n_revote` = tag-1-hari utk legenda); oracle test_dashboard (revoter1 `_iso(0)` tetap bertag; expire1 `_iso(26)` staked-1290 → valid + expiring; TOTAL 129; config defaults)
|
||||||
V33: reward = max(saldo liquid setelah − sebelum, 0) saat klaim (settle delay ≥ 10s); fee = floor(`BP_FEE_PERCENT` × reward) 4 desimal → `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD`, verifikasi txid Hyperion sebelum resend (pola V22); reward ≤ 0 → fee `skipped`; Σfee ≤ reward
|
V33: reward = max(saldo liquid setelah − sebelum, 0) saat klaim (settle delay ≥ 10s); fee = floor(`BP_FEE_PERCENT` × reward) 4 desimal → `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD`, verifikasi txid Hyperion sebelum resend (pola V22); reward ≤ 0 → fee `skipped`; Σfee ≤ reward
|
||||||
V34: siklus klaim selesai ⊥ bila klaim tercatat (`claim_runs`) DAN fee terkirim; fee `pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi
|
V34: siklus klaim selesai ⊥ bila klaim tercatat (`claim_runs`) DAN fee terkirim; fee `pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi
|
||||||
V35: kill-switch distribusi `DISTRIBUTE_ENABLED` default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; `--dry-run` tetap menampilkan rencana (read-only); nilai `true`/`1`/`yes` → normal
|
V35: kill-switch distribusi `DISTRIBUTE_ENABLED` default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; `--dry-run` tetap menampilkan rencana (read-only); nilai `true`/`1`/`yes` → normal
|
||||||
@@ -159,6 +164,10 @@ T41|x|kill-switch distribusi: `DISTRIBUTE_ENABLED` (config default false) + gate
|
|||||||
T42|x|Telegram dua kanal: rename `TELEGRAM_CHAT_IDS` → `TELEGRAM_COMMUNITY_CHAT_IDS` + tambah `TELEGRAM_INTERNAL_CHAT_IDS` (config), `telegram.py` `send_text(chat_ids=None)`=komunitas + `send_internal`; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env
|
T42|x|Telegram dua kanal: rename `TELEGRAM_CHAT_IDS` → `TELEGRAM_COMMUNITY_CHAT_IDS` + tambah `TELEGRAM_INTERNAL_CHAT_IDS` (config), `telegram.py` `send_text(chat_ids=None)`=komunitas + `send_internal`; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env
|
||||||
T43|x|jadwal mingguan: `distribute_loop.py` `next_boundary` = {one-off `DISTRIBUTE_FIRST_RUN` @ jam (jika depan)} ∪ {mingguan `DISTRIBUTE_WEEKDAY` @ jam ≥ tanggal launch; Sabtu sebelum launch dilewati} + parser hari/nama + config.py (`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`) + env compose + .env.example + oracle `test_distribute_loop.py`|V37,I.env
|
T43|x|jadwal mingguan: `distribute_loop.py` `next_boundary` = {one-off `DISTRIBUTE_FIRST_RUN` @ jam (jika depan)} ∪ {mingguan `DISTRIBUTE_WEEKDAY` @ jam ≥ tanggal launch; Sabtu sebelum launch dilewati} + parser hari/nama + config.py (`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`) + env compose + .env.example + oracle `test_distribute_loop.py`|V37,I.env
|
||||||
T44|x|dashboard banner BERITA jadwal distribusi berikutnya: `dashboard.py` mirror `_next_boundary` (⊥ impor lintas-image, V31) + `_next_schedule`/`_fmt_schedule` (id-ID) + `_recurrence_label` (baris berulang hanya utk mingguan, ⊥ saat first-run, `_is_first_run_next`); section `.news` di `index.html` + CSS; env compose web += `TZ`/`DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`; oracle V38 (banner + drift-guard vs distribute_loop)|V38,V37,I.env
|
T44|x|dashboard banner BERITA jadwal distribusi berikutnya: `dashboard.py` mirror `_next_boundary` (⊥ impor lintas-image, V31) + `_next_schedule`/`_fmt_schedule` (id-ID) + `_recurrence_label` (baris berulang hanya utk mingguan, ⊥ saat first-run, `_is_first_run_next`); section `.news` di `index.html` + CSS; env compose web += `TZ`/`DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN`; oracle V38 (banner + drift-guard vs distribute_loop)|V38,V37,I.env
|
||||||
|
T45|x|band umur vote V40: scan simpan semua umur (hapus `_is_stale`), `config` += `VEX_MATURITY_DAYS`/`VEX_EXPIRED_DAYS`, dashboard = BARU (bagian sendiri) + daftar utama (kadaluarsa menyatu di atas, sorot amber+badge+legenda, lalu VALID pager) + search bertag + stats/count/rank scoped, distribusi filter matang+segar (`_eligible_cutoffs`), env/.env.example + docs, oracle test_get_voters/test_dashboard/test_distribute|V40,V13,V11,V15,I.env
|
||||||
|
T46|x|BARU vs REVOTE (V41): `db.py` += tabel `voter_first_seen` + `ensure_first_seen_schema`/`record_first_seen` (INSERT OR IGNORE/IGNORE, PK owner, append-only); scan `persist` mencatat first_seen; dashboard `_edge_rows`/`_stats`/`_search_rows` klasifikasi `first_seen > new_cut` → BARU, ≤ → REVOTE (group enum += `revote`), bagian REVOTE sendiri di bawah BARU + tag CSS `--mint`; docs + oracle test_get_voters/test_dashboard/test_mariadb|V41,V7,V15,V40
|
||||||
|
T47|x|REVOTE = VALID langsung (V42): `distribute.py` `run_distribution` `ensure_first_seen_schema` + query LEFT JOIN `voter_first_seen` → `last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE)`; dashboard `_edge_rows` → `(new_rows, expired_rows)` (REVOTE ⊖ BARU, masuk daftar utama), `_db_rows`/`_count` jendela sama + flag revote (elemen ke-8) + tag mint `REVOTE` di baris valid, `_stats` partisi n_new+n_revote+n_expired+n_valid == total, `_search_rows` group `revote` di jendela BARU & VALID; template ⊖ bagian REVOTE + legenda mint (`?v=13`); docs + oracle test_distribute/test_dashboard|V42,V40,V41,V15,I.db,I.web
|
||||||
|
T48|x|tag/badge display V43: `config` += `VEX_REVOTE_TAG_DAYS` (1) + `VEX_WARN_DAYS` (3); `_freshness_cutoffs` → 4-tuple `(new_cut, warn_cut, exp_cut, hid_cut)` + `_revote_cut`; `_db_rows` flag revote = `last_vote > revote_cut AND fs ≤ new_cut` + flag expiring (elemen ke-9) `exp_cut < last_vote ≤ warn_cut`; `_stats` 9-tuple (+`n_expiring`, `n_revote` = tag-1-hari); `_search_rows` tag `revote` HANYA bila `last_vote > revote_cut` + field `expiring`; template badge `VOTE ULANG SEGERA` (main + `?q=`) + legenda mint/amber baru (`?v=14`), `app.js` warn-badge, CSS `.warn-badge`; env/.env.example + docs (V15/V42 amend, V43, T48); oracle test_dashboard|V43,V42,V40,V15,I.env,I.web
|
||||||
|
|
||||||
## §B — Bug log
|
## §B — Bug log
|
||||||
id|date|cause|fix
|
id|date|cause|fix
|
||||||
@@ -168,3 +177,5 @@ B3|2026-08-05|PyMySQL `fetchall()` → tuple, SQLite → list → asersi `== []`
|
|||||||
B4|2026-08-06|image dist ⊥ salin `scan_loop.py`, `distribute_loop` impor darinya → ModuleNotFoundError saat smoke container|V31
|
B4|2026-08-06|image dist ⊥ salin `scan_loop.py`, `distribute_loop` impor darinya → ModuleNotFoundError saat smoke container|V31
|
||||||
B5|2026-08-06|`DISTRIBUTE_ENABLED` pakai `not in ('1','true','yes')` → default jadi ON (kebalikan V35, kill-switch ⊥ berfungsi saat var ⊥ diset)|flip ke `in (...)` — default off (V35)
|
B5|2026-08-06|`DISTRIBUTE_ENABLED` pakai `not in ('1','true','yes')` → default jadi ON (kebalikan V35, kill-switch ⊥ berfungsi saat var ⊥ diset)|flip ke `in (...)` — default off (V35)
|
||||||
B6|2026-08-06|mirror `_parse_weekday` di dashboard memakai kunci nama hari id-ID (`sabtu`) tapi config default `sat` (singkatan EN) → ValueError 'sat'; drift-guard oracle tangkap|kunci parser = singkatan EN `mon..sun` (sama dgn distribute_loop.WEEKDAYS), V38 + drift-guard
|
B6|2026-08-06|mirror `_parse_weekday` di dashboard memakai kunci nama hari id-ID (`sabtu`) tapi config default `sat` (singkatan EN) → ValueError 'sat'; drift-guard oracle tangkap|kunci parser = singkatan EN `mon..sun` (sama dgn distribute_loop.WEEKDAYS), V38 + drift-guard
|
||||||
|
B7|2026-08-06|claim service di swarm mati total — log `[Error] Siklus klaim gagal: 0` tiap siklus: `fetch_producer_last_claim` parse respons `get_table_rows` sbg list (`data[0]`) padahal node balikin dict `{rows:[...]}` → `KeyError: 0` (str `0`); oracle `test_claim` mock `fetch_producer_last_claim` langsung → parse tak pernah diuji|parse `data.get('rows')` bentuk asli, bentuk tak dikenal → None (⊥ crash); oracle mock `_post_json` dgn bentuk respons asli (V39)
|
||||||
|
B8|2026-08-06|setelah jendela klaim lewat (poll 2 jam habis tanpa sukses) `next_window` tetap balikin `last+24h` masa lalu → `poll_claim` spin-timeout selamanya, klaim tak pernah bisa catch-up (deadlock) walau ≥24 jam sudah lewat|`next_window` = `max(last+24h, now)` — ≥24 jam → jendela = now, klaim dikejar segera (V32)
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
Setiap siklus (V32..V34): tentukan jendela klaim = `last_claim_time` (tabel
|
Setiap siklus (V32..V34): tentukan jendela klaim = `last_claim_time` (tabel
|
||||||
`producers`) + 24 jam, tidur sampai mendekatinya, lalu poll `claimrewards`
|
`producers`) + 24 jam, tidur sampai mendekatinya, lalu poll `claimrewards`
|
||||||
tiap `CLAIM_RETRY_SECONDS` sampai diterima chain. Reward diukur = selisih
|
tiap `CLAIM_RETRY_SECONDS` sampai diterima chain. Jendela yang sudah lewat
|
||||||
saldo liquid (setelah − sebelum klaim, settle delay); fee = floor(
|
(≥24 jam sejak last) → klaim dikejar segera (recovery, ⊥ spin-timeout
|
||||||
`VEX_BP_FEE_PERCENT` × reward) 4 desimal → `VEX_BP_FEE_WALLET` (default
|
selamanya). Reward diukur = selisih saldo liquid (setelah − sebelum klaim,
|
||||||
`bpdbsjasprod`), memo `BP FEE YYYY-MM-DD` (V33).
|
settle delay); fee = floor(`VEX_BP_FEE_PERCENT` × reward) 4 desimal →
|
||||||
|
`VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD` (V33).
|
||||||
|
|
||||||
Siklus selesai ⊥ bila klaim tercatat di `claim_runs` DAN fee terkirim; fee
|
Siklus selesai ⊥ bila klaim tercatat di `claim_runs` DAN fee terkirim; fee
|
||||||
`pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (crash-safe,
|
`pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (crash-safe,
|
||||||
@@ -46,12 +47,15 @@ def _post_json(url, payload, max_retries=3, timeout=30):
|
|||||||
|
|
||||||
|
|
||||||
def fetch_producer_last_claim():
|
def fetch_producer_last_claim():
|
||||||
"""`last_claim_time` baris producers utk TARGET_BP dari chain (| None)."""
|
"""`last_claim_time` baris producers utk TARGET_BP dari chain (| None).
|
||||||
|
|
||||||
|
`get_table_rows` mengembalikan dict `{"rows":[...]}` (bukan list) — parse
|
||||||
|
bentuk asli; bentuk tak dikenal → None (⊥ crash → siklus tak mati)."""
|
||||||
data = _post_json(f'{API_NODE}/v1/chain/get_table_rows',
|
data = _post_json(f'{API_NODE}/v1/chain/get_table_rows',
|
||||||
{'json': True, 'code': 'vexcore', 'scope': 'vexcore',
|
{'json': True, 'code': 'vexcore', 'scope': 'vexcore',
|
||||||
'table': 'producers', 'limit': 1,
|
'table': 'producers', 'limit': 1,
|
||||||
'lower_bound': TARGET_BP, 'upper_bound': TARGET_BP})
|
'lower_bound': TARGET_BP, 'upper_bound': TARGET_BP})
|
||||||
rows = data or []
|
rows = (data or {}).get('rows') or []
|
||||||
return (rows[0] or {}).get('last_claim_time') if rows else None
|
return (rows[0] or {}).get('last_claim_time') if rows else None
|
||||||
|
|
||||||
|
|
||||||
@@ -68,9 +72,17 @@ def parse_claim_time(raw):
|
|||||||
|
|
||||||
|
|
||||||
def next_window(now):
|
def next_window(now):
|
||||||
"""Jendela klaim berikutnya = `last_claim_time` + 24 jam; ⊥ last → now."""
|
"""Jendela klaim berikutnya = `last_claim_time` + 24 jam; ⊥ last → now.
|
||||||
|
|
||||||
|
Recovery: bila ≥24 jam sudah lewat sejak klaim terakhir, jendela = `now`
|
||||||
|
(klaim dibolehkan) — jendela yang terlewat (container down / poll habis)
|
||||||
|
⊥ jadi spin-timeout selamanya; klaim dikejar segera, lalu jendela dihitung
|
||||||
|
ulang dari last_claim_time yang baru."""
|
||||||
last = parse_claim_time(fetch_producer_last_claim())
|
last = parse_claim_time(fetch_producer_last_claim())
|
||||||
return (last + timedelta(hours=24)) if last is not None else now
|
if last is None:
|
||||||
|
return now
|
||||||
|
window = last + timedelta(hours=24)
|
||||||
|
return window if now < window else now
|
||||||
|
|
||||||
|
|
||||||
def build_claim_action():
|
def build_claim_action():
|
||||||
|
|||||||
@@ -43,9 +43,29 @@ DASH_PORT = int(os.getenv('DASH_PORT', '5000'))
|
|||||||
# Dashboard: jumlah worker gunicorn (produksi)
|
# Dashboard: jumlah worker gunicorn (produksi)
|
||||||
DASH_WORKERS = int(os.getenv('DASH_WORKERS', '2'))
|
DASH_WORKERS = int(os.getenv('DASH_WORKERS', '2'))
|
||||||
|
|
||||||
# Ambang batas vote "basi" (hari tanpa revote) — pemilih lewat batas ini dibuang di scan
|
# Ambang batas vote "basi" (hari tanpa revote). Sejak V40 pemilih lewat batas
|
||||||
|
# ini TETAP disimpan di scan (basi dibuang hanya saat tampil/bayar): band
|
||||||
|
# kadaluarsa 28..31 hari tetap tampil (sorot VOTE ULANG), >31 hari disembunyikan.
|
||||||
VEX_STALE_DAYS = int(os.getenv('VEX_STALE_DAYS', '28'))
|
VEX_STALE_DAYS = int(os.getenv('VEX_STALE_DAYS', '28'))
|
||||||
|
|
||||||
|
# Masa matang (hari): pemilih baru (revote < VEX_MATURITY_DAYS lalu) BELUM
|
||||||
|
# berhak reward (⊥ dibayar distribusi) hingga votenya berumur ≥ ambang ini.
|
||||||
|
VEX_MATURITY_DAYS = int(os.getenv('VEX_MATURITY_DAYS', '3'))
|
||||||
|
|
||||||
|
# Jendela "kadaluarsa" (hari SETELAH VEX_STALE_DAYS) yang masih tampil di
|
||||||
|
# dashboard (band sorot VOTE ULANG). Lewat 28+3 = 31 hari → disembunyikan.
|
||||||
|
VEX_EXPIRED_DAYS = int(os.getenv('VEX_EXPIRED_DAYS', '3'))
|
||||||
|
|
||||||
|
# Tag REVOTE (mint) bertahan (hari) sejak revote terakhir: hanya pemilih lama
|
||||||
|
# yang revote dalam `VEX_REVOTE_TAG_DAYS` terakhir diberi tag; setelah itu
|
||||||
|
# tampil sbg VALID biasa (tetap dibayar langsung — V42, ⊥ tunggu matang).
|
||||||
|
VEX_REVOTE_TAG_DAYS = int(os.getenv('VEX_REVOTE_TAG_DAYS', '1'))
|
||||||
|
|
||||||
|
# Jendela peringatan (hari SEBELUM VEX_STALE_DAYS): pemilih VALID yang
|
||||||
|
# `now − STALE < last_vote ≤ now − (STALE − WARN)` diberi badge `VOTE ULANG
|
||||||
|
# SEGERA` (segera kadaluarsa, masih dibayar).
|
||||||
|
VEX_WARN_DAYS = int(os.getenv('VEX_WARN_DAYS', '3'))
|
||||||
|
|
||||||
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard)
|
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard)
|
||||||
DATABISNIS_API = os.getenv('DATABISNIS_API', 'https://api.databisnis.id')
|
DATABISNIS_API = os.getenv('DATABISNIS_API', 'https://api.databisnis.id')
|
||||||
|
|
||||||
|
|||||||
+265
-45
@@ -7,7 +7,7 @@ Konfigurasi dari env/`.env`.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Flask, abort, jsonify, render_template, request
|
from flask import Flask, abort, jsonify, render_template, request
|
||||||
@@ -15,7 +15,8 @@ from flask import Flask, abort, jsonify, render_template, request
|
|||||||
import db
|
import db
|
||||||
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
|
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
|
||||||
DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY,
|
DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY,
|
||||||
PAGE_SIZE, TARGET_BP)
|
PAGE_SIZE, TARGET_BP, VEX_EXPIRED_DAYS, VEX_MATURITY_DAYS,
|
||||||
|
VEX_REVOTE_TAG_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS)
|
||||||
|
|
||||||
SEARCH_CAP = 500
|
SEARCH_CAP = 500
|
||||||
MAX_Q_LEN = 64
|
MAX_Q_LEN = 64
|
||||||
@@ -23,6 +24,9 @@ MAX_Q_LEN = 64
|
|||||||
# Tabel distribute_payments diperlukan utk kolom TOTAL REWARD (V29). Dipastikan
|
# Tabel distribute_payments diperlukan utk kolom TOTAL REWARD (V29). Dipastikan
|
||||||
# ada sekali per worker (idempoten) sebelum query list/search dijalankan.
|
# ada sekali per worker (idempoten) sebelum query list/search dijalankan.
|
||||||
_dist_schema_ready = False
|
_dist_schema_ready = False
|
||||||
|
# Tabel voter_first_seen (V41) dipakai utk band BARU vs REVOTE. Dipastikan ada
|
||||||
|
# (idempoten) — scan juga yang menciptakannya; dashboard siap bila belum.
|
||||||
|
_first_seen_schema_ready = False
|
||||||
|
|
||||||
|
|
||||||
def _ensure_dist_schema():
|
def _ensure_dist_schema():
|
||||||
@@ -32,6 +36,13 @@ def _ensure_dist_schema():
|
|||||||
_dist_schema_ready = True
|
_dist_schema_ready = True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_first_seen_schema():
|
||||||
|
global _first_seen_schema_ready
|
||||||
|
if not _first_seen_schema_ready:
|
||||||
|
db.ensure_first_seen_schema()
|
||||||
|
_first_seen_schema_ready = True
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -221,48 +232,255 @@ def _reward_join():
|
|||||||
' ON rw.owner = v.owner'), ['sent']
|
' ON rw.owner = v.owner'), ['sent']
|
||||||
|
|
||||||
|
|
||||||
|
def _freshness_cutoffs(now=None):
|
||||||
|
"""V40/V43: empat ambang umur vote (UTC-naive, konsisten dgn stempel
|
||||||
|
`last_vote` hasil scan). Stempel lebih besar = vote lebih baru.
|
||||||
|
|
||||||
|
new_cut = now − VEX_MATURITY_DAYS: `last_vote > new_cut` → BARU
|
||||||
|
warn_cut = now − (STALE − WARN): `warn_cut < last_vote ≤ new_cut`
|
||||||
|
→ VALID + badge `VOTE ULANG SEGERA`
|
||||||
|
expired_cut = now − VEX_STALE_DAYS: `expired_cut < last_vote ≤ new_cut`
|
||||||
|
→ VALID (mature+segar)
|
||||||
|
hidden_cut = now − (STALE+EXPIRED): `hidden_cut < last_vote ≤ expired_cut`
|
||||||
|
→ KADALUARSA; `≤ hidden_cut` → sembunyi
|
||||||
|
|
||||||
|
V43: tag REVOTE hanya selama `last_vote > now − VEX_REVOTE_TAG_DAYS`
|
||||||
|
(ambang `revote_cut`, dihitung pemanggil); badge peringatan selama
|
||||||
|
`expired_cut < last_vote ≤ warn_cut`.
|
||||||
|
"""
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
def cut(days):
|
||||||
|
return (now - timedelta(days=days)).isoformat(timespec='seconds')
|
||||||
|
|
||||||
|
return (cut(VEX_MATURITY_DAYS),
|
||||||
|
cut(VEX_STALE_DAYS - VEX_WARN_DAYS),
|
||||||
|
cut(VEX_STALE_DAYS),
|
||||||
|
cut(VEX_STALE_DAYS + VEX_EXPIRED_DAYS))
|
||||||
|
|
||||||
|
|
||||||
|
def _revote_cut(now=None):
|
||||||
|
"""V43: ambang tag REVOTE (mint) — hanya pemilih lama yang revote dalam
|
||||||
|
`now − VEX_REVOTE_TAG_DAYS` terakhir (`last_vote > revote_cut`) diberi
|
||||||
|
tag; setelah itu tampil VALID biasa (tetap dibayar langsung, V42)."""
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
return (now - timedelta(days=VEX_REVOTE_TAG_DAYS)).isoformat(timespec='seconds')
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_rows():
|
||||||
|
"""V40/V42: baris di luar band VALID — BARU (`last_vote > new` AND
|
||||||
|
pemilih BARU) dan KADALUARSA (`hidden < last_vote ≤ expired`). Jendela
|
||||||
|
kecil → selalu tampil penuh (⊥ pager). Rank = ordinal dalam band.
|
||||||
|
|
||||||
|
V42: REVOTE (pemilih lama yang revote, `first_seen ≤ now − MATURITY`)
|
||||||
|
DIANGGAP VALID langsung (⊥ masa matang) → barisnya diambil `_db_rows`
|
||||||
|
(daftar utama), ⊖ di sini. BARU = hanya pemilih baru (`first_seen` None/
|
||||||
|
DB lama atau `> new_cut`); yang sudah pernah terlihat sebelumnya ⊖ BARU.
|
||||||
|
"""
|
||||||
|
new_cut, _warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
|
||||||
|
rw_join, rw_params = _reward_join()
|
||||||
|
sql = (
|
||||||
|
'SELECT v.owner, v.staked, v.last_vote, COALESCE(rw.total, 0), '
|
||||||
|
' fs.first_seen_at '
|
||||||
|
'FROM voters v '
|
||||||
|
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
|
||||||
|
+ rw_join + ' '
|
||||||
|
'WHERE v.last_vote > %s '
|
||||||
|
'ORDER BY v.staked DESC, v.owner ASC'
|
||||||
|
)
|
||||||
|
new_rows, expired_rows = [], []
|
||||||
|
for r in db.query(sql, rw_params + [hid_cut]):
|
||||||
|
if r[2] > new_cut:
|
||||||
|
fs = r[4]
|
||||||
|
if fs is None or fs > new_cut:
|
||||||
|
new_rows.append(r[:4])
|
||||||
|
elif r[2] > exp_cut:
|
||||||
|
pass # REVOTE (first_seen ≤ new_cut) atau VALID → daftar utama
|
||||||
|
else:
|
||||||
|
expired_rows.append(r[:4])
|
||||||
|
return new_rows, expired_rows
|
||||||
|
|
||||||
|
|
||||||
def _db_rows(page, q):
|
def _db_rows(page, q):
|
||||||
"""V10/V11: satu halaman, urut staked DESC lalu owner ASC; rank global.
|
"""V10/V11/V40/V42: satu halaman band VALID (mature+segar + REVOTE
|
||||||
|
yang ⊥ tunggu matang), rank dalam band.
|
||||||
|
|
||||||
Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke
|
Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke
|
||||||
subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter.
|
subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter.
|
||||||
Correlated count selalu menghitung atas SELURUH tabel → rank tetap global.
|
Correlated count menghitung atas SELURUH tabel → rank tetap global.
|
||||||
|
V40: subquery korelasi dibatasi jendela VALID agar rank 1..N konsisten dgn
|
||||||
|
daftar yang tampil (tiap band punya penomoran sendiri).
|
||||||
|
V42: jendela VALID = `last_vote > now−STALE AND (last_vote ≤ now−MATURITY
|
||||||
|
OR REVOTE)` — REVOTE (`first_seen ≤ now−MATURITY`) masuk VALID langsung;
|
||||||
|
baris diberi flag revote (elemen ke-8) utk tag mint di template.
|
||||||
|
V43: flag revote (elemen ke-8) hanya utk revote < `VEX_REVOTE_TAG_DAYS`
|
||||||
|
(`last_vote > revote_cut`); elemen ke-9 = flag `VOTE ULANG SEGERA`
|
||||||
|
(`expired_cut < last_vote ≤ warn_cut`).
|
||||||
"""
|
"""
|
||||||
offset = (page - 1) * PAGE_SIZE
|
offset = (page - 1) * PAGE_SIZE
|
||||||
|
new_cut, warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
|
||||||
|
revote_cut = _revote_cut()
|
||||||
where, params = _filter_sql(q, 'v.')
|
where, params = _filter_sql(q, 'v.')
|
||||||
rw_join, rw_params = _reward_join()
|
rw_join, rw_params = _reward_join()
|
||||||
|
rev = ('v.last_vote <= %s OR '
|
||||||
|
'(fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s)')
|
||||||
|
band = 'v.last_vote > %s AND (' + rev + ')'
|
||||||
|
combined = ('WHERE ' + band
|
||||||
|
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
|
||||||
|
rev2 = ('v2.last_vote <= %s OR '
|
||||||
|
'(fs2.first_seen_at IS NOT NULL AND fs2.first_seen_at <= %s)')
|
||||||
sql = (
|
sql = (
|
||||||
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
|
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
|
||||||
' (SELECT COUNT(*) FROM voters v2 '
|
' (SELECT COUNT(*) FROM voters v2 '
|
||||||
' WHERE v2.staked > v.staked '
|
' LEFT JOIN voter_first_seen fs2 ON fs2.owner = v2.owner '
|
||||||
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
|
' WHERE v2.last_vote > %s AND (' + rev2 + ') '
|
||||||
|
' AND (v2.staked > v.staked '
|
||||||
|
' OR (v2.staked = v.staked AND v2.owner <= v.owner))) AS rn, '
|
||||||
' v.last_vote, '
|
' v.last_vote, '
|
||||||
' COALESCE(rw.total, 0) '
|
' COALESCE(rw.total, 0), '
|
||||||
'FROM voters v ' + rw_join + ' ' + where + ' '
|
' fs.first_seen_at '
|
||||||
|
'FROM voters v '
|
||||||
|
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
|
||||||
|
+ rw_join + ' ' + combined + ' '
|
||||||
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
|
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
|
||||||
)
|
)
|
||||||
return db.query(sql, rw_params + params + [PAGE_SIZE, offset])
|
rows = db.query(sql, [exp_cut, new_cut, new_cut] + rw_params
|
||||||
|
+ [exp_cut, new_cut, new_cut]
|
||||||
|
+ params + [PAGE_SIZE, offset])
|
||||||
|
return [
|
||||||
|
r + ((1,) if (r[7] is not None and r[7] <= new_cut
|
||||||
|
and r[5] > revote_cut) else (0,))
|
||||||
|
+ ((1,) if exp_cut < r[5] <= warn_cut else (0,))
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _count(q):
|
def _count(q):
|
||||||
"""V11: banyak baris cocok filter (atau total bila tanpa filter)."""
|
"""V11/V40/V42: banyak baris band VALID cocok filter (utk pager).
|
||||||
where, params = _filter_sql(q)
|
V43: tag REVOTE 1-hari & badge peringatan ⊥ ubah cakupan → tetap V42."""
|
||||||
|
new_cut, _warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
|
||||||
|
where, params = _filter_sql(q, 'v.')
|
||||||
|
rev = ('v.last_vote <= %s OR '
|
||||||
|
'(fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s)')
|
||||||
|
combined = ('WHERE v.last_vote > %s AND (' + rev + ')'
|
||||||
|
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
|
||||||
return db.queryone(
|
return db.queryone(
|
||||||
'SELECT COUNT(*) FROM voters ' + where, params
|
'SELECT COUNT(*) FROM voters v '
|
||||||
)[0]
|
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner ' + combined,
|
||||||
|
[exp_cut, new_cut, new_cut] + params)[0]
|
||||||
|
|
||||||
|
|
||||||
def _stats():
|
def _stats():
|
||||||
"""Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search)."""
|
"""Ringkasan agregat untuk band spec (selalu global, ⊥ dipengaruhi search).
|
||||||
|
|
||||||
|
V40/V42: cakupan = semua baris TAMPIL (BARU + REVOTE + VALID + KADALUARSA,
|
||||||
|
⊖ yang tersembunyi > 31 hari). Juga menghitung jumlah per band utk caption
|
||||||
|
bagian. V42: REVOTE (`first_seen ≤ now − MATURITY`) dihitung sbg pemilih
|
||||||
|
valid (⊥ masa matang) selama `last_vote > exp_cut`; VALID hanya pemilih
|
||||||
|
baru yang matang. Invariant: n_new + n_revote_window + n_expired + n_valid
|
||||||
|
== total_voters (partisi semua baris tampil), dgn `n_revote_window` =
|
||||||
|
REVOTE di jendela VALID.
|
||||||
|
V43: `n_revote` (legenda mint) = HANYA REVOTE < `VEX_REVOTE_TAG_DAYS`
|
||||||
|
(subset dari `n_revote_window` — berubah harian, ⊥ partisi); `n_expiring`
|
||||||
|
= VALID dalam `VEX_WARN_DAYS` terakhir sebelum kadaluarsa (`exp_cut <
|
||||||
|
last_vote ≤ warn_cut`, overlays band VALID) utk badge `VOTE ULANG SEGERA`.
|
||||||
|
"""
|
||||||
|
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
|
||||||
|
revote_cut = _revote_cut()
|
||||||
row = db.queryone(
|
row = db.queryone(
|
||||||
'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) '
|
'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) '
|
||||||
'FROM voters'
|
'FROM voters WHERE last_vote > %s', [hid_cut])
|
||||||
)
|
|
||||||
total_voters, total_staked, top_staked = row
|
total_voters, total_staked, top_staked = row
|
||||||
|
counts = db.queryone(
|
||||||
|
'SELECT '
|
||||||
|
' COALESCE(SUM(CASE WHEN v.last_vote > %s '
|
||||||
|
' AND (fs.first_seen_at IS NULL OR fs.first_seen_at > %s) '
|
||||||
|
' THEN 1 ELSE 0 END), 0), '
|
||||||
|
' COALESCE(SUM(CASE WHEN fs.first_seen_at IS NOT NULL '
|
||||||
|
' AND fs.first_seen_at <= %s AND v.last_vote > %s '
|
||||||
|
' THEN 1 ELSE 0 END), 0), '
|
||||||
|
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
|
||||||
|
' THEN 1 ELSE 0 END), 0), '
|
||||||
|
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
|
||||||
|
' AND (fs.first_seen_at IS NULL OR fs.first_seen_at > %s) '
|
||||||
|
' THEN 1 ELSE 0 END), 0), '
|
||||||
|
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
|
||||||
|
' THEN 1 ELSE 0 END), 0) '
|
||||||
|
'FROM voters v '
|
||||||
|
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
|
||||||
|
'WHERE v.last_vote > %s',
|
||||||
|
[new_cut, new_cut, new_cut, revote_cut,
|
||||||
|
hid_cut, exp_cut,
|
||||||
|
exp_cut, new_cut, new_cut,
|
||||||
|
exp_cut, warn_cut,
|
||||||
|
hid_cut],
|
||||||
|
)
|
||||||
|
n_new, n_revote, n_expired, n_valid, n_expiring = counts
|
||||||
scanned = db.queryone(
|
scanned = db.queryone(
|
||||||
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters'
|
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters')[0]
|
||||||
)[0]
|
return (total_voters, total_staked, top_staked, scanned,
|
||||||
return total_voters, total_staked, top_staked, scanned
|
n_new, n_revote, n_expired, n_valid, n_expiring)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_rows(q, cap):
|
||||||
|
"""V11/V40/V42: pencarian owner (substring) di SEMUA band tampil.
|
||||||
|
|
||||||
|
Kembalikan daftar dict bertag `group` ('baru'|'revote'|'valid'|
|
||||||
|
'kadaluarsa') + `expired` + rank ordinal dalam kelompok. Dipakai
|
||||||
|
`api_search` (JSON) dan mode `?q=` tanpa-JS agar hasilnya konsisten.
|
||||||
|
V42: REVOTE (`first_seen ≤ now − MATURITY`) adalah anggota band VALID
|
||||||
|
(⊥ masa matang). V43: tag `revote` HANYA utk revote < `VEX_REVOTE_TAG_DAYS`
|
||||||
|
(`last_vote > revote_cut`); revote lebih lama → `valid` polos. Baris juga
|
||||||
|
diberi `expiring` (`VOTE ULANG SEGERA`) saat `exp_cut < last_vote ≤
|
||||||
|
warn_cut`.
|
||||||
|
"""
|
||||||
|
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
|
||||||
|
revote_cut = _revote_cut()
|
||||||
|
where, params = _filter_sql(q, 'v.')
|
||||||
|
rw_join, rw_params = _reward_join()
|
||||||
|
# V40: pencarian menjangkau SEMUA baris tampil (BARU + VALID + KADALUARSA)
|
||||||
|
# = seluruh band di atas ambang tersembunyi `last_vote > hid_cut`.
|
||||||
|
window = 'v.last_vote > %s'
|
||||||
|
if where:
|
||||||
|
combined = where + ' AND ' + window
|
||||||
|
params = params + [hid_cut]
|
||||||
|
else:
|
||||||
|
combined = 'WHERE ' + window
|
||||||
|
params = [hid_cut]
|
||||||
|
sql = (
|
||||||
|
'SELECT v.owner, v.staked, v.weight, v.last_vote, '
|
||||||
|
' COALESCE(rw.total, 0), fs.first_seen_at '
|
||||||
|
'FROM voters v '
|
||||||
|
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
|
||||||
|
+ rw_join + ' ' + combined + ' '
|
||||||
|
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s'
|
||||||
|
)
|
||||||
|
rows = db.query(sql, rw_params + params + [cap])
|
||||||
|
|
||||||
|
buckets = {'baru': 0, 'revote': 0, 'valid': 0, 'kadaluarsa': 0}
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
fs = r[5]
|
||||||
|
is_revoter = fs is not None and fs <= new_cut
|
||||||
|
tag_revote = is_revoter and r[3] > revote_cut
|
||||||
|
expiring = exp_cut < r[3] <= warn_cut
|
||||||
|
if r[3] > new_cut:
|
||||||
|
group = ('revote' if tag_revote
|
||||||
|
else ('valid' if is_revoter else 'baru'))
|
||||||
|
elif r[3] > exp_cut:
|
||||||
|
group = 'revote' if tag_revote else 'valid'
|
||||||
|
else:
|
||||||
|
group = 'kadaluarsa'
|
||||||
|
buckets[group] += 1
|
||||||
|
results.append({
|
||||||
|
'owner': r[0], 'staked': r[1], 'weight': r[2],
|
||||||
|
'rank': buckets[group], 'last_vote': r[3], 'total_reward': r[4],
|
||||||
|
'group': group, 'expired': group == 'kadaluarsa',
|
||||||
|
'expiring': expiring,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@app.get('/')
|
@app.get('/')
|
||||||
@@ -273,18 +491,33 @@ def index():
|
|||||||
abort(400)
|
abort(400)
|
||||||
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
||||||
_ensure_dist_schema()
|
_ensure_dist_schema()
|
||||||
total_voters, total_staked, top_staked, scanned = _stats()
|
_ensure_first_seen_schema()
|
||||||
|
(total_voters, total_staked, top_staked, scanned,
|
||||||
|
n_new, n_revote, n_expired, n_valid, n_expiring) = _stats()
|
||||||
liquid_vex = _get_liquid_vex()
|
liquid_vex = _get_liquid_vex()
|
||||||
|
|
||||||
|
if q:
|
||||||
|
# Mode pencarian (⊥ JS): satu daftar datar ber-tag, ⊥ bagian/pager.
|
||||||
|
search_rows = _search_rows(q, SEARCH_CAP)
|
||||||
|
filtered_count = len(search_rows)
|
||||||
|
rows = new_rows = expired_rows = ()
|
||||||
|
page = total_pages = 1
|
||||||
|
else:
|
||||||
|
search_rows = None
|
||||||
filtered_count = _count(q)
|
filtered_count = _count(q)
|
||||||
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
total_pages = max(1, (filtered_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
page = min(page, total_pages)
|
page = min(page, total_pages)
|
||||||
rows = _db_rows(page, q)
|
rows = _db_rows(page, q)
|
||||||
|
new_rows, expired_rows = _edge_rows()
|
||||||
|
|
||||||
_now = datetime.now()
|
_now = datetime.now()
|
||||||
return render_template(
|
return render_template(
|
||||||
'index.html',
|
'index.html',
|
||||||
bp=TARGET_BP,
|
bp=TARGET_BP,
|
||||||
rows=rows,
|
rows=rows,
|
||||||
|
new_rows=new_rows,
|
||||||
|
expired_rows=expired_rows,
|
||||||
|
search_rows=search_rows,
|
||||||
page=page,
|
page=page,
|
||||||
total_pages=total_pages,
|
total_pages=total_pages,
|
||||||
total_voters=total_voters,
|
total_voters=total_voters,
|
||||||
@@ -294,6 +527,13 @@ def index():
|
|||||||
scanned=scanned,
|
scanned=scanned,
|
||||||
q=q,
|
q=q,
|
||||||
filtered_count=filtered_count,
|
filtered_count=filtered_count,
|
||||||
|
new_count=n_new,
|
||||||
|
revote_count=n_revote,
|
||||||
|
expired_count=n_expired,
|
||||||
|
valid_count=n_valid,
|
||||||
|
expiring_count=n_expiring,
|
||||||
|
stale_days=VEX_STALE_DAYS,
|
||||||
|
warn_days=VEX_WARN_DAYS,
|
||||||
next_schedule=_fmt_schedule(_next_schedule(_now)),
|
next_schedule=_fmt_schedule(_next_schedule(_now)),
|
||||||
schedule_recur=_recurrence_label(_now),
|
schedule_recur=_recurrence_label(_now),
|
||||||
)
|
)
|
||||||
@@ -301,36 +541,16 @@ def index():
|
|||||||
|
|
||||||
@app.get('/api/search')
|
@app.get('/api/search')
|
||||||
def api_search():
|
def api_search():
|
||||||
"""V11: JSON hasil pencarian owner (substring), urut staked DESC, rank global.
|
"""V11/V40: JSON hasil pencarian owner (substring) di SEMUA band tampil."""
|
||||||
|
|
||||||
Menyertakan `total_reward` (V29) agar hasil search sama kolomnya dgn list.
|
|
||||||
"""
|
|
||||||
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
||||||
_ensure_dist_schema()
|
_ensure_dist_schema()
|
||||||
where, params = _filter_sql(q, 'v.')
|
_ensure_first_seen_schema()
|
||||||
rw_join, rw_params = _reward_join()
|
results = _search_rows(q, SEARCH_CAP)
|
||||||
sql = (
|
|
||||||
'SELECT v.owner, v.staked, v.weight, '
|
|
||||||
' (SELECT COUNT(*) FROM voters v2 '
|
|
||||||
' WHERE v2.staked > v.staked '
|
|
||||||
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
|
|
||||||
' v.last_vote, '
|
|
||||||
' COALESCE(rw.total, 0) '
|
|
||||||
'FROM voters v ' + rw_join + ' ' + where + ' '
|
|
||||||
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s'
|
|
||||||
)
|
|
||||||
rows = db.query(sql, rw_params + params + [SEARCH_CAP])
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'query': q,
|
'query': q,
|
||||||
'count': len(rows),
|
'count': len(results),
|
||||||
'cap': SEARCH_CAP,
|
'cap': SEARCH_CAP,
|
||||||
'results': [
|
'results': results,
|
||||||
{
|
|
||||||
'owner': r[0], 'staked': r[1], 'weight': r[2],
|
|
||||||
'rank': r[3], 'last_vote': r[4], 'total_reward': r[5],
|
|
||||||
}
|
|
||||||
for r in rows
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -337,6 +337,57 @@ def update_claim_fee(claim_id, fee_status, fee_txid=None):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ——————————————————————————————————————————————————————————————————————
|
||||||
|
# Kemunculan pertama pemilih (V41). Tabel append kecil — satu baris per owner
|
||||||
|
# (PK), mengingat KAPAN pertama kali muncul di snapshot. `voters` tetap
|
||||||
|
# snapshot murni (diganti tiap skan); first_seen bertahan di sini. Dipakai
|
||||||
|
# dashboard utk membedakan PEMILIH BARU (belum pernah terlihat) vs REVOTE
|
||||||
|
# (pemilih yang sudah pernah ada, revote/perpanjang).
|
||||||
|
# ——————————————————————————————————————————————————————————————————————
|
||||||
|
|
||||||
|
_FIRST_SEEN_COLS = 'owner VARCHAR(13) PRIMARY KEY, first_seen_at TEXT NOT NULL'
|
||||||
|
_FIRST_SEEN_DDL_SQLITE = (f'CREATE TABLE IF NOT EXISTS voter_first_seen '
|
||||||
|
f'({_FIRST_SEEN_COLS})')
|
||||||
|
_FIRST_SEEN_DDL_MYSQL = (f'CREATE TABLE IF NOT EXISTS voter_first_seen '
|
||||||
|
f'({_FIRST_SEEN_COLS})')
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_first_seen_schema():
|
||||||
|
"""Buat tabel kemunculan pertama bila belum ada (idempoten, kedua backend)."""
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
if DB_BACKEND == 'sqlite':
|
||||||
|
cur.execute(_FIRST_SEEN_DDL_SQLITE)
|
||||||
|
else:
|
||||||
|
cur.execute(_FIRST_SEEN_DDL_MYSQL)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def record_first_seen(rows, scanned_at):
|
||||||
|
"""Catat kemunculan pertama tiap owner (idempoten, ⊥ overwrite).
|
||||||
|
|
||||||
|
INSERT OR IGNORE (sqlite) / INSERT IGNORE (mysql): hanya baris yang belum
|
||||||
|
ada yang masuk — `first_seen_at` selalu tanggal kemunculan pertama.
|
||||||
|
"""
|
||||||
|
ensure_first_seen_schema()
|
||||||
|
conn = connect()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
if DB_BACKEND == 'sqlite':
|
||||||
|
sql = ('INSERT OR IGNORE INTO voter_first_seen '
|
||||||
|
'(owner, first_seen_at) VALUES (?, ?)')
|
||||||
|
else:
|
||||||
|
sql = ('INSERT IGNORE INTO voter_first_seen '
|
||||||
|
'(owner, first_seen_at) VALUES (%s, %s)')
|
||||||
|
cur.executemany(sql, [(r['owner'], scanned_at) for r in rows])
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def pending_claim_fee():
|
def pending_claim_fee():
|
||||||
"""Klaim terbaru dengan fee belum terkirim (fee_amount > 0) → baris | None.
|
"""Klaim terbaru dengan fee belum terkirim (fee_amount > 0) → baris | None.
|
||||||
|
|
||||||
|
|||||||
+27
-4
@@ -13,7 +13,7 @@ terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
|
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -22,11 +22,25 @@ import db
|
|||||||
import telegram
|
import telegram
|
||||||
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API,
|
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API,
|
||||||
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP,
|
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP,
|
||||||
TOKEN_CONTRACT, VEX_SYMBOL)
|
TOKEN_CONTRACT, VEX_MATURITY_DAYS, VEX_STALE_DAYS,
|
||||||
|
VEX_SYMBOL)
|
||||||
|
|
||||||
VEX_PREC = Decimal('0.0001')
|
VEX_PREC = Decimal('0.0001')
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_cutoffs():
|
||||||
|
"""V40/V42: jendela reward = vote matang s/d segar: `now − STALE < last_vote ≤
|
||||||
|
now − MATURITY` (UTC-naive, konsisten dgn stempel `last_vote` dari scan).
|
||||||
|
Pemilih baru (< VEX_MATURITY_DAYS) & basi (> VEX_STALE_DAYS) ⊥ dibayar.
|
||||||
|
V42: pemilih yang SUDAH PERNAH terlihat (REVOTE, `first_seen ≤ now −
|
||||||
|
MATURITY`) dianggap VALID langsung — ⊥ tunggu masa matang; hanya pemilih
|
||||||
|
baru (first_seen tak ada / < MATURITY lalu) yang harus matang dulu."""
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
stale_cutoff = (now - timedelta(days=VEX_STALE_DAYS)).isoformat(timespec='seconds')
|
||||||
|
mature_cutoff = (now - timedelta(days=VEX_MATURITY_DAYS)).isoformat(timespec='seconds')
|
||||||
|
return stale_cutoff, mature_cutoff
|
||||||
|
|
||||||
|
|
||||||
def _post_json(url, payload, max_retries=3, timeout=30):
|
def _post_json(url, payload, max_retries=3, timeout=30):
|
||||||
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
|
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
@@ -129,9 +143,18 @@ def run_distribution(dry_run=False):
|
|||||||
print('Distribusi dinonaktifkan (DISTRIBUTE_ENABLED=false) — no-op.',
|
print('Distribusi dinonaktifkan (DISTRIBUTE_ENABLED=false) — no-op.',
|
||||||
flush=True)
|
flush=True)
|
||||||
return 0
|
return 0
|
||||||
voters = db.query('SELECT owner, staked FROM voters')
|
db.ensure_first_seen_schema()
|
||||||
|
stale_cutoff, mature_cutoff = _eligible_cutoffs()
|
||||||
|
voters = db.query(
|
||||||
|
'SELECT v.owner, v.staked FROM voters v '
|
||||||
|
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
|
||||||
|
'WHERE v.last_vote > %s '
|
||||||
|
' AND (v.last_vote <= %s '
|
||||||
|
' OR (fs.first_seen_at IS NOT NULL '
|
||||||
|
' AND fs.first_seen_at <= %s))',
|
||||||
|
[stale_cutoff, mature_cutoff, mature_cutoff])
|
||||||
if not voters:
|
if not voters:
|
||||||
print('Tidak ada pemilih segar tersimpan — no-op.', flush=True)
|
print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True)
|
||||||
return 0
|
return 0
|
||||||
balance = fetch_balance()
|
balance = fetch_balance()
|
||||||
if balance is None or balance < VEX_PREC:
|
if balance is None or balance < VEX_PREC:
|
||||||
|
|||||||
+16
-16
@@ -1,7 +1,10 @@
|
|||||||
"""Scan tabel voters Vexanium, simpan pemilih BP databisnisid + bobot suara ke SQLite.
|
"""Scan tabel voters Vexanium, simpan pemilih BP databisnisid + bobot suara ke SQLite.
|
||||||
|
|
||||||
Hanya pemilih segar (revote ≤ VEX_STALE_DAYS lalu) yang disimpan — vote basi
|
Sejak V40 SEMUA pemilih BP dengan last_vote terverifikasi disimpan — termasuk
|
||||||
dibuang. Berjalan imparsial (repeatable) dan siap dijadwalkan (cron).
|
yang basi (> VEX_STALE_DAYS). Banding/kadaluarsa bukan urusan scan: tampilan
|
||||||
|
(dashboard) dan pembayaran (distribute) menghitung umur vote saat itu juga.
|
||||||
|
Hanya pemilih dengan last_vote tak terverifikasi (bobot nol/kosong) yang dibuang.
|
||||||
|
Berjalan imparsial (repeatable) dan siap dijadwalkan (cron).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -12,7 +15,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
import db
|
import db
|
||||||
from config import API_NODE, MIN_STAKED_VEX, TARGET_BP, VEX_STALE_DAYS
|
from config import API_NODE, MIN_STAKED_VEX, TARGET_BP
|
||||||
|
|
||||||
# Kontrak & scope sistem Vexanium = vexcore (BUKAN vexio sesuai konvensi EOS)
|
# Kontrak & scope sistem Vexanium = vexcore (BUKAN vexio sesuai konvensi EOS)
|
||||||
CODE = 'vexcore'
|
CODE = 'vexcore'
|
||||||
@@ -105,21 +108,15 @@ def scan_voters():
|
|||||||
return rows_all
|
return rows_all
|
||||||
|
|
||||||
|
|
||||||
def _is_stale(last_vote, now):
|
|
||||||
"""V13: apakah revote terakhir sudah lewat ambang basi (VEX_STALE_DAYS)."""
|
|
||||||
try:
|
|
||||||
vote_at = datetime.fromisoformat(last_vote)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return True
|
|
||||||
return (now - vote_at) > timedelta(days=VEX_STALE_DAYS)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize(row, now=None):
|
def normalize(row, now=None):
|
||||||
"""V4/V5/V6/V13: saring ke baris yang relevan, bidang dinormalisasi.
|
"""V4/V5/V6/V13: saring ke baris yang relevan, bidang dinormalisasi.
|
||||||
|
|
||||||
V13: pemilih dengan last_vote tak diketahui atau basi dibuang di sini —
|
V40: pemilih dengan last_vote basi TIDAK lagi dibuang di sini — semua umur
|
||||||
hanya pemilih segar yang berakhir di DB. Estimasi last_vote yang melewati
|
vote disimpan (tampil/bayar menghitung umur saat query). Yang tetap dibuang:
|
||||||
`now` (unstake tanpa revote → rasio bobot/stake membesar) diklamp ke `now`.
|
sentinel "0", bukan BP target, stake < minimum, dan last_vote yang tak bisa
|
||||||
|
diverifikasi (bobot nol/kosong → tanggal tak diketahui, tak bisa diklasifikasi).
|
||||||
|
Estimasi last_vote yang melewati `now` (unstake tanpa revote → rasio
|
||||||
|
bobot/stake membesar) diklamp ke `now`.
|
||||||
"""
|
"""
|
||||||
if now is None:
|
if now is None:
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
@@ -134,7 +131,7 @@ def normalize(row, now=None):
|
|||||||
last_vote = derive_last_vote(row.get('last_vote_weight', ''), staked_vex)
|
last_vote = derive_last_vote(row.get('last_vote_weight', ''), staked_vex)
|
||||||
if last_vote is not None and last_vote > now.isoformat(timespec='seconds'):
|
if last_vote is not None and last_vote > now.isoformat(timespec='seconds'):
|
||||||
last_vote = now.isoformat(timespec='seconds') # V13: heuristik ⊥ boleh di masa depan
|
last_vote = now.isoformat(timespec='seconds') # V13: heuristik ⊥ boleh di masa depan
|
||||||
if last_vote is None or _is_stale(last_vote, now): # V13: basi ⊥ disimpan
|
if last_vote is None: # V13: tanggal tak diketahui → tak dapat diklasifikasi
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
'owner': owner,
|
'owner': owner,
|
||||||
@@ -150,9 +147,12 @@ def persist(rows):
|
|||||||
Delegasi ke `db.replace_snapshot` (backend sqlite/mysql):
|
Delegasi ke `db.replace_snapshot` (backend sqlite/mysql):
|
||||||
sqlite DROP+CREATE dalam WAL → pembaca (dashboard multi-worker) tak
|
sqlite DROP+CREATE dalam WAL → pembaca (dashboard multi-worker) tak
|
||||||
terblokir; mysql DELETE+INSERT dalam satu transaksi (MVCC snapshot).
|
terblokir; mysql DELETE+INSERT dalam satu transaksi (MVCC snapshot).
|
||||||
|
Lalu `db.record_first_seen` (V41): catat kemunculan pertama tiap owner
|
||||||
|
(idempoten) utk membedakan pemilih BARU vs REVOTE di dashboard.
|
||||||
"""
|
"""
|
||||||
scanned_at = datetime.now().isoformat(timespec='seconds')
|
scanned_at = datetime.now().isoformat(timespec='seconds')
|
||||||
db.replace_snapshot(rows, scanned_at)
|
db.replace_snapshot(rows, scanned_at)
|
||||||
|
db.record_first_seen(rows, scanned_at)
|
||||||
return scanned_at
|
return scanned_at
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+35
-2
@@ -14,8 +14,16 @@
|
|||||||
var timer = null;
|
var timer = null;
|
||||||
var originalRows = null;
|
var originalRows = null;
|
||||||
var originalPager = null;
|
var originalPager = null;
|
||||||
|
var originalBands = null;
|
||||||
var DEBOUNCE_MS = 200;
|
var DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
|
var GROUP_LABEL = {
|
||||||
|
baru: 'BARU',
|
||||||
|
revote: 'REVOTE',
|
||||||
|
valid: 'VALID',
|
||||||
|
kadaluarsa: 'KADALUARSA'
|
||||||
|
};
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
var el = document.createElement('div');
|
var el = document.createElement('div');
|
||||||
el.textContent = text == null ? '' : String(text);
|
el.textContent = text == null ? '' : String(text);
|
||||||
@@ -36,15 +44,34 @@
|
|||||||
return String(value).slice(0, 10);
|
return String(value).slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideBands(on) {
|
||||||
|
var bands = document.querySelectorAll('.band-list');
|
||||||
|
if (on && originalBands === null) {
|
||||||
|
originalBands = Array.prototype.map.call(bands, function (el) {
|
||||||
|
return el.style.display || '';
|
||||||
|
});
|
||||||
|
Array.prototype.forEach.call(bands, function (el) {
|
||||||
|
el.style.display = 'none';
|
||||||
|
});
|
||||||
|
} else if (!on && originalBands !== null) {
|
||||||
|
Array.prototype.forEach.call(bands, function (el, i) {
|
||||||
|
el.style.display = originalBands[i] || '';
|
||||||
|
});
|
||||||
|
originalBands = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setSearchMode(on) {
|
function setSearchMode(on) {
|
||||||
if (on && originalRows === null) {
|
if (on && originalRows === null) {
|
||||||
originalRows = rows.innerHTML;
|
originalRows = rows.innerHTML;
|
||||||
originalPager = pager.style.display || '';
|
originalPager = pager.style.display || '';
|
||||||
|
hideBands(true);
|
||||||
} else if (!on && originalRows !== null) {
|
} else if (!on && originalRows !== null) {
|
||||||
rows.innerHTML = originalRows;
|
rows.innerHTML = originalRows;
|
||||||
pager.style.display = originalPager;
|
pager.style.display = originalPager;
|
||||||
originalRows = null;
|
originalRows = null;
|
||||||
originalPager = null;
|
originalPager = null;
|
||||||
|
hideBands(false);
|
||||||
if (result) {
|
if (result) {
|
||||||
result.textContent = '';
|
result.textContent = '';
|
||||||
}
|
}
|
||||||
@@ -56,9 +83,15 @@
|
|||||||
rows.innerHTML = '<div class="empty">TAK ADA AKUN COCOK</div>';
|
rows.innerHTML = '<div class="empty">TAK ADA AKUN COCOK</div>';
|
||||||
} else {
|
} else {
|
||||||
rows.innerHTML = data.results.map(function (r) {
|
rows.innerHTML = data.results.map(function (r) {
|
||||||
return '<div class="list-row">' +
|
var tag = '<span class="group-tag tag-' + r.group + '">' +
|
||||||
|
(GROUP_LABEL[r.group] || r.group) + '</span>';
|
||||||
|
var badge = r.expired ?
|
||||||
|
'<span class="expired-badge">VOTE ULANG</span>' : '';
|
||||||
|
var warn = r.expiring ?
|
||||||
|
'<span class="warn-badge">VOTE ULANG SEGERA</span>' : '';
|
||||||
|
return '<div class="list-row' + (r.expired ? ' expired' : '') + '">' +
|
||||||
'<span class="rank">' + r.rank + '</span>' +
|
'<span class="rank">' + r.rank + '</span>' +
|
||||||
'<span class="owner">' + escapeHtml(r.owner) + '</span>' +
|
'<span class="owner">' + escapeHtml(r.owner) + tag + warn + badge + '</span>' +
|
||||||
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' +
|
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' +
|
||||||
'<span class="reward" data-label="TOTAL REWARD (VEX)">' + vex(r.total_reward) + '</span>' +
|
'<span class="reward" data-label="TOTAL REWARD (VEX)">' + vex(r.total_reward) + '</span>' +
|
||||||
'<span class="vote-date" data-label="VOTE TERAKHIR">' + shortdate(r.last_vote) + '</span>' +
|
'<span class="vote-date" data-label="VOTE TERAKHIR">' + shortdate(r.last_vote) + '</span>' +
|
||||||
|
|||||||
+79
-5
@@ -13,6 +13,9 @@
|
|||||||
--surface-card: #141414;
|
--surface-card: #141414;
|
||||||
--on-dark: #ffffff;
|
--on-dark: #ffffff;
|
||||||
--link: #c3d9f3;
|
--link: #c3d9f3;
|
||||||
|
--amber: #d4a017;
|
||||||
|
--amber-dim: #8a6d1a;
|
||||||
|
--mint: #7fd4b0;
|
||||||
|
|
||||||
/* typography — DESIGN.md substitutes: Saira Condensed (Display),
|
/* typography — DESIGN.md substitutes: Saira Condensed (Display),
|
||||||
EB Garamond (Text Regular), JetBrains Mono (Monospace) */
|
EB Garamond (Text Regular), JetBrains Mono (Monospace) */
|
||||||
@@ -231,7 +234,9 @@ button.pill {
|
|||||||
grid-template-columns: 200px 200px 120px 1fr;
|
grid-template-columns: 200px 200px 120px 1fr;
|
||||||
}
|
}
|
||||||
.voter-list .list-header,
|
.voter-list .list-header,
|
||||||
.voter-list .list-row {
|
.voter-list .list-row,
|
||||||
|
.band-list .list-header,
|
||||||
|
.band-list .list-row {
|
||||||
grid-template-columns: 72px repeat(4, 1fr);
|
grid-template-columns: 72px repeat(4, 1fr);
|
||||||
}
|
}
|
||||||
.list-header {
|
.list-header {
|
||||||
@@ -257,6 +262,75 @@ button.pill {
|
|||||||
color: var(--on-dark);
|
color: var(--on-dark);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ————— band BARU / KADALUARSA (V40) ————— */
|
||||||
|
.band-list { margin-top: var(--sp-xl); }
|
||||||
|
.section-caption {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: var(--sp-md) var(--sp-sm) 0;
|
||||||
|
}
|
||||||
|
.section-legend {
|
||||||
|
margin-top: var(--sp-xs);
|
||||||
|
padding: 0 var(--sp-sm);
|
||||||
|
color: var(--muted-soft);
|
||||||
|
}
|
||||||
|
.section-legend { color: var(--amber-dim); }
|
||||||
|
.section-legend.mint-legend { color: var(--mint); }
|
||||||
|
.section-legend.warn-legend { color: var(--amber-dim); }
|
||||||
|
|
||||||
|
.list-row.expired {
|
||||||
|
border-left: 2px solid var(--amber);
|
||||||
|
background: rgba(212, 160, 23, 0.12);
|
||||||
|
}
|
||||||
|
.list-row.expired:nth-child(odd) { background: rgba(212, 160, 23, 0.12); }
|
||||||
|
.list-row.expired .owner,
|
||||||
|
.list-row.expired .stake,
|
||||||
|
.list-row.expired .reward { color: var(--amber); }
|
||||||
|
|
||||||
|
.expired-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: var(--sp-sm);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--amber);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.warn-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: var(--sp-sm);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px dashed var(--amber-dim);
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--amber-dim);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.group-tag {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: var(--sp-sm);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--hairline-strong);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.tag-baru { color: var(--link); border-color: var(--link); }
|
||||||
|
.tag-valid { color: var(--muted); }
|
||||||
|
.tag-revote { color: var(--mint); border-color: var(--mint); }
|
||||||
|
.tag-kadaluarsa { color: var(--amber); border-color: var(--amber); }
|
||||||
.stake,
|
.stake,
|
||||||
.reward {
|
.reward {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
@@ -267,9 +341,7 @@ button.pill {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.list-row .vote-date { text-align: center; }
|
.list-row .vote-date { text-align: center; }
|
||||||
.list-header span:nth-child(3),
|
.list-header span:nth-child(n+3) { text-align: center; }
|
||||||
.list-header span:nth-child(4) { text-align: center; }
|
|
||||||
.voter-list .list-header span:nth-child(n+3) { text-align: center; }
|
|
||||||
.pay-list .list-header span:nth-child(2),
|
.pay-list .list-header span:nth-child(2),
|
||||||
.pay-list .list-header span:nth-child(3) { text-align: center; }
|
.pay-list .list-header span:nth-child(3) { text-align: center; }
|
||||||
.pay-list .list-header span:nth-child(4) { text-align: left; }
|
.pay-list .list-header span:nth-child(4) { text-align: left; }
|
||||||
@@ -345,7 +417,9 @@ button.pill {
|
|||||||
.list-header,
|
.list-header,
|
||||||
.list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
|
.list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
|
||||||
.voter-list .list-header,
|
.voter-list .list-header,
|
||||||
.voter-list .list-row { grid-template-columns: 56px 1fr 160px 1fr 1fr; }
|
.voter-list .list-row,
|
||||||
|
.band-list .list-header,
|
||||||
|
.band-list .list-row { grid-template-columns: 56px 1fr 160px 1fr 1fr; }
|
||||||
.pay-list .list-header,
|
.pay-list .list-header,
|
||||||
.pay-list .list-row { grid-template-columns: 180px 180px 110px 1fr; }
|
.pay-list .list-row { grid-template-columns: 180px 180px 110px 1fr; }
|
||||||
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
|
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
|
||||||
|
|||||||
+78
-6
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=9) }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=14) }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-nav">
|
<header class="top-nav">
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
|
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{% if q %}
|
||||||
<section class="spec-list voter-list" id="spec-list">
|
<section class="spec-list voter-list" id="spec-list">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<span>RANK</span>
|
<span>RANK</span>
|
||||||
@@ -73,22 +74,92 @@
|
|||||||
<span>VOTE TERAKHIR</span>
|
<span>VOTE TERAKHIR</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-rows" id="rows">
|
<div class="list-rows" id="rows">
|
||||||
|
{% for r in search_rows %}
|
||||||
|
<div class="list-row{% if r.expired %} expired{% endif %}">
|
||||||
|
<span class="rank">{{ r.rank }}</span>
|
||||||
|
<span class="owner">{{ r.owner }} <span class="group-tag tag-{{ r.group }}">{{ {'baru': 'BARU', 'revote': 'REVOTE', 'valid': 'VALID', 'kadaluarsa': 'KADALUARSA'}[r.group] }}</span>{% if r.expiring %}<span class="warn-badge">VOTE ULANG SEGERA</span>{% endif %}{% if r.expired %}<span class="expired-badge">VOTE ULANG</span>{% endif %}</span>
|
||||||
|
<span class="stake" data-label="STAKE (VEX)">{{ r.staked|vex }}</span>
|
||||||
|
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ r.total_reward|vex }}</span>
|
||||||
|
<span class="vote-date" data-label="VOTE TERAKHIR">{{ r.last_vote|shortdate }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">TAK ADA AKUN COCOK — "{{ q }}"</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
|
||||||
|
{% if new_rows %}
|
||||||
|
<section class="spec-list band-list news-list">
|
||||||
|
<p class="caption section-caption">PEMILIH BARU · BELUM MATANG (0-3 HARI) · {{ new_count }}</p>
|
||||||
|
<div class="list-header">
|
||||||
|
<span>RANK</span>
|
||||||
|
<span>AKUN</span>
|
||||||
|
<span>STAKE (VEX)</span>
|
||||||
|
<span>TOTAL REWARD (VEX)</span>
|
||||||
|
<span>VOTE TERAKHIR</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-rows">
|
||||||
|
{% for row in new_rows %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="rank">{{ loop.index }}</span>
|
||||||
|
<span class="owner">{{ row[0] }} <span class="group-tag tag-baru">BARU</span></span>
|
||||||
|
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
|
||||||
|
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[3]|vex }}</span>
|
||||||
|
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[2]|shortdate }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if revote_count %}
|
||||||
|
<p class="caption section-legend mint-legend">TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG · {{ revote_count }} AKUN</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="spec-list voter-list" id="spec-list">
|
||||||
|
{% if expiring_count %}
|
||||||
|
<p class="caption section-legend warn-legend">TAG AMBER = KADALUARSA DALAM {{ warn_days }} HARI — VOTE ULANG DATABISNISID · {{ expiring_count }} PEMILIH</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if expired_rows %}
|
||||||
|
<p class="caption section-legend">BARIS AMBER = VOTE LEWAT {{ stale_days }} HARI — VOTE ULANG DATABISNISID UNTUK TETAP MENERIMA PROFIT SHARE · {{ expired_count }} SUARA KADALUARSA</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="list-header">
|
||||||
|
<span>RANK</span>
|
||||||
|
<span>AKUN</span>
|
||||||
|
<span>STAKE (VEX)</span>
|
||||||
|
<span>TOTAL REWARD (VEX)</span>
|
||||||
|
<span>VOTE TERAKHIR</span>
|
||||||
|
</div>
|
||||||
|
<div class="list-rows" id="rows">
|
||||||
|
{% for row in expired_rows %}
|
||||||
|
<div class="list-row expired">
|
||||||
|
<span class="rank">{{ loop.index }}</span>
|
||||||
|
<span class="owner">{{ row[0] }} <span class="expired-badge">VOTE ULANG</span></span>
|
||||||
|
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
|
||||||
|
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[3]|vex }}</span>
|
||||||
|
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[2]|shortdate }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
<span class="rank">{{ row[4] }}</span>
|
<span class="rank">{{ row[4] + expired_count }}</span>
|
||||||
<span class="owner">{{ row[0] }}</span>
|
<span class="owner">{{ row[0] }}{% if row[8] %} <span class="group-tag tag-revote">REVOTE</span>{% endif %}{% if row[9] %} <span class="warn-badge">VOTE ULANG SEGERA</span>{% endif %}</span>
|
||||||
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
|
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
|
||||||
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[6]|vex }}</span>
|
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[6]|vex }}</span>
|
||||||
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[5]|shortdate }}</span>
|
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[5]|shortdate }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">
|
{% if not expired_rows %}
|
||||||
{% if q %}TAK ADA AKUN COCOK — "{{ q }}"{% else %}BELUM ADA DATA — JALANKAN get_voters.py{% endif %}
|
<div class="empty">BELUM ADA DATA — JALANKAN get_voters.py</div>
|
||||||
</div>
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not q %}
|
||||||
<nav class="pager" id="pager" aria-label="Navigasi halaman">
|
<nav class="pager" id="pager" aria-label="Navigasi halaman">
|
||||||
{% set qp = q if q else None %}
|
{% set qp = q if q else None %}
|
||||||
<a class="pill {% if page <= 1 %}pill-disabled{% endif %}"
|
<a class="pill {% if page <= 1 %}pill-disabled{% endif %}"
|
||||||
@@ -97,6 +168,7 @@
|
|||||||
<a class="pill {% if page >= total_pages %}pill-disabled{% endif %}"
|
<a class="pill {% if page >= total_pages %}pill-disabled{% endif %}"
|
||||||
href="{{ url_for('index', page=page+1, q=qp) }}">BERIKUTNYA →</a>
|
href="{{ url_for('index', page=page+1, q=qp) }}">BERIKUTNYA →</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
|
|||||||
+26
-7
@@ -1,9 +1,10 @@
|
|||||||
"""Uji verifikasi klaim reward BP (oracle §V32..V34).
|
"""Uji verifikasi klaim reward BP (oracle §V32..V34).
|
||||||
|
|
||||||
`./venv/bin/python test_claim.py` harus exit 0. Memakai DB sementara + mock
|
`./venv/bin/python test_claim.py` harus exit 0. Memakai DB sementara + mock
|
||||||
jaringan/pyntelope — ⊥ klaim nyata. Verifikasi: jendela klaim (V32), math fee
|
jaringan/pyntelope — ⊥ klaim nyata. Verifikasi: jendela klaim (V32), parse
|
||||||
(V33), siklus penuh klaim→fee, retry fee, resume, reward 0 → skipped, klaim
|
respons `get_table_rows` bentuk asli `{rows:[...]}` + recovery window lewat,
|
||||||
yang mendarat walau timeout (V34).
|
math fee (V33), siklus penuh klaim→fee, retry fee, resume, reward 0 →
|
||||||
|
skipped, klaim yang mendarat walau timeout (V34).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -82,14 +83,32 @@ def main():
|
|||||||
check('V32 parse kosong → None', claim.parse_claim_time(None) is None)
|
check('V32 parse kosong → None', claim.parse_claim_time(None) is None)
|
||||||
check('V32 parse sampah → None', claim.parse_claim_time('x') is None)
|
check('V32 parse sampah → None', claim.parse_claim_time('x') is None)
|
||||||
|
|
||||||
# ————— V32: next_window = last_claim_time + 24 jam —————
|
# ————— V32: fetch_producer_last_claim — bentuk asli get_table_rows —————
|
||||||
|
claim._post_json = lambda url, payload: {
|
||||||
|
'rows': [{'last_claim_time': '2026-08-05T07:01:44.500'}],
|
||||||
|
'more': False, 'next_key': ''}
|
||||||
|
check('V32 parse respons asli {rows:[...]} → waktu',
|
||||||
|
claim.fetch_producer_last_claim() == '2026-08-05T07:01:44.500')
|
||||||
|
claim._post_json = lambda url, payload: {'rows': [], 'more': False,
|
||||||
|
'next_key': ''}
|
||||||
|
check('V32 rows kosong → None',
|
||||||
|
claim.fetch_producer_last_claim() is None)
|
||||||
|
claim._post_json = lambda url, payload: {}
|
||||||
|
check('V32 respons tak dikenal → None (⊥ crash)',
|
||||||
|
claim.fetch_producer_last_claim() is None)
|
||||||
|
|
||||||
|
# ————— V32: next_window = last + 24 jam; lewat → klaim segera —————
|
||||||
claim.fetch_producer_last_claim = lambda: '2026-08-05T07:01:44.500'
|
claim.fetch_producer_last_claim = lambda: '2026-08-05T07:01:44.500'
|
||||||
now = datetime(2026, 8, 6, 12, 0, 0)
|
|
||||||
check('V32 jendela = last + 24 jam',
|
check('V32 jendela = last + 24 jam',
|
||||||
claim.next_window(now) == datetime(2026, 8, 6, 7, 1, 44, 500000))
|
claim.next_window(datetime(2026, 8, 6, 5, 0, 0))
|
||||||
|
== datetime(2026, 8, 6, 7, 1, 44, 500000))
|
||||||
|
check('V32 window lewat (≥24 jam) → now (recovery, ⊥ deadlock)',
|
||||||
|
claim.next_window(datetime(2026, 8, 6, 12, 0, 0))
|
||||||
|
== datetime(2026, 8, 6, 12, 0, 0))
|
||||||
claim.fetch_producer_last_claim = lambda: None
|
claim.fetch_producer_last_claim = lambda: None
|
||||||
check('V32 tanpa last → now (langsung coba)',
|
check('V32 tanpa last → now (langsung coba)',
|
||||||
claim.next_window(now) == now)
|
claim.next_window(datetime(2026, 8, 6, 12, 0, 0))
|
||||||
|
== datetime(2026, 8, 6, 12, 0, 0))
|
||||||
|
|
||||||
# ————— V32/V33: siklus penuh — klaim → catat → fee terkirim —————
|
# ————— V32/V33: siklus penuh — klaim → catat → fee terkirim —————
|
||||||
fresh_db('cycle')
|
fresh_db('cycle')
|
||||||
|
|||||||
+145
-11
@@ -42,14 +42,45 @@ def main():
|
|||||||
db = os.path.join(tmp, 'voters.db')
|
db = os.path.join(tmp, 'voters.db')
|
||||||
rows = []
|
rows = []
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
fresh = (now - timedelta(days=5)).isoformat(timespec='seconds')
|
|
||||||
for i in range(1, 126): # 125 baris -> 3 halaman
|
def _iso(days_ago):
|
||||||
|
return (now - timedelta(days=days_ago)).isoformat(timespec='seconds')
|
||||||
|
|
||||||
|
fresh = _iso(5)
|
||||||
|
for i in range(1, 126): # 125 baris valid -> 3 halaman
|
||||||
owner = f'acct{i:03d}'
|
owner = f'acct{i:03d}'
|
||||||
rows.append((owner, f'{i}.00000000000000000', float(i) * 10.0,
|
rows.append((owner, f'{i}.00000000000000000', float(i) * 10.0,
|
||||||
'2026-08-04T00:00:00', fresh))
|
'2026-08-04T00:00:00', fresh))
|
||||||
rows[5] = (rows[5][0], rows[5][1], 5.0 * 10.0, rows[5][3], fresh) # non-monotonic guard
|
rows[5] = (rows[5][0], rows[5][1], 5.0 * 10.0, rows[5][3], fresh) # non-monotonic guard
|
||||||
|
rows.append(('zzzold', '999.00000000000000000', 2.0,
|
||||||
|
'2026-08-04T00:00:00', _iso(30))) # V40: kadaluarsa (28-31 hr)
|
||||||
|
rows.append(('zzhide', '888.00000000000000000', 1.0,
|
||||||
|
'2026-08-04T00:00:00', _iso(60))) # V40: tersembunyi (>31 hr)
|
||||||
|
rows.append(('newacct1', '11.00000000000000000', 1300.0,
|
||||||
|
'2026-08-04T00:00:00', _iso(1))) # V41: BARU (first-seen baru)
|
||||||
|
rows.append(('revoter1', '12.00000000000000000', 1200.0,
|
||||||
|
'2026-08-04T00:00:00', _iso(0))) # V41/V43: REVOTE < 1 hari (first-seen lama)
|
||||||
|
rows.append(('expire1', '13.00000000000000000', 1290.0,
|
||||||
|
'2026-08-04T00:00:00', _iso(26))) # V43: VALID matang + VOTE ULANG SEGERA (25-28 hr)
|
||||||
make_db(db, rows)
|
make_db(db, rows)
|
||||||
|
|
||||||
|
# V41: first_seen — newacct1 pertama kali muncul baru-baru ini → BARU;
|
||||||
|
# revoter1 sudah pernah terlihat lama → REVOTE. acct001 juga ber-first_seen
|
||||||
|
# (baru) — flag revote harus ⊖ True padanya (guard regresi V42: tag hanya
|
||||||
|
# utk first_seen lama, ⊥ sekedar first_seen ada). expire1 tanpa first_seen
|
||||||
|
# → pemilih baru yang matang (V43: badge peringatan tetap tampil padanya).
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen '
|
||||||
|
'(owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
|
||||||
|
conn.executemany(
|
||||||
|
'INSERT OR REPLACE INTO voter_first_seen (owner, first_seen_at) '
|
||||||
|
'VALUES (?, ?)',
|
||||||
|
[('newacct1', _iso(1)), ('revoter1', _iso(60)),
|
||||||
|
('acct001', _iso(1))],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
dbm.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan)
|
dbm.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan)
|
||||||
real_get_liquid = dashboard._get_liquid_vex
|
real_get_liquid = dashboard._get_liquid_vex
|
||||||
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
|
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
|
||||||
@@ -85,13 +116,45 @@ def main():
|
|||||||
check('V16 SALDO LIQUID paling kiri (sblm TOTAL PEMILIH)',
|
check('V16 SALDO LIQUID paling kiri (sblm TOTAL PEMILIH)',
|
||||||
html.index('SALDO LIQUID') < html.index('TOTAL PEMILIH'))
|
html.index('SALDO LIQUID') < html.index('TOTAL PEMILIH'))
|
||||||
check('V16 nilai saldo diformat id-ID', '50.691,4676' in html)
|
check('V16 nilai saldo diformat id-ID', '50.691,4676' in html)
|
||||||
|
check('V40 TOTAL PEMILIH = baris tampil (129)',
|
||||||
|
'<span class="cell-value">129</span>' in html)
|
||||||
|
|
||||||
|
# ————— V40/V41: band umur — BARU / REVOTE / daftar utama (kadaluarsa menyatu) —————
|
||||||
|
check('V40 KADALUARSA menyatu dgn daftar utama (bukan bagian sendiri)',
|
||||||
|
'expired-list' not in html)
|
||||||
|
check('V40 baris kadaluarsa ber-sorot .expired',
|
||||||
|
'class="list-row expired"' in html)
|
||||||
|
check('V40 badge VOTE ULANG', 'VOTE ULANG' in html)
|
||||||
|
check('V40 legenda amber di daftar utama',
|
||||||
|
'BARIS AMBER = VOTE LEWAT' in html and 'SUARA KADALUARSA' in html)
|
||||||
|
check('V40 pemilih kadaluarsa tampil', 'zzzold' in html)
|
||||||
|
check('V40 kadaluarsa & valid satu daftar (#rows)',
|
||||||
|
'zzzold' in _slice_rows(html) and 'acct125' in _slice_rows(html))
|
||||||
|
check('V40 pemilih tersembunyi (>31 hr) tak tampil', 'zzhide' not in html)
|
||||||
|
check('V42 REVOTE ⊖ bagian sendiri (menyatu dgn VALID)',
|
||||||
|
'PEMILIH REVOTE · BELUM MATANG' not in html)
|
||||||
|
check('V41 caption BARU count', 'PEMILIH BARU · BELUM MATANG (0-3 HARI) · 1' in html)
|
||||||
|
check('V42 revoter1 (REVOTE) ada di daftar utama (#rows)',
|
||||||
|
'revoter1' in _slice_rows(html))
|
||||||
|
check('V42 newacct1 di BARU, revoter1 di VALID',
|
||||||
|
'newacct1' in html and 'revoter1' in html)
|
||||||
|
check('V42 tag REVOTE pada baris valid', 'tag-revote">REVOTE' in html)
|
||||||
|
check('V42 hanya 1 tag REVOTE di daftar utama hal.1 (revoter1)',
|
||||||
|
_slice_rows(html).count('tag-revote') == 1)
|
||||||
|
check('V43 badge VOTE ULANG SEGERA pada baris valid (expire1)',
|
||||||
|
'warn-badge">VOTE ULANG SEGERA' in _slice_rows(html))
|
||||||
|
check('V43 legenda mint REVOTE 1-hari',
|
||||||
|
'TAG MINT = REVOTE KURANG DARI 1 HARI' in html)
|
||||||
|
check('V43 legenda amber peringatan',
|
||||||
|
'TAG AMBER = KADALUARSA DALAM 3 HARI' in html and '1 PEMILIH' in html)
|
||||||
|
|
||||||
staked_vals = []
|
staked_vals = []
|
||||||
for i in range(1, 4):
|
for i in range(1, 4):
|
||||||
resp = client.get(f'/?page={i}')
|
resp = client.get(f'/?page={i}')
|
||||||
check(f'V10 halaman {i} → 200', resp.status_code == 200)
|
check(f'V10 halaman {i} → 200', resp.status_code == 200)
|
||||||
staked_vals += _extract_stakes(resp.get_data(as_text=True))
|
staked_vals += _extract_stakes(resp.get_data(as_text=True))
|
||||||
check('V10 total 125 baris ditarik', len(staked_vals) == 125)
|
check('V42 total 127 baris valid ditarik (125 acct + revoter1 + expire1)',
|
||||||
|
len(staked_vals) == 127)
|
||||||
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
|
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
|
||||||
for i in range(len(staked_vals) - 1)))
|
for i in range(len(staked_vals) - 1)))
|
||||||
|
|
||||||
@@ -107,6 +170,8 @@ def main():
|
|||||||
check('V29 reward acct001 = total sent (50,0000)',
|
check('V29 reward acct001 = total sent (50,0000)',
|
||||||
'<span class="reward" data-label="TOTAL REWARD (VEX)">50,0000</span>'
|
'<span class="reward" data-label="TOTAL REWARD (VEX)">50,0000</span>'
|
||||||
in html)
|
in html)
|
||||||
|
check('V42 acct001 (first_seen baru) ⊖ tag REVOTE di hal.3',
|
||||||
|
'tag-revote">REVOTE' not in _slice_rows(html))
|
||||||
|
|
||||||
# ————— V38: banner BERITA jadwal distribusi berikutnya —————
|
# ————— V38: banner BERITA jadwal distribusi berikutnya —————
|
||||||
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 17, 10, 0)
|
dashboard._next_schedule = lambda now=None: datetime(2026, 8, 17, 10, 0)
|
||||||
@@ -181,13 +246,20 @@ def main():
|
|||||||
check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True)
|
check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True)
|
||||||
and len(q_vals) == 6)
|
and len(q_vals) == 6)
|
||||||
|
|
||||||
# pager membawa q
|
# mode search = daftar datar ber-tag; input mempertahankan q
|
||||||
check('V11 pager mempertahankan q', 'q=acct12' in html)
|
check('V40 search input mempertahankan q', 'value="acct12"' in html)
|
||||||
|
|
||||||
# halaman ekstra saat filter → dipatok ke total_pages filter
|
# halaman ekstra saat filter → dipatok ke total_pages filter
|
||||||
resp = client.get('/?q=acct12&page=99')
|
resp = client.get('/?q=acct12&page=99')
|
||||||
check('V11 clamp halaman saat search', resp.status_code == 200)
|
check('V11 clamp halaman saat search', resp.status_code == 200)
|
||||||
|
|
||||||
|
# V43: badge peringatan juga tampil di mode search tanpa-JS (?q=)
|
||||||
|
resp = client.get('/?q=expire1')
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
check('V43 search ?q= menampilkan badge VOTE ULANG SEGERA',
|
||||||
|
'warn-badge">VOTE ULANG SEGERA' in html
|
||||||
|
and 'expire1' in _slice_rows(html))
|
||||||
|
|
||||||
# search tanpa hasil → status kosong
|
# search tanpa hasil → status kosong
|
||||||
resp = client.get('/?q=zzzz')
|
resp = client.get('/?q=zzzz')
|
||||||
html = resp.get_data(as_text=True)
|
html = resp.get_data(as_text=True)
|
||||||
@@ -218,6 +290,44 @@ def main():
|
|||||||
all('total_reward' in r for r in data['results']))
|
all('total_reward' in r for r in data['results']))
|
||||||
check('V29 search total_reward 0 (tanpa transfer)',
|
check('V29 search total_reward 0 (tanpa transfer)',
|
||||||
all(r['total_reward'] == 0 for r in data['results']))
|
all(r['total_reward'] == 0 for r in data['results']))
|
||||||
|
check('V40 search group hadir',
|
||||||
|
all(r['group'] in ('baru', 'revote', 'valid', 'kadaluarsa')
|
||||||
|
for r in data['results']))
|
||||||
|
check('V40 search expired flag hadir',
|
||||||
|
all('expired' in r for r in data['results']))
|
||||||
|
check('V43 search expiring flag hadir',
|
||||||
|
all('expiring' in r for r in data['results']))
|
||||||
|
check('V40 search acct12 semua valid + expired=False',
|
||||||
|
all(r['group'] == 'valid' and not r['expired']
|
||||||
|
for r in data['results']))
|
||||||
|
|
||||||
|
resp = client.get('/api/search?q=revoter1')
|
||||||
|
data = resp.get_json()
|
||||||
|
check('V41 search revoter1 → group revote (tag < 1 hari)',
|
||||||
|
len(data['results']) == 1 and data['results'][0]['group'] == 'revote'
|
||||||
|
and data['results'][0]['expired'] is False
|
||||||
|
and data['results'][0]['expiring'] is False)
|
||||||
|
resp = client.get('/api/search?q=expire1')
|
||||||
|
data = resp.get_json()
|
||||||
|
check('V43 search expire1 → group valid + expiring',
|
||||||
|
len(data['results']) == 1
|
||||||
|
and data['results'][0]['group'] == 'valid'
|
||||||
|
and data['results'][0]['expired'] is False
|
||||||
|
and data['results'][0]['expiring'] is True)
|
||||||
|
resp = client.get('/api/search?q=newacct1')
|
||||||
|
data = resp.get_json()
|
||||||
|
check('V41 search newacct1 → group baru',
|
||||||
|
len(data['results']) == 1 and data['results'][0]['group'] == 'baru'
|
||||||
|
and data['results'][0]['expired'] is False)
|
||||||
|
resp = client.get('/api/search?q=zzzold')
|
||||||
|
data = resp.get_json()
|
||||||
|
check('V40 search kadaluarsa → group + expired',
|
||||||
|
len(data['results']) == 1
|
||||||
|
and data['results'][0]['group'] == 'kadaluarsa'
|
||||||
|
and data['results'][0]['expired'] is True)
|
||||||
|
resp = client.get('/api/search?q=zzhide')
|
||||||
|
check('V40 search tersembunyi (>31 hr) → 0 hasil',
|
||||||
|
resp.get_json()['count'] == 0)
|
||||||
|
|
||||||
resp = client.get('/api/search?q=acct001')
|
resp = client.get('/api/search?q=acct001')
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
@@ -234,11 +344,11 @@ def main():
|
|||||||
resp = client.get('/api/search?q=acct!%')
|
resp = client.get('/api/search?q=acct!%')
|
||||||
check('V17 wildcard `%` di-escape (bukan pola)', resp.get_json()['count'] == 0)
|
check('V17 wildcard `%` di-escape (bukan pola)', resp.get_json()['count'] == 0)
|
||||||
|
|
||||||
# rank HARUS global, bukan dalam-filter: 'acct00' menyentuh baris stake kecil
|
# rank ordinal DALAM kelompok: 'acct00' menyentuh 9 baris VALID → 1..9
|
||||||
resp = client.get('/api/search?q=acct00')
|
resp = client.get('/api/search?q=acct00')
|
||||||
data = resp.get_json()
|
data = resp.get_json()
|
||||||
check('V11 rank global (bukan dalam-filter)',
|
check('V40 rank ordinal dalam kelompok (1..9)',
|
||||||
[r['rank'] for r in data['results']] == list(range(117, 126)))
|
[r['rank'] for r in data['results']] == list(range(1, 10)))
|
||||||
|
|
||||||
# ————— env config: default = konstanta lama —————
|
# ————— env config: default = konstanta lama —————
|
||||||
import config
|
import config
|
||||||
@@ -249,6 +359,10 @@ def main():
|
|||||||
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
|
check('V11 config default DB_PATH', config.DB_PATH == 'voters.db')
|
||||||
check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2)
|
check('V12 config default DASH_WORKERS', config.DASH_WORKERS == 2)
|
||||||
check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28)
|
check('V13 config default VEX_STALE_DAYS', config.VEX_STALE_DAYS == 28)
|
||||||
|
check('V40 config default VEX_MATURITY_DAYS', config.VEX_MATURITY_DAYS == 3)
|
||||||
|
check('V40 config default VEX_EXPIRED_DAYS', config.VEX_EXPIRED_DAYS == 3)
|
||||||
|
check('V43 config default VEX_REVOTE_TAG_DAYS', config.VEX_REVOTE_TAG_DAYS == 1)
|
||||||
|
check('V43 config default VEX_WARN_DAYS', config.VEX_WARN_DAYS == 3)
|
||||||
check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id')
|
check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id')
|
||||||
check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60)
|
check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60)
|
||||||
check('V17 config default DB_BACKEND', config.DB_BACKEND == 'sqlite')
|
check('V17 config default DB_BACKEND', config.DB_BACKEND == 'sqlite')
|
||||||
@@ -292,9 +406,29 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
def _extract_stakes(text):
|
def _extract_stakes(text):
|
||||||
"""Ambil nilai stake (dalam urutan kemunculan) dari baris HTML."""
|
"""Ambil nilai stake (dalam urutan kemunculan) dari baris HTML — HANYA dari
|
||||||
return [_parse_vex(m.group(1))
|
kontainer `id="rows"` (daftar utama VALID / hasil search). Baris sorot
|
||||||
for m in re.finditer(r'<span class="stake"[^>]*>(.*?)</span>', text)]
|
`list-row expired` dilewati (V40: kadaluarsa menyatu di daftar utama, ⊥
|
||||||
|
hitungan/pengurutan VALID)."""
|
||||||
|
chunk = _slice_rows(text)
|
||||||
|
vals = []
|
||||||
|
for m in re.finditer(r'<div class="list-row(?: expired)?">(.*?)</div>',
|
||||||
|
chunk, re.S):
|
||||||
|
if ' expired' in m.group(0):
|
||||||
|
continue
|
||||||
|
sm = re.search(r'<span class="stake"[^>]*>(.*?)</span>', m.group(1))
|
||||||
|
if sm:
|
||||||
|
vals.append(_parse_vex(sm.group(1)))
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
def _slice_rows(text):
|
||||||
|
start = text.index('id="rows">') + len('id="rows">')
|
||||||
|
if '<nav class="pager"' in text:
|
||||||
|
end = text.index('<nav class="pager"', start)
|
||||||
|
else:
|
||||||
|
end = text.index('<footer', start)
|
||||||
|
return text[start:end]
|
||||||
|
|
||||||
|
|
||||||
def _parse_vex(text):
|
def _parse_vex(text):
|
||||||
|
|||||||
+58
-8
@@ -12,7 +12,7 @@ import sqlite3
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import db
|
import db
|
||||||
@@ -55,6 +55,20 @@ def make_voters_db(path, rows):
|
|||||||
'VALUES (?, ?, ?, ?, ?)',
|
'VALUES (?, ?, ?, ?, ?)',
|
||||||
rows,
|
rows,
|
||||||
)
|
)
|
||||||
|
# V42: distribusi LEFT JOIN voter_first_seen — tabel harus ada.
|
||||||
|
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen ('
|
||||||
|
'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def make_first_seen(path, rows):
|
||||||
|
"""V42: isi tabel voter_first_seen (owner, first_seen_at)."""
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen ('
|
||||||
|
'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
|
||||||
|
conn.executemany('INSERT OR IGNORE INTO voter_first_seen '
|
||||||
|
'(owner, first_seen_at) VALUES (?, ?)', rows)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -68,6 +82,9 @@ def main():
|
|||||||
tmp = tempfile.mkdtemp()
|
tmp = tempfile.mkdtemp()
|
||||||
db.DB_PATH = os.path.join(tmp, 'test.db')
|
db.DB_PATH = os.path.join(tmp, 'test.db')
|
||||||
_orig_query = db.query
|
_orig_query = db.query
|
||||||
|
# V40: stempel last_vote SEJAK SEKARANG (relatif) agar umur jatuh di band
|
||||||
|
# VALID (3..28 hari) — stempel hardcode berumur <3 hari justru dibuang.
|
||||||
|
fresh = (datetime.now() - timedelta(days=5)).isoformat(timespec='seconds')
|
||||||
|
|
||||||
# Oracle: Telegram ⊥ boleh mengirim nyata — nonaktifkan dulu, dipakai lagi §V30
|
# Oracle: Telegram ⊥ boleh mengirim nyata — nonaktifkan dulu, dipakai lagi §V30
|
||||||
_tg_token = telegram.TELEGRAM_BOT_TOKEN
|
_tg_token = telegram.TELEGRAM_BOT_TOKEN
|
||||||
@@ -109,7 +126,7 @@ def main():
|
|||||||
|
|
||||||
# ————— V26: dry-run — rencana tanpa tanda tangan / tanpa tulis DB —————
|
# ————— V26: dry-run — rencana tanpa tanda tangan / tanpa tulis DB —————
|
||||||
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
||||||
'2026-08-05T00:00:00')])
|
fresh)])
|
||||||
dist.fetch_balance = lambda: Decimal('100.0000')
|
dist.fetch_balance = lambda: Decimal('100.0000')
|
||||||
dist.BP_PRIVATE_KEY = ''
|
dist.BP_PRIVATE_KEY = ''
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
@@ -133,7 +150,7 @@ def main():
|
|||||||
check('V25 pemilih kosong → no-op', rc == 0 and 'no-op' in buf.getvalue())
|
check('V25 pemilih kosong → no-op', rc == 0 and 'no-op' in buf.getvalue())
|
||||||
|
|
||||||
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
||||||
'2026-08-05T00:00:00')])
|
fresh)])
|
||||||
dist.fetch_balance = lambda: Decimal('0.0000')
|
dist.fetch_balance = lambda: Decimal('0.0000')
|
||||||
db.query = _orig_query
|
db.query = _orig_query
|
||||||
buf = io.StringIO()
|
buf = io.StringIO()
|
||||||
@@ -152,9 +169,9 @@ def main():
|
|||||||
|
|
||||||
# ————— V21/V23/V24: run nyata — pending→sent, memo per run —————
|
# ————— V21/V23/V24: run nyata — pending→sent, memo per run —————
|
||||||
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
|
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
|
||||||
'2026-08-05T00:00:00'),
|
fresh),
|
||||||
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
|
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
|
||||||
'2026-08-05T00:00:00')])
|
fresh)])
|
||||||
dist.fetch_balance = lambda: Decimal('100.0000')
|
dist.fetch_balance = lambda: Decimal('100.0000')
|
||||||
dist.BP_PRIVATE_KEY = '5Ktest'
|
dist.BP_PRIVATE_KEY = '5Ktest'
|
||||||
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
|
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
|
||||||
@@ -231,7 +248,7 @@ def main():
|
|||||||
|
|
||||||
# ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
|
# ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
|
||||||
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
||||||
'2026-08-05T00:00:00')])
|
fresh)])
|
||||||
db.query = _orig_query
|
db.query = _orig_query
|
||||||
dist.fetch_balance = lambda: Decimal('100.0000')
|
dist.fetch_balance = lambda: Decimal('100.0000')
|
||||||
_orig_record_run = db.record_run
|
_orig_record_run = db.record_run
|
||||||
@@ -260,6 +277,39 @@ def main():
|
|||||||
db.record_run = _orig_record_run
|
db.record_run = _orig_record_run
|
||||||
dist.build_signed_transfer = _orig_build
|
dist.build_signed_transfer = _orig_build
|
||||||
|
|
||||||
|
# ————— V40/V42: reward utk vote matang+segar; REVOTE langsung VALID —————
|
||||||
|
def _iso(days_ago):
|
||||||
|
return (datetime.now() - timedelta(days=days_ago)).isoformat(timespec='seconds')
|
||||||
|
|
||||||
|
make_voters_db(db.DB_PATH, [
|
||||||
|
('new1', '1.0', 1200.0, fresh, _iso(1)), # BARU — belum matang
|
||||||
|
('mid5', '1.0', 1200.0, fresh, _iso(5)), # VALID — matang+segar
|
||||||
|
('rev7', '1.0', 1200.0, fresh, _iso(1)), # REVOTE — pemilih lama revote
|
||||||
|
('old9', '1.0', 1200.0, fresh, _iso(40)), # basi — lewat 28 hari
|
||||||
|
])
|
||||||
|
# V42: first_seen — new1 baru (umur 1 hr), rev7 lama (first_seen 60 hr lalu).
|
||||||
|
make_first_seen(db.DB_PATH, [('new1', _iso(1)), ('rev7', _iso(60))])
|
||||||
|
db.query = _orig_query
|
||||||
|
dist.fetch_balance = lambda: Decimal('300.0000')
|
||||||
|
dist.BP_PRIVATE_KEY = '5Ktest'
|
||||||
|
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
|
||||||
|
signed_to = []
|
||||||
|
dist.build_signed_transfer = (lambda to, amount, memo:
|
||||||
|
signed_to.append(to) or FakeSigned('txV' + to))
|
||||||
|
calls['runs'] = []
|
||||||
|
db.record_run = lambda *a, **k: calls['runs'].append(a) or 7
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
dist.run_distribution(dry_run=False)
|
||||||
|
check('V42 REVOTE (umur 1 hr, first_seen lama) LANGSUNG dibayar',
|
||||||
|
'rev7' in signed_to)
|
||||||
|
check('V40 pemilih baru (umur 1 hr) ⊥ dibayar', 'new1' not in signed_to)
|
||||||
|
check('V40 basi (umur 40 hr) ⊥ dibayar', 'old9' not in signed_to)
|
||||||
|
check('V40 matang+segar (umur 5 hr) dibayar', 'mid5' in signed_to)
|
||||||
|
check('V40/V42 tepat 2 terkirim (mid5 + rev7)',
|
||||||
|
sorted(signed_to) == ['mid5', 'rev7'])
|
||||||
|
db.record_run = _orig_record_run
|
||||||
|
dist.build_signed_transfer = _orig_build
|
||||||
|
|
||||||
# ————— V30: notifikasi Telegram — dua kanal, komunitas brief/internal detail —————
|
# ————— V30: notifikasi Telegram — dua kanal, komunitas brief/internal detail —————
|
||||||
tg = {'posts': []}
|
tg = {'posts': []}
|
||||||
|
|
||||||
@@ -281,9 +331,9 @@ def main():
|
|||||||
|
|
||||||
# dry-run → ⊥ notif Telegram
|
# dry-run → ⊥ notif Telegram
|
||||||
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
|
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
|
||||||
'2026-08-05T00:00:00'),
|
fresh),
|
||||||
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
|
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
|
||||||
'2026-08-05T00:00:00')])
|
fresh)])
|
||||||
db.query = _orig_query
|
db.query = _orig_query
|
||||||
dist.fetch_balance = lambda: Decimal('100.0000')
|
dist.fetch_balance = lambda: Decimal('100.0000')
|
||||||
dist.BP_PRIVATE_KEY = '5Ktest'
|
dist.BP_PRIVATE_KEY = '5Ktest'
|
||||||
|
|||||||
+31
-13
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Oracle: `./venv/bin/python test_get_voters.py` harus exit 0.
|
Oracle: `./venv/bin/python test_get_voters.py` harus exit 0.
|
||||||
Dalam setiap contoh, 'last_vote_weight' berisi 17 desimal (bobot teliti).
|
Dalam setiap contoh, 'last_vote_weight' berisi 17 desimal (bobot teliti).
|
||||||
Pemilih buatan memakai bobot segar (revote ~sekarang) supaya lolos saringan
|
Pemilih buatan memakai bobot segar (revote ~sekarang); sejak V40 baris basi
|
||||||
V13; baris basi ditambahkan eksplisit untuk membuktikan ia dibuang.
|
JUGA disimpan (banding/tampil/bayar menghitung umur saat query) — contoh basi
|
||||||
|
membuktikan ia lolos ke snapshot, bukan dibuang.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -105,7 +106,8 @@ def main():
|
|||||||
norm = [n for n in norm if n is not None]
|
norm = [n for n in norm if n is not None]
|
||||||
norm_owners = [n['owner'] for n in norm]
|
norm_owners = [n['owner'] for n in norm]
|
||||||
check('V4 sentinel "0" dilewati', gv.ZERO_OWNER not in norm_owners)
|
check('V4 sentinel "0" dilewati', gv.ZERO_OWNER not in norm_owners)
|
||||||
check('V5 hanya BP target', all(n['owner'] in ('aaa1', 'ccc3') for n in norm))
|
check('V5 hanya BP target', all(n['owner'] in ('aaa1', 'ccc3', 'zzz9')
|
||||||
|
for n in norm))
|
||||||
by = {n['owner']: n for n in norm}
|
by = {n['owner']: n for n in norm}
|
||||||
aaa1_w = _fresh_weight(12000000)
|
aaa1_w = _fresh_weight(12000000)
|
||||||
check('V6 bobot verbatim TEXT', by['aaa1']['weight'] == aaa1_w)
|
check('V6 bobot verbatim TEXT', by['aaa1']['weight'] == aaa1_w)
|
||||||
@@ -114,13 +116,15 @@ def main():
|
|||||||
check('staked ÷10000', by['aaa1']['staked'] == 1200.0)
|
check('staked ÷10000', by['aaa1']['staked'] == 1200.0)
|
||||||
check('staked batas ≥1000 ÷10000', by['ccc3']['staked'] == 1000.0)
|
check('staked batas ≥1000 ÷10000', by['ccc3']['staked'] == 1000.0)
|
||||||
|
|
||||||
# V13: last_vote diturunkan di normalize; baris basi dibuang (⊥ disimpan)
|
# V13: last_vote diturunkan di normalize; V40: basi tetap disimpan
|
||||||
check('V13 last_vote ikut dinormalisasi', 'last_vote' in by['aaa1'])
|
check('V13 last_vote ikut dinormalisasi', 'last_vote' in by['aaa1'])
|
||||||
check('V13 last_vote segar (≤ VEX_STALE_DAYS)',
|
check('V13 last_vote segar (≤ VEX_STALE_DAYS)',
|
||||||
datetime.fromisoformat(by['aaa1']['last_vote'])
|
datetime.fromisoformat(by['aaa1']['last_vote'])
|
||||||
>= datetime.now() - timedelta(days=gv.VEX_STALE_DAYS))
|
>= datetime.now() - timedelta(days=28))
|
||||||
check('V13 basi (zzz9) dibuang di normalize',
|
check('V40 basi (zzz9) tetap disimpan', by['zzz9']['owner'] == 'zzz9')
|
||||||
all(n['owner'] != 'zzz9' for n in norm))
|
check('V40 last_vote basi zzz9 ~900 hari lalu',
|
||||||
|
(datetime.now() - datetime.fromisoformat(by['zzz9']['last_vote'])).days
|
||||||
|
> 800)
|
||||||
|
|
||||||
# V13: formula — staked_raw × 2^26 → ~2026-01-01 (±2 hari, toleransi 365.25 hari/tahun)
|
# V13: formula — staked_raw × 2^26 → ~2026-01-01 (±2 hari, toleransi 365.25 hari/tahun)
|
||||||
lv = gv.derive_last_vote(f'{12000000 * 2 ** 26}.00000000000000000', 1200.0)
|
lv = gv.derive_last_vote(f'{12000000 * 2 ** 26}.00000000000000000', 1200.0)
|
||||||
@@ -134,12 +138,12 @@ def main():
|
|||||||
check('V13 bobot tak valid → None', gv.derive_last_vote('abc', 1200.0) is None)
|
check('V13 bobot tak valid → None', gv.derive_last_vote('abc', 1200.0) is None)
|
||||||
check('V13 stake nol → None', gv.derive_last_vote('1.0', 0.0) is None)
|
check('V13 stake nol → None', gv.derive_last_vote('1.0', 0.0) is None)
|
||||||
|
|
||||||
# V13: normalize membuang basi / tak dapat diverifikasi, menyimpan segar
|
# V13/V40: normalize menyimpan basi, membuang yang tak dapat diverifikasi
|
||||||
check('V13 normalize basi → None',
|
old = gv.normalize({'owner': 'old8', 'producers': [gv.TARGET_BP],
|
||||||
gv.normalize({'owner': 'old8', 'producers': [gv.TARGET_BP],
|
|
||||||
'staked': 20000000,
|
'staked': 20000000,
|
||||||
'last_vote_weight': _fresh_weight(20000000, days_ago=900)})
|
'last_vote_weight': _fresh_weight(20000000, days_ago=900)})
|
||||||
is None)
|
check('V40 normalize basi (900 hari) → disimpan',
|
||||||
|
old is not None and old['owner'] == 'old8')
|
||||||
check('V13 normalize bobot kosong (unverifiable) → None',
|
check('V13 normalize bobot kosong (unverifiable) → None',
|
||||||
gv.normalize({'owner': 'unk7', 'producers': [gv.TARGET_BP],
|
gv.normalize({'owner': 'unk7', 'producers': [gv.TARGET_BP],
|
||||||
'staked': 20000000, 'last_vote_weight': ''}) is None)
|
'staked': 20000000, 'last_vote_weight': ''}) is None)
|
||||||
@@ -180,7 +184,7 @@ def main():
|
|||||||
{'owner': 'aaa1', 'weight': '111.0', 'staked': 1200.0},
|
{'owner': 'aaa1', 'weight': '111.0', 'staked': 1200.0},
|
||||||
{'owner': 'ccc3', 'weight': '222.0', 'staked': 1000.0},
|
{'owner': 'ccc3', 'weight': '222.0', 'staked': 1000.0},
|
||||||
]
|
]
|
||||||
gv.persist(set_a)
|
scanned_a = gv.persist(set_a)
|
||||||
conn = sqlite3.connect(db.DB_PATH)
|
conn = sqlite3.connect(db.DB_PATH)
|
||||||
cnt = conn.execute('SELECT COUNT(*) FROM voters').fetchone()[0]
|
cnt = conn.execute('SELECT COUNT(*) FROM voters').fetchone()[0]
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -190,7 +194,7 @@ def main():
|
|||||||
{'owner': 'aaa1', 'weight': '999.0', 'staked': 5000.0},
|
{'owner': 'aaa1', 'weight': '999.0', 'staked': 5000.0},
|
||||||
{'owner': 'ddd4', 'weight': '333.0', 'staked': 2000.0},
|
{'owner': 'ddd4', 'weight': '333.0', 'staked': 2000.0},
|
||||||
]
|
]
|
||||||
gv.persist(set_b)
|
scanned_b = gv.persist(set_b)
|
||||||
conn = sqlite3.connect(db.DB_PATH)
|
conn = sqlite3.connect(db.DB_PATH)
|
||||||
rows = conn.execute('SELECT owner, weight, staked, scanned_at FROM voters').fetchall()
|
rows = conn.execute('SELECT owner, weight, staked, scanned_at FROM voters').fetchall()
|
||||||
by = {r[0]: r for r in rows}
|
by = {r[0]: r for r in rows}
|
||||||
@@ -206,6 +210,20 @@ def main():
|
|||||||
check('V7 owner PRIMARY KEY', 'owner' in cols and pk == ['owner'])
|
check('V7 owner PRIMARY KEY', 'owner' in cols and pk == ['owner'])
|
||||||
check('V7 kolom last_vote ada', 'last_vote' in cols)
|
check('V7 kolom last_vote ada', 'last_vote' in cols)
|
||||||
|
|
||||||
|
# V41: first_seen — kemunculan pertama tiap owner, idempoten (⊥ overwrite)
|
||||||
|
db.record_first_seen([{'owner': 'aaa1'}], '2000-01-01T00:00:00')
|
||||||
|
conn = sqlite3.connect(db.DB_PATH)
|
||||||
|
fs = {r[0]: r[1] for r in conn.execute(
|
||||||
|
'SELECT owner, first_seen_at FROM voter_first_seen').fetchall()}
|
||||||
|
conn.close()
|
||||||
|
check('V41 first_seen tercatat utk semua owner',
|
||||||
|
set(fs) == {'aaa1', 'ccc3', 'ddd4'})
|
||||||
|
check('V41 first_seen = kemunculan pertama',
|
||||||
|
fs['aaa1'] == scanned_a and fs['ccc3'] == scanned_a
|
||||||
|
and fs['ddd4'] == scanned_b)
|
||||||
|
check('V41 first_seen idempoten (record ulang ⊥ overwrite)',
|
||||||
|
fs['aaa1'] == scanned_a and fs['aaa1'] != '2000-01-01T00:00:00')
|
||||||
|
|
||||||
print('\nSEMUA UJI PASS')
|
print('\nSEMUA UJI PASS')
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ def main():
|
|||||||
scanned = db.queryone("SELECT COALESCE(MAX(scanned_at), '') FROM voters")[0]
|
scanned = db.queryone("SELECT COALESCE(MAX(scanned_at), '') FROM voters")[0]
|
||||||
check('mysql MAX(scanned_at) non-empty', isinstance(scanned, str) and scanned != '')
|
check('mysql MAX(scanned_at) non-empty', isinstance(scanned, str) and scanned != '')
|
||||||
|
|
||||||
|
# ————— V41: voter_first_seen — INSERT IGNORE idempoten (⊥ overwrite) —————
|
||||||
|
db.record_first_seen([{'owner': 'aaa1'}, {'owner': 'ddd4'}], '2026-08-01T00:00:00')
|
||||||
|
db.record_first_seen([{'owner': 'aaa1'}], '2026-08-02T00:00:00')
|
||||||
|
fs = {r[0]: r[1] for r in db.query(
|
||||||
|
'SELECT owner, first_seen_at FROM voter_first_seen ORDER BY owner')}
|
||||||
|
check('mysql first_seen idempoten',
|
||||||
|
fs == {'aaa1': '2026-08-01T00:00:00', 'ddd4': '2026-08-01T00:00:00'})
|
||||||
|
|
||||||
# ————— rank via correlated-count (global, sama semantik sqlite) —————
|
# ————— rank via correlated-count (global, sama semantik sqlite) —————
|
||||||
ranked = db.query(
|
ranked = db.query(
|
||||||
'SELECT v.owner, (SELECT COUNT(*) FROM voters v2 '
|
'SELECT v.owner, (SELECT COUNT(*) FROM voters v2 '
|
||||||
|
|||||||
Reference in new issue
Block a user