Compare commits

...
7 Commits
Author SHA1 Message Date
proitlab a5e8367363 promo VEXWALLET pakai logo IDRS (static/idrs.png) + BARU band kolom TOTAL REWARD → MATURITY PERIOD (V52/V53) ; image web rebuild + push 2026-08-08 21:40:23 +07:00
proitlab cd1452a353 BARU band: hapus kolom VOTE TERAKHIR + tambah countdown VALID DALAM N HARI (V52) — _edge_rows bawa first_seen_at, filter countdown (first_seen + MATURITY), grid band-list 4 kolom, .valid-in ; image web rebuild + push 2026-08-08 21:09:01 +07:00
proitlab d6a15a07ae maturity pemilih baru diukur dari first_seen_at (V52/B15): last_vote terkuantisasi pekan (stake2vote) membuat voter baru Senin–Kamis tampak matang → lolos wait 3-hari ; BARU = first_seen baru/NULL, VALID = first_seen matang ; dashboard + distribusi konsisten ; image web+dist rebuild + push 2026-08-08 00:46:11 +07:00
proitlab 9b040a8c71 dashboard: nama akun jadi link https://vexascan.com/account/{owner} (V51) — index list (4 spot) + history pay-list + live search, .owner-link target=_blank ; image web rebuild + push 2026-08-08 00:07:55 +07:00
proitlab 5618bad427 klaim: _confirm_landed ⊥ menyerah saat Hyperion executed:false (V50/B14) — tx baru mendarat belum terindeks (lag) dianggap tak mendarat → retry-loop permanen, reward & fee tak tercatat ; landing = Hyperion executed ATAU last_claim_time maju, dicek utk verified False MAUPUN None ; oracle + image claim rebuild + push 2026-08-07 23:01:46 +07:00
proitlab 642822989c klaim: math jendela UTC-naive (V49/B13): _utcnow() di step/poll_claim/_try_claim_once konsisten dgn last_claim_time chain — ⊥ datetime.now() lokal (TZ Asia/Jakarta) yang menggeser jendela ~7 jam → poll prematur ditolak chain → retry tanpa henti ; oracle UTC + image claim rebuild + push 2026-08-07 22:34:57 +07:00
proitlab 0e83137b5a dashboard: hapus TOTAL VEX + perbaiki wrap mobile (V48) ; catatan stats-note dipendekkan (buang perbandingan vexascan) ; formula last_vote 52-pekan (V13/B12) ; image web rebuild + push 2026-08-07 22:04:25 +07:00
15 changed files with 366 additions and 166 deletions

No files matched your search

+8 -8
View File
@@ -6,13 +6,13 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- Node reference script: `node get_voters.js` (no deps, Node 18+).
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) + `pyntelope` (distribute signing) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
- Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot **restricted to the reward window** (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; if 24h have already elapsed since the last claim the window clamps to now (missed-window recovery, no permanent spin-timeout). A claim is only treated as "landed" if Hyperion says `executed` OR `last_claim_time` actually advanced past its pre-send value (`_claim_time_advanced`) — on **both** the send-success and send-exception paths (`_confirm_landed`), because pyntelope's `send()` does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11). Unconfirmed ⇒ silent `retry` (no `claim_runs` row, no success notify) — self-heals when Hyperion indexes the tx on the next poll. `next_window` raises if all producer reads fail (never clamps to now prematurely). Reward is measured **from the claim tx itself** (sum of `vex.bpay`+`vex.vpay` → BP transfers via Hyperion, `reward_from_tx`), falling back to the liquid-balance delta if Hyperion is down; if neither can be read the claim is recorded with fee `pending` and an internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert is sent (never a misleading 0-reward success). 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 payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot **restricted to the reward window** (not stale AND account mature, `last_vote > now−28d` AND `first_seen_at ≤ now−3d`, §V40/V52: new voters (`first_seen` recent/NULL) and stale voters get nothing — returning re-voters with mature first_seen are paid regardless of vote age), fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed.
- Daily reward claim (production): `./venv/bin/python claim_loop.py` — the `claim` service scheduler. `claim.py` computes the claim window as `last_claim_time + 24h` (from the `vexcore` `producers` table) in **UTC-naive time** (`_utcnow()`, matching the chain's UTC `last_claim_time`; the container's local TZ must not shift the window — V49/B13), sleeps until near it, then polls `vexcore::claimrewards` every `CLAIM_RETRY_SECONDS` (default 60) until the chain accepts; if 24h have already elapsed since the last claim the window clamps to now (missed-window recovery, no permanent spin-timeout). A claim is only treated as "landed" if Hyperion says `executed` OR `last_claim_time` actually advanced past its pre-send value (`_claim_time_advanced`) — on **both** the send-success and send-exception paths (`_confirm_landed`), because pyntelope's `send()` does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11), and Hyperion returning `executed: false` for a just-landed-but-not-yet-indexed tx must NOT count as "didn't land" (V50/B14). Unconfirmed ⇒ silent `retry` (no `claim_runs` row, no success notify) — self-heals when Hyperion indexes the tx on the next poll. `next_window` raises if all producer reads fail (never clamps to now prematurely). Reward is measured **from the claim tx itself** (sum of `vex.bpay`+`vex.vpay` → BP transfers via Hyperion, `reward_from_tx`), falling back to the liquid-balance delta if Hyperion is down; if neither can be read the claim is recorded with fee `pending` and an internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert is sent (never a misleading 0-reward success). 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
- Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
- Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container:
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5001`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` twice-weekly at `DISTRIBUTE_WEEKDAY` default `wed,sat` (CSV, multi-day V44) + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42): 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.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42/V52): BARU (new first-time voters, `first_seen_at` within 3 days, own table on top, never paid — maturity is measured from `first_seen_at`, NOT `last_vote` which is week-quantized) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with `VOTE ULANG` badge + legend) then VALID (the paged 50/page list, `staked DESC`, live owner search via `/api/search`); voters older than 31 days are stored by the scan but hidden. Since V42, **REVOTE** (returning voters, `voter_first_seen.first_seen_at ≤ now−3d`) are VALID immediately — they join the main list tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`), no separate band, no maturity wait; since V43 the mint tag shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS` (default 1 day) — after that they're plain VALID (still paid); VALID rows within `VEX_WARN_DAYS` (default 3) of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge (legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH`); only genuinely new voters pass the 3-day maturity in BARU. Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY` (CSV multi-hari V44, default `wed,sat`), `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, `./venv/bin/python test_distribute_loop.py`, `./venv/bin/python test_images.py`, and `./venv/bin/python test_claim.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`/`Dockerfile.claim`) copies every intra-project module its copied modules import. `test_distribute_loop.py` (V37/V44) is pure calendar logic — weekly schedule boundary (multi-day CSV) + one-off first-run. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
@@ -26,11 +26,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- The node sometimes returns `staked` as a string instead of a number — normalize coerces to float (spec §V8).
- The voters table has a sentinel first row (`owner="...........q"`, uint64 0) that must be skipped.
- Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6).
- Freshness filter (spec §V13/V40): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(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.
- Freshness filter (spec §V13/V40): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(weeks since 2000 / 52)` → `last_vote = 2000-01-01 + round(52 × log2(weight / (staked×10000))) weeks` — the chain (`stake2vote`) quantizes the exponent to whole weeks (`int64((now−2000-01-01)/(86400×7))/52.0`), so derived dates land on 7-day boundaries (B12; earlier 365.25-day divisor drifted ~33 days forward for 2026 votes). Since V40 `normalize()` stores **every** BP voter with a derivable `last_vote` — any age; only unverifiable votes (zero/empty weight) are dropped. Banding (BARU/VALID/KADALUARSA), display and payout are computed at query time (dashboard cuts 28/31 days on `last_vote` age; distribution pays `last_vote > now−28d`). **Maturity (3 days) is measured from `first_seen_at`, not `last_vote`** — `last_vote` is week-quantized so a new voter voting Mon–Thu would look 4–6 days old and bypass the wait (B15); V52 uses `voter_first_seen.first_seen_at` (scan-precise to the second) for the new-voter wait. The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`. Since V41, `voter_first_seen.first_seen_at` separates PEMILIH BARU (new, first-seen within the 3-day maturity) from REVOTE (returning re-voters); since V42 REVOTE are VALID immediately (paid + shown in the main list, mint tag), only new voters wait out maturity; since V43 the mint tag lasts only `VEX_REVOTE_TAG_DAYS` (1) and VALID rows nearing the KADALUARSA cutoff (within `VEX_WARN_DAYS`, 3) get a `VOTE ULANG SEGERA` badge — display-only.
- The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of `distribute_payments.amount` where `status='sent'` (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
- Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key.
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {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; a window already ≥24h past clamps to now so a missed claim is caught up, never spin-timeout (B8). `get_table_rows` responses are always `{"rows":[...]}` — parse `data.get('rows')`, never `data[0]` (B7, V39). Landed detection = Hyperion `executed` OR `last_claim_time` advanced past pre-send value, checked on BOTH the send-success and send-exception paths (`_confirm_landed`) because pyntelope's `send()` returns HTTP 500 as a body instead of raising on a chain rejection (V46, B11); `next_window` raises if all producer reads fail so a flaky node never triggers a premature claim. Reward = sum of `vex.bpay`+`vex.vpay` → BP transfers read from the claim tx via Hyperion (`reward_from_tx`), balance-delta as fallback (V33); if neither can be read → fee `pending` + internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert, never a misleading 0-reward success (B10). 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run.
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam; a window already ≥24h past clamps to now so a missed claim is caught up, never spin-timeout (B8). `get_table_rows` responses are always `{"rows":[...]}` — parse `data.get('rows')`, never `data[0]` (B7, V39). Landed detection = Hyperion `executed` OR `last_claim_time` advanced past pre-send value, checked on BOTH the send-success and send-exception paths (`_confirm_landed`) because pyntelope's `send()` returns HTTP 500 as a body instead of raising on a chain rejection (V46, B11); Hyperion `executed: false` on a not-yet-indexed tx still falls back to the `last_claim_time` advance check (V50, B14). `next_window` raises if all producer reads fail so a flaky node never triggers a premature claim. Reward = sum of `vex.bpay`+`vex.vpay` → BP transfers read from the claim tx via Hyperion (`reward_from_tx`), balance-delta as fallback (V33); if neither can be read → fee `pending` + internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert, never a misleading 0-reward success (B10). 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run.
- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr); `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to `https://vexascan.com/transaction/{txid}` — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
- 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.
@@ -40,11 +40,11 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- `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`. 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.
- `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`, di-retry V46) → `next_window` = +24 jam (≥24 jam lewat → clamp ke now, recovery B8; semua pembacaan gagal → raise, ⊥ clamp prematur); tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; deteksi mendarat via `_confirm_landed` di KEDUA jalur (send sukses & exception) = Hyperion `executed` ATAU `last_claim_time` maju dari baseline (V46/B11 — `send()` pyntelope ⊥ raise saat chain menolak, HTTP 500 sbg body); tak terkonfirmasi → retry senyap; reward = isi tx via Hyperion (bpay+vpay → BP, `reward_from_tx`) prioritas, selisih saldo fallback; reward ⊥ terukur → fee `pending` + notif `KLAIM MENDARAT · REWARD TAK TERUKUR` (V33/B10); 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).
- `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V52** (`last_vote > now−28d AND first_seen_at ≤ now−3d` via LEFT JOIN `voter_first_seen` — pemilih baru (`first_seen` baru/NULL) & basi ⊥ dibayar; akun yang sudah matang (`first_seen ≤ now−3d`) dibayar langsung, ⊖ peduli umur vote, V52) → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`, di-retry V46) → `next_window` = +24 jam, math UTC-naive via `_utcnow()` (V49/B13 — ⊥ `datetime.now()` lokal yang geser +TZ → poll prematur) → (≥24 jam lewat → clamp ke now, recovery B8); tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; deteksi mendarat via `_confirm_landed` di KEDUA jalur (send sukses & exception) = Hyperion `executed` ATAU `last_claim_time` maju dari baseline (V46/B11 — `send()` pyntelope ⊥ raise saat chain menolak, HTTP 500 sbg body; Hyperion `executed: false` utk tx baru-mendarat-belum-terindeks juga dicek lewat `_claim_time_advanced`, V50/B14); tak terkonfirmasi → retry senyap; reward = isi tx via Hyperion (bpay+vpay → BP, `reward_from_tx`) prioritas, selisih saldo fallback; reward ⊥ terukur → fee `pending` + notif `KLAIM MENDARAT · REWARD TAK TERUKUR` (V33/B10); 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.
- `telegram.py` — kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`.
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40): 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.
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40/V52): the voters list is split into BARU (top, `PEMILIH BARU · BELUM MATANG`, new accounts by `first_seen_at` within 3 days) and the main list (`voter-list` section, `.reward` cells, V29) where KADALUARSA rows are pinned at the top with amber styling + `VOTE ULANG` badge + legend before the paged VALID rows, which LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column; since V42 the VALID list also holds REVOTE (returning re-voters, `voter_first_seen.first_seen_at ≤ now−3d`) tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS`) — no separate band, no maturity wait; only genuinely new voters sit in BARU tagged `BARU`; since V43 VALID rows within `VEX_WARN_DAYS` of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge + legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH` (display-only); search (`?q=` and `/api/search`) covers all bands and tags each result (`group` = `baru|revote|valid|kadaluarsa` + `expired` + `expiring`). Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard); the `SETIAP ... · HH:00` recurrence line renders only for regular weekly rounds — hidden while the next event is the one-off first-run (`_is_first_run_next`, date ≠ weekly day). A `stats-note` caption states the list is single-vote-to-BP accounts ≥ min stake (V47); account names link to `https://vexascan.com/account/{owner}` in the index list, `/history` pay-list, and live search (`.owner-link`, V51); a promo card (`BY DATABISNISID · VEXWALLET`) links the VexWallet Android app on the index page via the IDRS logo (`static/idrs.png`) + `UNDUH DI PLAY STORE` (V53); BARU rows show a `MATURITY PERIOD` countdown column (`VALID DALAM N HARI`) instead of TOTAL REWARD. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
- `gunicorn.conf.py` — gunicorn production config (bind/workers from `config`, sync worker). `run.sh` — launcher: `./run.sh` = `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` from any cwd.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
- `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.
+13 -5
View File
@@ -87,8 +87,8 @@ V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000)
V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB
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`
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; 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)
V13: `last_vote` = epoch 2000-01-01 + `round(52 × log2(last_vote_weight / (staked×10000)))` PEKAN (Vexanium/EOSIO `stake2vote`: eksponen = `int64((now − epoch)/(86400×7)) / 52.0` → bobot dikuantisasi pekan utuh; jadi tanggal kelipatan 7 hari, 00:00:00; regresi oracle baris nyata `..tg` 1291 pekan → 2024-09-28, `1.crownz` 1314 pekan → 2025-03-08); None bila bobot/stake nol; estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` dibuang di `normalize` (bobot tak bisa diverifikasi → tanggal tak diketahui, ⊥ bisa diklasifikasi); V40: vote basi (> VEX_STALE_DAYS) TIDAK lagi dibuang di scan — SEMUA umur disimpan; banding/tampil/bayar menghitung umur saat query (⊥ peran scan)
V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA; stats = SALDO LIQUID/TOTAL PEMILIH/STAKE TERTINGGI atas semua baris TAMPIL (V40: ⊖ yang tersembunyi > 31 hari); TOTAL VEX dihapus (V48: raw single-vote stake mudah disalahartikan); V40/V42/V43: BARU (`PEMILIH BARU · BELUM MATANG`, new saja) + daftar utama — kadaluarsa menyatu DI ATAS baris VALID (dipin, sorot amber + badge `VOTE ULANG` + legenda `BARIS AMBER = VOTE LEWAT …`), lalu VALID (pager) = matang-baru + REVOTE (tag mint `REVOTE` + legenda `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, V43) + badge amber dashed `VOTE ULANG SEGERA` (V43) pada baris `exp_cut < last_vote ≤ warn_cut` + legenda `TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH`; REVOTE ⊖ lagi bagian sendiri (V42); `/api/search` hasil `{owner,staked,weight,rank,last_vote,total_reward,group,expired,expiring}` — `group` = `baru|revote|valid|kadaluarsa` (weight disimpan & di-API, ⊥ dirender); V47: catatan `stats-note` menyatakan TOTAL PEMILIH = akun suara tunggal ke BP stake ≥ `MIN_STAKED_VEX`; V51: nama akun (index + history pay-list + search live) jadi link `https://vexascan.com/account/{owner}` (`.owner-link`, `target=_blank rel=noopener`) — display-only
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`)
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)
@@ -107,14 +107,18 @@ V30: notifikasi Telegram dua kanal (best-effort): `notify_start` saat run nyata
V31: ∀ image stack (web/scan/dist/claim) → tiap modul .py yang diimpor modul yang di-COPY ikut disalin (⊥ impor intra-proyek yang ⊥ ada di image); oracle `test_images.py` memindai import tiap image → daftar COPY memuat semua impor intra-proyek
V32: klaim reward BP via aksi `vexcore::claimrewards` (owner=TARGET_BP), sign pyntelope `VEX_BP_PRIVATE_KEY`; jendela klaim = `last_claim_time` (tabel `producers`) + 24 jam; `claim_loop.py` tidur sampai mendekati jendela lalu poll tiap `CLAIM_RETRY_SECONDS` (default 60) sampai diterima chain; ⊥ klaim di luar jendela (ditolak chain → retry); setelah sukses jendela dihitung ulang dari `last_claim_time` segar; RECOVERY: bila ≥24 jam sudah lewat sejak last → jendela = `now` (klaim dikejar segera, ⊥ spin-timeout utk jendela yang terlewat / container down); loop tak pernah keluar
V39: respons chain `get_table_rows` SELALU dict `{rows:[...]}` (⊥ list); semua parser memakai `data.get('rows')`; oracle memakai bentuk respons asli (mock `_post_json`, ⊥ mock fungsi parse) agar bentuk tak dikenal/tak terduga tertangkap; `get_currency_balance` memang list string — jangan 'diperbaiki'
V40: band umur vote (dashboard + distribusi, UTC-naive konsisten stempel scan): `age = now − last_vote`; BARU `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
V40: band umur vote (dashboard + distribusi, UTC-naive konsisten stempel scan): `age = now − last_vote`; BARU → bagian sendiri (paling atas), ⊖ reward — V52: BARU = akun baru (`first_seen_at` None/`> now − MATURITY`) dgn `last_vote > now − STALE` (maturity diukur dari `first_seen_at`, ⊖ `last_vote` terkuantisasi pekan); VALID `last_vote > now−STALE` DAN akun matang (`first_seen_at ≤ now−MATURITY`) → bagian utama + pager, satu-satunya penerima reward; KADALUARSA `STALE < age ≤ STALE + VEX_EXPIRED_DAYS` (31) → baris menyatu di daftar utama, DIPIN di atas baris VALID, sorot amber + badge `VOTE ULANG` + legenda; HIDDEN `age > 31` → disimpan scan tapi ⊖ tampil; scan menyimpan semua umur (V13); stats/count/rank/search dibatasi band yang tampil (rank ordinal per band; jendela VALID utk pager); distribusi query `last_vote > now−STALE AND first_seen_at ≤ now−MATURITY` (V52) — pemilih BARU (first_seen baru/NULL) ⊥ dibayar siklus itu (anti-gaming); akun yang sudah terlihat ≥ `VEX_MATURITY_DAYS` lalu (REVOTE) dibayar langsung, ⊖ menunggu matang
V41: pembagian pemilih belum-matang menjadi PEMILIH BARU (new) vs REVOTE (pemilih lama yang revote/perpanjang): `first_seen_at` (tabel `voter_first_seen`, V7) dibandingkan dgn `new_cut = now − MATURITY` — `first_seen > new_cut` (atau NULL/DB lama) → BARU; `first_seen ≤ new_cut` → REVOTE; klasifikasi tampilan/search (V15); cold-start: skan pertama mencatat semua owner sbg first-seen-now → semua BARU tampak new satu siklus, klasifikasi benar mulai siklus revote berikutnya
V42: REVOTE dianggap VALID LANGSUNG (dashboard + distribusi, V41): pemilih dgn `first_seen_at ≤ now − MATURITY` ⊖ perlu masa matang — memenuhi syarat reward begitu `last_vote > now − STALE`; hanya pemilih BARU (first_seen NULL atau `> now − MATURITY`) yang harus lewat masa matang (`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)
V42: REVOTE dianggap VALID LANGSUNG (dashboard + distribusi, V41): pemilih dgn `first_seen_at ≤ now − MATURITY` ⊖ perlu masa matang — memenuhi syarat reward begitu `last_vote > now − STALE`; hanya pemilih BARU (first_seen NULL atau `> now − MATURITY`) yang harus lewat masa matang (V52: dari `first_seen_at`, bukan `last_vote`); jendela VALID dashboard (`_db_rows`/`_count`/rank) = jendela eligibilitas distribusi = `last_vote > now−STALE AND first_seen_at ≤ now−MATURITY` — konsisten; baris REVOTE menyatu di daftar utama dgn tag mint `REVOTE` (⊥ bagian sendiri, V15); `_stats` partisi `n_new + n_valid + n_expired == total_voters` (`n_revote` yang dirender = subset tag 1-hari); distribusi LEFT JOIN `voter_first_seen` (`ensure_first_seen_schema`); oracle test_distribute (rev7 umur-1hr first_seen-60hr → dibayar; new1 umur-1hr → ⊖) + test_dashboard (revoter1 di #rows bertag)
V43: tag & badge display (display-only, ⊥ ubah eligibilitas/payout V40/V42): tag mint `REVOTE` HANYA utk pemilih lama yang revote dalam `VEX_REVOTE_TAG_DAYS` (default 1) terakhir (`last_vote > revote_cut = now − VEX_REVOTE_TAG_DAYS` AND `first_seen ≤ now − MATURITY`) — setelah itu tampil VALID polos (tetap dibayar langsung); badge amber `VOTE ULANG SEGERA` (outline dashed, elemen ke-9) utk baris VALID dgn `expired_cut < last_vote ≤ warn_cut = now − (VEX_STALE_DAYS − VEX_WARN_DAYS)` (default 3 → umur 25..28 hr, overlay band VALID, ⊖ pin/tinting); `_freshness_cutoffs` → 4-tuple `(new_cut, warn_cut, exp_cut, hid_cut)`; `_search_rows` tag `revote` HANYA bila `last_vote > revote_cut` (revoter lebih lama → `valid`), + field `expiring` (main list, `?q=`, `/api/search`); legenda mint `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG · N AKUN` + legenda amber `TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH`; `_stats` 9-tuple (+`n_expiring`, `n_revote` = tag-1-hari utk legenda); oracle test_dashboard (revoter1 `_iso(0)` tetap bertag; expire1 `_iso(26)` staked-1290 → valid + expiring; TOTAL 129; config defaults)
V33: reward klaim diukur PRIORITAS dari isi tx via Hyperion `get_transaction` — jumlah transfer `vex.bpay` + `vex.vpay` → TARGET_BP (`reward_from_tx`; ⊥ selisih saldo yang flaky/stale saat node baca saldo di belakang); fallback selisih saldo (setelah − sebelum, settle delay, retry baca) bila Hyperion tak terjangkau; reward ⊥ terukur keduanya → baris dicatat fee `pending` + notif internal `KLAIM MENDARAT · REWARD TAK TERUKUR` (⊥ sukses-0 menyesatkan, V45); fee = floor(`BP_FEE_PERCENT` × reward) 4 desimal → `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD`, verifikasi txid Hyperion sebelum resend (pola V22); reward ≤ 0 → fee `skipped`; Σfee ≤ reward
V34: siklus klaim selesai ⊥ bila klaim tercatat (`claim_runs`) DAN fee terkirim; fee `pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi
V45: anti false-success klaim: klaim dianggap "mendarat" ⊥ cukup karena `last_claim_time` dalam 24 jam terakhir (heuristik lama — klaim yang DITOLAK sebelum jendela bisa salah dianggap sukses bila ada klaim lama <24 jam); deteksi = Hyperion `executed` ATAU `last_claim_time` benar-benar MAJU dari nilai sebelum kirim (`_claim_time_advanced`, `after > before`; baseline tak terbaca → ⊥ dianggap maju, retry); `next_window` baca `last_claim_time` di-retry (node flaky ⊥ langsung dianggap "belum pernah klaim" → clamp now prematur → percobaan ditolak); reward tak terukur → fee `pending` + notif cek manual, ⊥ `skipped` diam-diam (V33); oracle test_claim (last maju vs tak maju, reward_from_tx parse bpay+vpay, unmeasured → pending+notif)
V46: ⊥ pernah percaya `signed.send()` semata utk deteksi mendarat — pyntelope TIDAK raise saat chain menolak (`Net._request` mengembalikan body HTTP 500 sbg JSON, ⊥ exception), jadi "sukses kirim" bisa berarti DITOLAK (mis. claimrewards sebelum jendela 24 jam) → false success reward 0. Landing dikonfirmasi di KEDUA jalur (send sukses maupun exception) lewat `_confirm_landed` = Hyperion `executed` ATAU `last_claim_time` maju dari baseline; tak terkonfirmasi → `retry` senyap (⊥ catat `claim_runs`, ⊥ notif sukses) — klaim sah yang belum terindeks Hyperion → retry 60s berikutnya terverifikasi (self-healing dalam 2 jam poll). `next_window`: semua pembacaan producer gagal → raise (loop catch-and-sleep), ⊥ clamp now prematur; `now` hanya utk pembacaan sukses tanpa klaim sebelumnya (BP baru). Oracle test_claim: send-normal+verify-False → retry + ⊥ baris + ⊥ notif; send-normal+last-maju → claimed; baca-producer-gagal → raise
V46: ⊥ pernah percaya `signed.send()` semata utk deteksi mendarat — pyntelope TIDAK raise saat chain menolak (`Net._request` mengembalikan body HTTP 500 sbg JSON, ⊥ exception), jadi "sukses kirim" bisa berarti DITOLAK (mis. claimrewards sebelum jendela 24 jam) → false success reward 0. Landing dikonfirmasi di KEDUA jalur (send sukses maupun exception) lewat `_confirm_landed` = Hyperion `executed` ATAU `last_claim_time` maju dari baseline; tak terkonfirmasi → `retry` senyap (⊥ catat `claim_runs`, ⊥ notif sukses) — klaim sah yang belum terindeks Hyperion → retry 60s berikutnya terverifikasi (self-healing dalam 2 jam poll). `next_window`: semua pembacaan producer gagal → raise (loop catch-and-sleep), ⊥ clamp now prematur; `now` hanya utk pembacaan sukses tanpa klaim sebelumnya (BP baru). Oracle test_claim: send-normal+verify-False+last-maju → claimed (V50); send-normal+verify-False+last-tak-maju → retry + ⊥ baris + ⊥ notif; baca-producer-gagal → raise
V49: math jendela klaim memakai `_utcnow()` (UTC-naive, `datetime.now(timezone.utc).replace(tzinfo=None)`) — konsisten dgn `last_claim_time` chain (UTC) & `get_voters`/`dashboard`; `step()`/`poll_claim`/`_try_claim_once` memakai `_utcnow()` (⊥ `datetime.now()` lokal yang bergeser +TZ di container → jendela maju ~7 jam → poll prematur → ditolak chain → retry tanpa henti, B13); oracle test_claim (`_utcnow` tzinfo None; step before window → sleep)
V50: `_confirm_landed` ⊥ langsung menyerah saat Hyperion `executed: false` (verify_txid → False): tx yang BARU SAJA mendarat bisa belum terindeks Hyperion (lag, response `executed: false` utk id yg belum diindeks) — menganggap False sbg "tak mendarat" membuat klaim sukses masuk retry-loop permanen (B14). Landing = Hyperion `executed` ATAU `last_claim_time` maju dari baseline; utk verified False MAUPUN None tetap cek `_claim_time_advanced`; hanya bila itu juga False → retry (klaim ditolak ⊥ memajukan `last_claim_time`). Oracle test_claim: send-normal+verify-False+last-maju → claimed + reward tercatat (V50); send-normal+verify-False+last-tak-maju → retry + ⊥ baris (V46)
V52: maturity pemilih baru diukur dari `first_seen_at` (stempel scan presisi detik, tabel `voter_first_seen`) BUKAN `last_vote` — `last_vote` terkuantisasi PEKAN oleh stake2vote (B12) sehingga voter baru yang vote Senin–Kamis tampak 4–6 hari ⇒ lolos maturity 3-hari tanpa menunggu (B15). Aturan (dashboard + distribusi): BARU = `last_vote > now−STALE` DAN (`first_seen_at` NULL atau `> now−MATURITY`); VALID = `last_vote > now−STALE` DAN `first_seen_at ≤ now−MATURITY` (akun matang, ⊖ perlu vote tua). `_edge_rows`/`_db_rows`/`_count`/`_stats`/`_search_rows` + distribusi memakai ambang `first_seen_at`; `_stats` partisi `n_new + n_valid + n_expired == total_voters`; BARU band kolom MATURITY PERIOD = countdown `VALID DALAM N HARI` (`first_seen + MATURITY`, filter `countdown`; ⊖ TOTAL REWARD yang selalu 0 utk pemilih baru); oracle test_distribute (make_voters_db seed first_seen matang default, make_first_seen REPLACE utk BARU/REVOTE) + test_dashboard (acct### seed first_seen matang; band check MATURITY PERIOD) | V52,V40,V42,V41
V53: kartu promo VEXWALLET di index (display-only): section `.news` tag `BY DATABISNISID`, judul `VEXWALLET · ANDROID`, link logo `static/idrs.png` (`.promo-logo`) + label `UNDUH DI PLAY STORE` → `https://play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet&hl=id` (`target=_blank rel=noopener`); oracle test_dashboard (logo idrs.png + link + tag + label ada di `/`); history ⊖ berubah
V35: kill-switch distribusi `DISTRIBUTE_ENABLED` default false → run nyata (bukan dry-run) no-op: exit 0, ⊥ baca saldo, ⊥ tanda tangan, ⊥ tulis DB, ⊥ notif; `--dry-run` tetap menampilkan rencana (read-only); nilai `true`/`1`/`yes` → normal
V36: notifikasi klaim reward (sukses `notify_claim` & gagal `notify_claim_failure`) → kanal INTERNAL saja, ⊥ pernah ke KOMUNITAS (V30); fee gagal ⊥ spam — satu notif per klaim (saat transisi ke `failed`)
V37: jadwal distribusi MINGGUAN (⊥ harian): `distribute_loop` menunggu `DISTRIBUTE_WEEKDAY` — SATU hari (`sat`) atau CSV beberapa hari (`wed,sat`), nama `mon`..`sun` atau 0-6 (V44: multi-hari, event terdekat di antara hari-hari terdaftar) — jam `DISTRIBUTE_HOUR` (lokal TZ); plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`; default baked `2026-08-17`) yang diambil bila lebih dekat dari mingguan; setelah lewat (atau ⊥ diset) → hanya mingguan; batas dihitung ulang tiap iterasi loop (setelah run → jadwal berikutnya); payout di-tunda bila `DISTRIBUTE_ENABLED=false` (V35) — jadwal tetap maju, run jadi no-op; `distribute_loop.main` baca jadwal via `config` (⊥ `os.getenv` sendiri) agar konsisten dgn dashboard V38
@@ -186,3 +190,7 @@ B8|2026-08-06|setelah jendela klaim lewat (poll 2 jam habis tanpa sukses) `next_
B9|2026-08-07|notif `KLAIM REWARD SUKSES / Reward 0.0000` walau tx klaim TIDAK mendarat: `signed.send()` timeout + Hyperion sempat tak terjangkau → heuristik "last_claim_time dalam 24 jam" (klaim lama masih <24h) menganggap mendarat → reward diukur 0 (δ saldo ⊥ berubah) → fee `skipped` diam-diam + sukses-0|deteksi mendarat = Hyperion `executed` ATAU `last_claim_time` MAJU dari baseline (V45); reward ⊥ terukur → fee `pending` + notif cek manual (V33); `next_window` baca di-retry (⊥ clamp prematur)
B10|2026-08-07|reward klaim diukur dari selisih saldo liquid (setelah−sebelum): saat node baca saldo di belakang/fetch gagal, reward nyata (transfer bpay/vpay) ⊥ tercatat → fee ⊥ dikirim utk siklus itu (fee `skipped` permanen, V34 ⊥ retry skipped)|ukur reward dari isi tx klaim via Hyperion (bpay+vpay → BP) prioritas; selisih saldo jadi fallback; keduanya gagal → fee `pending` + notif (V33/V45)
B11|2026-08-07|notif `[OK] klaim reward 0.0000` STILL terbit walau tx klaim tak mendarat — V45 hanya mengerasankan jalur exception, tapi `signed.send()` pyntelope TIDAK raise saat chain menolak (`Net._request` kembalikan body HTTP 500 sbg JSON): klaim sebelum jendela → HTTP 500 → `send()` normal → `landed=True` di jalur sukses → reward 0 → `[OK]`/`KLAIM REWARD SUKSES` palsu|landing dikonfirmasi di KEDUA jalur via `_confirm_landed` (Hyperion executed ATAU last_claim_time maju dari baseline); tak terkonfirmasi → retry senyap, ⊥ catat/notif (V46); `next_window` baca producer gagal → raise (⊥ clamp prematur)
B12|2026-08-07|`derive_last_vote` pakai `365.25 hari/tahun` tapi Vexanium (`stake2vote`) mengkuantisasi bobot ke PEKAN UTUH (`int64((now−2000)/(86400×7))/52.0`) — drift ~33 hari ke depan utk vote 2026 (365.25 vs 364 hari/eksponen); jendela maturity 3/28/31 hari jadi miring ~sebulan|rekonstruksi = `epoch + round(52×log2(ratio)) pekan` (kelipatan 7 hari); oracle `_fresh_weight` + 2^26 + regresi baris nyata (..tg, 1.crownz) (V13)
B13|2026-08-07|claim service loop `[Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat — retry` tanpa henti ~7 jam sebelum jendela: `step()` memakai `datetime.now()` (TZ container Asia/Jakarta, UTC+7) sedangkan `last_claim_time` chain UTC-naive → `next_window` menganggap jendela 24 jam sudah buka ~7 jam lebih awal → poll tiap 60s → `claimrewards` ditolak chain (HTTP 500 sbg body, pyntelope ⊥ raise) → `_confirm_landed` ⊥ terkonfirmasi → retry; V46 bekerja benar (⊥ sukses-0) tapi tak pernah ada tx mendarat utk dicatat|window math pakai `_utcnow()` (UTC-naive) di `step()`/`poll_claim`/`_try_claim_once` — konsisten dgn `last_claim_time` chain (V49); oracle `_utcnow` tzinfo None + step before-window → sleep
B14|2026-08-07|setelah fix TZ (V49) klaim BENAR-BENAR mendarat (tx `0e61fcb5…`, bpay 466.7587 + vpay 1215.4164 = 1682.1751 VEX) tapi app tetap retry-loop & ⊥ catat reward/fee: `_confirm_landed` menyerah saat `verify_txid` → False (`executed: false`) — Hyperion merespons false utk tx yang baru saja mendarat tapi belum terindeks (lag), padahal `last_claim_time` SUDAH maju; retry berikutnya baca `last_before` yg sudah maju → `_claim_time_advanced` tak pernah deteksi → retry permanen, fee 168.22 VEX utk siklus itu tak terkirim|`_confirm_landed`: verified False MAUPUN None → tetap cek `_claim_time_advanced`; hanya bila itu False → retry (V50); oracle send-normal+verify-False+last-maju → claimed
B15|2026-08-08|maturity 3-hari pemilih BARU ⊥ berfungsi utk vote Senin–Kamis: `last_vote` diturunkan dari bobot yang terkuantisasi PEKAN (stake2vote) → voter baru yg vote tengah pekan tampak 4–6 hari ⇒ lolos `last_vote ≤ now−MATURITY` di hari pertama (anti-gaming tembus); kejadian nyata: eosauthority vote 5 Agt (Rabu) → `last_vote` terderivasi 1 Agt (Sabtu)|maturity diukur dari `first_seen_at` (presisi detik) di dashboard + distribusi: BARU = first_seen baru/NULL; VALID = first_seen matang (V52); oracle test_distribute/test_dashboard seed first_seen matang
+25 -10
View File
@@ -1,7 +1,8 @@
"""Klaim reward BP + transfer fee harian: `vexcore::claimrewards` utk TARGET_BP.
Setiap siklus (V32..V34): tentukan jendela klaim = `last_claim_time` (tabel
`producers`) + 24 jam, tidur sampai mendekatinya, lalu poll `claimrewards`
`producers`) + 24 jam (math UTC-naive via `_utcnow`, konsisten dgn stempel
chain; V49/B13), tidur sampai mendekatinya, lalu poll `claimrewards`
tiap `CLAIM_RETRY_SECONDS` sampai diterima chain. Jendela yang sudah lewat
(≥24 jam sejak last) → klaim dikejar segera (recovery, ⊥ spin-timeout
selamanya). Landing dikonfirmasi via Hyperion `executed` ATAU `last_claim_time`
@@ -22,7 +23,7 @@ Modul logika (⊥ CLI) — `claim_loop.py` yang menjalankan loop tak-berhenti.
"""
import time
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
import db
@@ -37,6 +38,16 @@ SETTLE_SECONDS = 10 # tunggu finalitas sebelum baca saldo setelah klaim
POLL_SPAN_SECONDS = 7200 # batas poll sekali jendela (2 jam)
def _utcnow():
"""UTC-naive `datetime.now()` — konsisten dgn `last_claim_time` chain
(Vexanium mengembalikan UTC) & `get_voters`/`dashboard` (V49).
Memakai waktu lokal (`datetime.now()`, TZ container Asia/Jakarta) untuk
math jendela 24 jam membuat jendela maju ~7 jam lebih awal → poll ditolak
chain → retry tanpa henti (B13)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def _post_json(url, payload, max_retries=3, timeout=30):
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
for attempt in range(max_retries):
@@ -88,7 +99,8 @@ def next_window(now):
Pembacaan `last_claim_time` node flaky di-retry: bila SEMUA pembacaan
gagal/kosong → raise (loop catch-and-sleep, ⊥ clamp ke now prematur yang
memicu percobaan klaim sebelum jendela → ditolak chain). `now` hanya utk
pembacaan yang BENAR-BENAR berhasil tanpa klaim sebelumnya (BP baru)."""
pembacaan yang BENAR-BENAR berhasil tanpa klaim sebelumnya (BP baru).
`now` & `last_claim_time` sama-sama UTC-naive (V49/B13)."""
raw = None
for _ in range(3):
try:
@@ -191,13 +203,16 @@ def _confirm_landed(txid, last_before):
`send()` pyntelope TIDAK raise saat chain menolak (HTTP 500 dikembalikan
sbg body) — jadi "sukses kirim" ⊥ cukup (V46/B11). Mendarat hanya bila
Hyperion `executed` ATAU `last_claim_time` MAJU dari baseline sebelum kirim.
Hyperion tak terjangkau (None) → cek `_claim_time_advanced`; False → retry
senyap (⊥ catat baris / sukses-0)."""
Hyperion `False` (bukan hanya None) ⊥ langsung dianggap tak mendarat:
Hyperion bisa merespons `executed: false` utk tx yang BARU SAJA mendarat
tapi belum terindeks (lag) — padahal klaim sukses. Jadi utk verified False
maupun None, tetap cek `_claim_time_advanced`; hanya bila itu pun False →
retry senyap (⊥ catat baris / sukses-0). Klaim yang ditolak ⊥ memajukan
`last_claim_time` → tetap retry (V50/B14)."""
verified = dist.verify_txid(txid)
if verified is True:
return True
if verified is False:
return False
try:
last_after = fetch_producer_last_claim()
except Exception:
@@ -241,7 +256,7 @@ def _try_claim_once():
'— retry (⊥ catat sukses)', flush=True)
return 'retry'
reward = _measure_reward(txid, before)
now = datetime.now()
now = _utcnow()
now_iso = now.isoformat(timespec='seconds')
txid_label = txid[:16] + '...' if txid else '?'
if reward is None:
@@ -267,7 +282,7 @@ def poll_claim(deadline):
Return `claimed` | `timeout` (deadline lewat — jadwal dihitung ulang).
"""
while datetime.now() < deadline:
while _utcnow() < deadline:
status = _try_claim_once()
if status == 'claimed':
return 'claimed'
@@ -315,7 +330,7 @@ def step():
claim = db.pending_claim_fee()
if claim is not None:
return 'fee_sent' if _send_fee(claim) else 'fee_failed'
now = datetime.now()
now = _utcnow()
window = next_window(now)
margin = timedelta(seconds=CLAIM_RETRY_SECONDS)
if now < window - margin:
+90 -70
View File
@@ -6,6 +6,7 @@ API databisnis dengan cache TTL singkat (V16). Styling per `DESIGN.md`.
Konfigurasi dari env/`.env`.
"""
import math
import time
from datetime import datetime, timedelta, timezone
@@ -78,6 +79,23 @@ def shortdate(value):
return '—'
@app.template_filter('countdown')
def countdown(value, now=None):
"""'VALID DALAM N HARI' utk pemilih BARU (V52).
Maturity = `first_seen_at` + `VEX_MATURITY_DAYS`. Nilai NULL/legacy →
None (⊥ cue). Dihitung utk hari penuh, minimal 1 hari."""
if not value:
return None
try:
valid_at = datetime.fromisoformat(value) + timedelta(days=VEX_MATURITY_DAYS)
except ValueError:
return None
now = now or datetime.now(timezone.utc).replace(tzinfo=None)
days = max(1, math.ceil((valid_at - now).total_seconds() / 86400))
return f'VALID DALAM {days} HARI'
# ————— Jadwal distribusi berikutnya (V38) —————
# Banner BERITA di dashboard menampilkan run distribusi berikutnya. Logika
# boundary sengaja mirror `distribute_loop.next_boundary` (⊥ impor lintas-image,
@@ -251,14 +269,16 @@ def _reward_join():
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.
"""V40/V43/V52: empat ambang umur (UTC-naive, konsisten dgn stempel
`last_vote` hasil scan & `first_seen_at`). Stempel lebih besar = lebih baru.
new_cut = now − VEX_MATURITY_DAYS: `last_vote > new_cut` → BARU
warn_cut = now − (STALE − WARN): `warn_cut < last_vote ≤ new_cut`
new_cut = now − VEX_MATURITY_DAYS: maturity akun — `first_seen_at
≤ new_cut` → matang/VALID;
`> new_cut`/NULL → BARU (V52)
warn_cut = now − (STALE − WARN): `expired_cut < last_vote ≤ warn_cut`
→ VALID + badge `VOTE ULANG SEGERA`
expired_cut = now − VEX_STALE_DAYS: `expired_cut < last_vote ≤ new_cut`
→ VALID (mature+segar)
expired_cut = now − VEX_STALE_DAYS: `last_vote > expired_cut` → belum
basi (VALID bila akun matang)
hidden_cut = now − (STALE+EXPIRED): `hidden_cut < last_vote ≤ expired_cut`
→ KADALUARSA; `≤ hidden_cut` → sembunyi
@@ -288,14 +308,15 @@ def _revote_cut(now=None):
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.
"""V40/V52: baris di luar band VALID — BARU (pemilih BARU) dan KADALUARSA
(`hidden < last_vote ≤ expired`). Jendela kecil → selalu tampil penuh
(⊥ pager). Rank = ordinal dalam band.
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.
V52: BARU = akun yang baru pertama kali terlihat (`first_seen` None/DB lama
atau `> new_cut`) dengan `last_vote > exp_cut` — maturity 3 hari diukur
dari `first_seen_at` (presisi detik), BUKAN `last_vote` yang terkuantisasi
pekan (⊥ B15: voter baru Senin–Kamis tampak matang). Akun yang sudah
pernah terlihat (`first_seen ≤ new_cut`) → band VALID (`_db_rows`).
"""
new_cut, _warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
rw_join, rw_params = _reward_join()
@@ -310,29 +331,30 @@ def _edge_rows():
)
new_rows, expired_rows = [], []
for r in db.query(sql, rw_params + [hid_cut]):
if r[2] > new_cut:
if r[2] > exp_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
# V52: BARU membawa first_seen_at (elemen ke-4) utk countdown
# `VALID DALAM N HARI` di template.
new_rows.append(r[:4] + (fs,))
else:
expired_rows.append(r[:4])
return new_rows, expired_rows
def _db_rows(page, q):
"""V10/V11/V40/V42: satu halaman band VALID (mature+segar + REVOTE
yang ⊥ tunggu matang), rank dalam band.
"""V10/V11/V40/V52: satu halaman band VALID (akun matang, `first_seen ≤
now−MATURITY`), rank dalam band.
Rank via correlated-count (bukan ROW_NUMBER): SQLite mendorong WHERE masuk ke
subquery sebelum window function, sehingga ROW_NUMBER jadi rank-dalam-filter.
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.
V52: jendela VALID = `last_vote > now−STALE AND first_seen ≤ now−MATURITY`
(maturity diukur dari `first_seen_at`, presisi detik — ⊥ `last_vote` yang
terkuantisasi pekan, B15); pemilih BARU (first_seen None/`> new_cut`)
→ band BARU, ⊖ di sini.
V43: flag revote (elemen ke-8) hanya utk revote < `VEX_REVOTE_TAG_DAYS`
(`last_vote > revote_cut`); elemen ke-9 = flag `VOTE ULANG SEGERA`
(`expired_cut < last_vote ≤ warn_cut`).
@@ -342,18 +364,16 @@ def _db_rows(page, q):
revote_cut = _revote_cut()
where, params = _filter_sql(q, 'v.')
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 + ')'
mature = ('fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s')
band = 'v.last_vote > %s AND ' + mature
combined = ('WHERE ' + band
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
rev2 = ('v2.last_vote <= %s OR '
'(fs2.first_seen_at IS NOT NULL AND fs2.first_seen_at <= %s)')
mature2 = ('fs2.first_seen_at IS NOT NULL AND fs2.first_seen_at <= %s')
sql = (
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
' (SELECT COUNT(*) FROM voters v2 '
' LEFT JOIN voter_first_seen fs2 ON fs2.owner = v2.owner '
' WHERE v2.last_vote > %s AND (' + rev2 + ') '
' WHERE v2.last_vote > %s AND ' + mature2 + ' '
' AND (v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner))) AS rn, '
' v.last_vote, '
@@ -364,8 +384,8 @@ def _db_rows(page, q):
+ rw_join + ' ' + combined + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
)
rows = db.query(sql, [exp_cut, new_cut, new_cut] + rw_params
+ [exp_cut, new_cut, new_cut]
rows = db.query(sql, [exp_cut, new_cut] + rw_params
+ [exp_cut, new_cut]
+ params + [PAGE_SIZE, offset])
return [
r + ((1,) if (r[7] is not None and r[7] <= new_cut
@@ -376,41 +396,40 @@ def _db_rows(page, q):
def _count(q):
"""V11/V40/V42: banyak baris band VALID cocok filter (utk pager).
V43: tag REVOTE 1-hari & badge peringatan ⊥ ubah cakupan → tetap V42."""
"""V11/V40/V52: banyak baris band VALID cocok filter (utk pager).
V52: jendela VALID = `last_vote > now−STALE AND first_seen ≤ now−MATURITY`
(maturity dari `first_seen_at` — B15); tag REVOTE 1-hari & badge peringatan
⊥ ubah cakupan."""
new_cut, _warn_cut, exp_cut, _hid_cut = _freshness_cutoffs()
where, params = _filter_sql(q, 'v.')
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 + ')'
mature = ('fs.first_seen_at IS NOT NULL AND fs.first_seen_at <= %s')
combined = ('WHERE v.last_vote > %s AND ' + mature
+ ((' AND ' + where[len('WHERE '):]) if where else ''))
return db.queryone(
'SELECT COUNT(*) FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner ' + combined,
[exp_cut, new_cut, new_cut] + params)[0]
[exp_cut, new_cut] + params)[0]
def _stats():
"""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`.
V40/V52: cakupan = semua baris TAMPIL (BARU + VALID + KADALUARSA,
⊖ yang tersembunyi > 31 hari). V52: maturity dari `first_seen_at`
(presisi detik, ⊥ `last_vote` terkuantisasi pekan — B15): BARU = akun
baru (`first_seen` None/`> new_cut`) dgn `last_vote > exp_cut`; VALID =
akun matang (`first_seen ≤ new_cut`) dgn `last_vote > exp_cut`.
Invariant: n_new + n_valid + n_expired == total_voters (partisi semua
baris tampil). V43: `n_revote` (legenda mint) = subset VALID yg revote
< `VEX_REVOTE_TAG_DAYS` (`last_vote > revote_cut`); `n_expiring` =
subset VALID dgn `exp_cut < last_vote ≤ warn_cut` utk badge peringatan.
"""
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
row = db.queryone(
'SELECT COUNT(*), COALESCE(SUM(staked), 0), COALESCE(MAX(staked), 0) '
'SELECT COUNT(*), COALESCE(MAX(staked), 0) '
'FROM voters WHERE last_vote > %s', [hid_cut])
total_voters, total_staked, top_staked = row
total_voters, top_staked = row
counts = db.queryone(
'SELECT '
' COALESCE(SUM(CASE WHEN v.last_vote > %s '
@@ -421,38 +440,42 @@ def _stats():
' 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) '
' COALESCE(SUM(CASE WHEN v.last_vote > %s '
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s '
' THEN 1 ELSE 0 END), 0), '
' COALESCE(SUM(CASE WHEN v.last_vote > %s AND v.last_vote <= %s '
' THEN 1 ELSE 0 END), 0) '
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s THEN 1 ELSE 0 END), 0) '
'FROM voters v '
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
'WHERE v.last_vote > %s',
[new_cut, new_cut, new_cut, revote_cut,
[exp_cut, new_cut,
new_cut, revote_cut,
hid_cut, exp_cut,
exp_cut, new_cut, new_cut,
exp_cut, warn_cut,
exp_cut, new_cut,
exp_cut, warn_cut, new_cut,
hid_cut],
)
n_new, n_revote, n_expired, n_valid, n_expiring = counts
scanned = db.queryone(
'SELECT COALESCE(MAX(scanned_at), \'\') FROM voters')[0]
return (total_voters, total_staked, top_staked, scanned,
return (total_voters, 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.
"""V11/V40/V52: pencarian owner (substring) di SEMUA band tampil.
Kembalikan daftar dict bertag `group` ('baru'|'revote'|'valid'|
'kadaluarsa') + `expired` + rank ordinal dalam kelompok. Dipakai
`api_search` (JSON) dan mode `?q=` tanpa-JS agar hasilnya konsisten.
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`.
V52: BARU = akun baru (`first_seen` None/`> new_cut`) dgn `last_vote >
exp_cut`; VALID = akun matang (`first_seen ≤ new_cut`) dgn `last_vote >
exp_cut`. V43: tag `revote` HANYA utk akun matang yg revote <
`VEX_REVOTE_TAG_DAYS` (`last_vote > revote_cut`); revote lebih lama →
`valid` polos. Baris juga diberi `expiring` (`VOTE ULANG SEGERA`) saat
`exp_cut < last_vote ≤ warn_cut`.
"""
new_cut, warn_cut, exp_cut, hid_cut = _freshness_cutoffs()
revote_cut = _revote_cut()
@@ -481,14 +504,12 @@ def _search_rows(q, cap):
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
is_mature = fs is not None and fs <= new_cut
tag_revote = is_mature and r[3] > revote_cut
expiring = exp_cut < r[3] <= warn_cut
if r[3] > new_cut:
if r[3] > exp_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 ('valid' if is_mature else 'baru'))
else:
group = 'kadaluarsa'
buckets[group] += 1
@@ -510,7 +531,7 @@ def index():
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
_ensure_dist_schema()
_ensure_first_seen_schema()
(total_voters, total_staked, top_staked, scanned,
(total_voters, top_staked, scanned,
n_new, n_revote, n_expired, n_valid, n_expiring) = _stats()
liquid_vex = _get_liquid_vex()
@@ -539,7 +560,6 @@ def index():
page=page,
total_pages=total_pages,
total_voters=total_voters,
total_staked=total_staked,
top_staked=top_staked,
liquid_vex=liquid_vex,
scanned=scanned,
+12 -10
View File
@@ -29,12 +29,15 @@ 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."""
"""V40/V52: jendela reward = `last_vote > now−STALE` (⊥ basi) DAN akun
MATANG (`first_seen_at ≤ now−MATURITY`).
V52: maturity diukur dari `first_seen_at` (stempel scan presisi detik),
BUKAN `last_vote` yang terkuantisasi pekan (stake2vote) — voter baru yang
vote Senin–Kamis tampak 4–6 hari ⇒ lolos maturity 3-hari tanpa menunggu
(B15). Pemilih BARU (`first_seen` NULL/`> now−MATURITY`) ⊖ dibayar; akun
yang sudah terlihat ≥ `VEX_MATURITY_DAYS` lalu (REVOTE) dibayar langsung.
"""
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')
@@ -149,10 +152,9 @@ def run_distribution(dry_run=False):
'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])
' AND fs.first_seen_at IS NOT NULL '
' AND fs.first_seen_at <= %s',
[stale_cutoff, mature_cutoff])
if not voters:
print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True)
return 0
+9 -5
View File
@@ -28,7 +28,8 @@ LIMIT = 500
# Ambang-batas nama akun "0" (kodifikasi uint64 0) pada tabel voters
ZERO_OWNER = '...........q'
# Skala stake (×10000) dan epoch bobot suara Vexanium (1 Jan 2000)
# Skala stake (×10000) dan epoch bobot suara Vexanium (1 Jan 2000,
# block_timestamp_epoch). Eksponen bobot = PEKAN utuh sejak epoch ÷ 52.
VEX_SCALE = 10000
WEIGHT_EPOCH = datetime(2000, 1, 1)
@@ -36,8 +37,11 @@ WEIGHT_EPOCH = datetime(2000, 1, 1)
def derive_last_vote(weight, staked_vex):
"""V13: perkiraan waktu revote terakhir dari rasio bobot/stake.
Vexanium: last_vote_weight = staked_raw × 2^(tahun sejak 2000).
Maka t = log2(last_vote_weight / staked_raw) → tanggal revote.
Vexanium (sistem EOSIO): `last_vote_weight = staked_raw × 2^(pekan/52)`
— eksponen = `int64((now − 2000-01-01) / (86400×7)) / 52.0`, jadi bobot
dikuantisasi ke PEKAN UTUH sejak epoch 2000 (stake2vote). Maka
`t_pekan = round(52 × log2(last_vote_weight / staked_raw))` → tanggal
revote = epoch + t_pekan pekan (kelipatan 7 hari, 00:00:00).
Kembalikan None bila tak bisa dihitung (bobot/stake nol atau tak valid).
"""
staked_raw = staked_vex * VEX_SCALE
@@ -49,8 +53,8 @@ def derive_last_vote(weight, staked_vex):
return None
if ratio <= 0:
return None
t_years = math.log(ratio) / math.log(2)
return (WEIGHT_EPOCH + timedelta(days=t_years * 365.25)).isoformat(timespec='seconds')
t_weeks = round(52 * math.log(ratio) / math.log(2))
return (WEIGHT_EPOCH + timedelta(weeks=t_weeks)).isoformat(timespec='seconds')
def fetch_with_retry(payload, max_retries=3, timeout=30):
+3 -1
View File
@@ -91,7 +91,9 @@
'<span class="warn-badge">VOTE ULANG SEGERA</span>' : '';
return '<div class="list-row' + (r.expired ? ' expired' : '') + '">' +
'<span class="rank">' + r.rank + '</span>' +
'<span class="owner">' + escapeHtml(r.owner) + tag + warn + badge + '</span>' +
'<span class="owner"><a class="owner-link" href="https://vexascan.com/account/' +
encodeURIComponent(r.owner) + '" target="_blank" rel="noopener">' +
escapeHtml(r.owner) + '</a>' + tag + warn + badge + '</span>' +
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' +
'<span class="reward" data-label="TOTAL REWARD (VEX)">' + vex(r.total_reward) + '</span>' +
'<span class="vote-date" data-label="VOTE TERAKHIR">' + shortdate(r.last_vote) + '</span>' +
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+54 -8
View File
@@ -147,11 +147,17 @@ body {
/* ————— stats band ————— */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
border-top: 1px solid var(--hairline);
border-bottom: 1px solid var(--hairline);
margin: 0 var(--sp-lg) var(--sp-xl);
}
.stats-note {
margin: calc(-1 * var(--sp-md)) var(--sp-lg) var(--sp-xl);
color: var(--muted-soft);
font-size: 10px;
letter-spacing: 1.5px;
}
.spec-cell {
padding: var(--sp-lg) var(--sp-md);
display: flex;
@@ -234,10 +240,12 @@ button.pill {
grid-template-columns: 200px 200px 120px 1fr;
}
.voter-list .list-header,
.voter-list .list-row,
.voter-list .list-row {
grid-template-columns: 72px repeat(4, 1fr);
}
.band-list .list-header,
.band-list .list-row {
grid-template-columns: 72px repeat(4, 1fr);
grid-template-columns: 72px repeat(3, 1fr);
}
.list-header {
font-family: var(--font-mono);
@@ -248,6 +256,7 @@ button.pill {
padding-top: var(--sp-md);
}
.list-row:nth-child(odd) { background: var(--surface-soft); }
.list-row > * { min-width: 0; }
.rank,
.vote-date {
font-family: var(--font-mono);
@@ -260,8 +269,42 @@ button.pill {
font-size: 20px;
letter-spacing: 1px;
color: var(--on-dark);
overflow-wrap: anywhere;
overflow-wrap: break-word;
}
.owner-link {
color: inherit;
text-decoration: underline;
text-underline-offset: 3px;
}
.owner-link:hover { color: var(--link); }
.maturity {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 1.5px;
text-align: center;
color: var(--muted);
}
.promo {
display: inline-flex;
align-items: center;
gap: var(--sp-md);
text-decoration: none;
color: var(--on-dark);
}
.promo-logo {
width: 56px;
height: 56px;
border-radius: 12px;
border: 1px solid var(--hairline-strong);
}
.promo-cta {
font-family: var(--font-mono);
font-size: 14px;
letter-spacing: 2.5px;
text-transform: uppercase;
color: var(--on-dark);
}
.promo:hover .promo-cta { color: var(--link); }
/* ————— band BARU / KADALUARSA (V40) ————— */
.band-list { margin-top: var(--sp-xl); }
@@ -417,9 +460,9 @@ button.pill {
.list-header,
.list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
.voter-list .list-header,
.voter-list .list-row,
.voter-list .list-row { grid-template-columns: 56px 1fr 160px 1fr 1fr; }
.band-list .list-header,
.band-list .list-row { grid-template-columns: 56px 1fr 160px 1fr 1fr; }
.band-list .list-row { grid-template-columns: 56px 1fr 160px 1fr; }
.pay-list .list-header,
.pay-list .list-row { grid-template-columns: 180px 180px 110px 1fr; }
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
@@ -439,13 +482,16 @@ button.pill {
.search .pill { justify-content: center; width: 100%; }
.list-header { display: none; }
.list-row {
grid-template-columns: 48px 1fr;
.voter-list .list-row,
.band-list .list-row {
grid-template-columns: 48px minmax(0, 1fr);
gap: var(--sp-xxs) var(--sp-sm);
padding: var(--sp-lg) var(--sp-sm);
}
.list-row .rank { grid-row: 1 / span 3; grid-column: 1; align-self: start; padding-top: 6px; }
.list-row .owner { grid-column: 2; font-size: 18px; }
.list-row > * { min-width: 0; }
.list-row .owner { overflow-wrap: break-word; }
.list-row .stake,
.list-row .vote-date,
.list-row .txid,
+2 -2
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=9) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=10) }}">
</head>
<body>
<header class="top-nav">
@@ -71,7 +71,7 @@
<div class="list-rows">
{% for pay in payments %}
<div class="list-row">
<span class="owner">{{ pay[2] }}</span>
<span class="owner"><a class="owner-link" href="https://vexascan.com/account/{{ pay[2] }}" target="_blank" rel="noopener">{{ pay[2] }}</a></span>
<span class="stake" data-label="JUMLAH">{{ pay[3]|vex }}</span>
<span class="vote-date" data-label="STATUS">{{ pay[5]|upper }}</span>
<span class="owner txid" data-label="TXID">{% if pay[4] %}<a href="https://vexascan.com/transaction/{{ pay[4] }}" target="_blank" rel="noopener">{{ pay[4] }}</a>{% else %}—{% endif %}</span>
+19 -14
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=14) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=20) }}">
</head>
<body>
<header class="top-nav">
@@ -32,6 +32,15 @@
{% endif %}
</section>
<section class="news">
<span class="news-tag">BY DATABISNISID</span>
<h2 class="news-title">VEXWALLET · ANDROID</h2>
<a class="promo" href="https://play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet&hl=id" target="_blank" rel="noopener">
<img class="promo-logo" src="{{ url_for('static', filename='idrs.png') }}" alt="IDRS">
<span class="promo-cta">UNDUH DI PLAY STORE</span>
</a>
</section>
<section class="stats">
<div class="spec-cell">
<span class="cell-value">{{ liquid_vex|vex if liquid_vex is not none else '—' }}</span>
@@ -41,16 +50,14 @@
<span class="cell-value">{{ total_voters }}</span>
<span class="cell-label">TOTAL PEMILIH</span>
</div>
<div class="spec-cell">
<span class="cell-value">{{ total_staked|vex }}</span>
<span class="cell-label">TOTAL VEX</span>
</div>
<div class="spec-cell">
<span class="cell-value">{{ top_staked|vex }}</span>
<span class="cell-label">STAKE TERTINGGI</span>
</div>
</section>
<p class="caption stats-note">DAFTAR INI = AKUN SUARA TUNGGAL KE {{ bp }} · STAKE ≥ 1.000 VEX</p>
<form class="search" method="get" action="{{ url_for('index') }}">
<input class="search-input" type="search" name="q" id="search-input"
value="{{ q }}" placeholder="CARI AKUN..." autocomplete="off">
@@ -77,7 +84,7 @@
{% 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="owner"><a class="owner-link" href="https://vexascan.com/account/{{ r.owner }}" target="_blank" rel="noopener">{{ r.owner }}</a> <span class="group-tag tag-{{ r.group }}">{{ {'baru': 'BARU', 'revote': 'REVOTE', 'valid': 'VALID', 'kadaluarsa': 'KADALUARSA'}[r.group] }}</span>{% if r.expiring %}<span class="warn-badge">VOTE ULANG SEGERA</span>{% endif %}{% if r.expired %}<span class="expired-badge">VOTE ULANG</span>{% endif %}</span>
<span class="stake" data-label="STAKE (VEX)">{{ 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>
@@ -97,17 +104,15 @@
<span>RANK</span>
<span>AKUN</span>
<span>STAKE (VEX)</span>
<span>TOTAL REWARD (VEX)</span>
<span>VOTE TERAKHIR</span>
<span>MATURITY PERIOD</span>
</div>
<div class="list-rows">
{% for row in new_rows %}
<div class="list-row">
<span class="rank">{{ loop.index }}</span>
<span class="owner">{{ row[0] }} <span class="group-tag tag-baru">BARU</span></span>
<span class="owner"><a class="owner-link" href="https://vexascan.com/account/{{ row[0] }}" target="_blank" rel="noopener">{{ row[0] }}</a> <span class="group-tag tag-baru">BARU</span></span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[3]|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[2]|shortdate }}</span>
<span class="maturity" data-label="MATURITY PERIOD">{{ row[4]|countdown }}</span>
</div>
{% endfor %}
</div>
@@ -136,7 +141,7 @@
{% 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="owner"><a class="owner-link" href="https://vexascan.com/account/{{ row[0] }}" target="_blank" rel="noopener">{{ row[0] }}</a> <span class="expired-badge">VOTE ULANG</span></span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[3]|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[2]|shortdate }}</span>
@@ -145,14 +150,14 @@
{% for row in rows %}
<div class="list-row">
<span class="rank">{{ row[4] + expired_count }}</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="owner"><a class="owner-link" href="https://vexascan.com/account/{{ row[0] }}" target="_blank" rel="noopener">{{ row[0] }}</a>{% if row[8] %} <span class="group-tag tag-revote">REVOTE</span>{% endif %}{% if row[9] %} <span class="warn-badge">VOTE ULANG SEGERA</span>{% endif %}</span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[6]|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[5]|shortdate }}</span>
</div>
{% else %}
{% if not expired_rows %}
<div class="empty">BELUM ADA DATA — JALANKAN get_voters.py</div>
<div class="empty">BELUM ADA PEMILIH YANG VALID</div>
{% endif %}
{% endfor %}
</div>
+50 -14
View File
@@ -123,10 +123,22 @@ def main():
claim.next_window(datetime(2026, 8, 6, 12, 0, 0))
== datetime(2026, 8, 6, 12, 0, 0))
# ————— V49: window math UTC-naive (konsisten dgn last_claim_time chain) —————
now_utc = claim._utcnow()
check('V49 _utcnow UTC-naive (⊥ tzinfo lokal)',
now_utc.tzinfo is None)
# step dengan now jauh sebelum last+24h → sleep (⊥ poll prematur)
fresh_db('prewindow')
claim.fetch_producer_last_claim = lambda: (
claim._utcnow() - timedelta(hours=12)
).strftime('%Y-%m-%dT%H:%M:%S')
check('V49 before window → sleep (⊥ poll prematur)',
claim.step() == 'sleep')
# ————— V32/V33: siklus penuh — klaim → catat → fee terkirim —————
fresh_db('cycle')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('100.0000'), Decimal('110.0004')])
dist.fetch_balance = lambda: next(balances)
@@ -151,7 +163,7 @@ def main():
check('V34 fee terkirim → fee_sent', r2 == 'fee_sent')
check('V33 fee ke wallet bpdbsjasprod', memos[-1][0] == 'bpdbsjasprod')
check('V33 memo BP FEE + tanggal',
memos[-1][2] == f'BP FEE {datetime.now().strftime("%Y-%m-%d")}')
memos[-1][2] == f'BP FEE {claim._utcnow().strftime("%Y-%m-%d")}')
row = last_claim_row()
check('V34 fee ditandai sent + txid',
row[5] == 'sent' and row[6] == 'txfee')
@@ -174,7 +186,7 @@ def main():
# ————— V33: reward 0 → fee skipped; step lanjut ke jadwal —————
fresh_db('zero')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('100.0000'), Decimal('100.0000')])
dist.fetch_balance = lambda: next(balances)
@@ -185,14 +197,14 @@ def main():
check('V33 fee 0 → skipped', row[4] == 0.0 and row[5] == 'skipped')
# pending_claim_fee ⊥ memuat fee 0 → step lanjut ke jadwal (sleep)
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=20)
claim._utcnow() - timedelta(hours=20)
).strftime('%Y-%m-%dT%H:%M:%S')
check('V34 ⊥ fee pending → jadwal (sleep)', claim.step() == 'sleep')
# ————— V34: klaim timeout tapi mendarat (verify True) → diukur —————
fresh_db('landed')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('50.0000'), Decimal('60.0000')])
dist.fetch_balance = lambda: next(balances)
@@ -211,9 +223,9 @@ def main():
def advancing_last():
last_state['n'] += 1
if last_state['n'] == 1: # baseline sebelum kirim
return (datetime.now() - timedelta(hours=2)
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (datetime.now() - timedelta(hours=1)
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S') # maju → mendarat
claim.fetch_producer_last_claim = advancing_last
@@ -230,7 +242,7 @@ def main():
# ————— V45: verify None + last TIDAK maju → retry (⊥ false success) —————
fresh_db('stale')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=1)
claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S')
dist.fetch_balance = lambda: Decimal('100.0000')
claim.build_claim_action = lambda: FakeSigned(
@@ -247,7 +259,7 @@ def main():
# error; landing harus diverifikasi ulang → tak mendarat → retry.
fresh_db('rejectsilent')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
dist.fetch_balance = lambda: Decimal('100.0000')
claim.build_claim_action = lambda: FakeSigned('txR2') # send ⊥ raise
@@ -270,9 +282,9 @@ def main():
def advancing_success():
last_s['n'] += 1
if last_s['n'] == 1:
return (datetime.now() - timedelta(hours=2)
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (datetime.now() - timedelta(hours=1)
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S')
claim.fetch_producer_last_claim = advancing_success
@@ -285,10 +297,34 @@ def main():
check('V46 reward terukur',
last_claim_row()[3] == 2.0)
# ————— V50: Hyperion `False` (lag indeks) tapi last_claim_time MAJU —————
# tx BARU SAJA mendarat belum terindeks Hyperion → verify False (⊥ None);
# landing tetap dideteksi via kemajuan `last_claim_time` (⊥ retry-loop).
fresh_db('lagindex')
last_l = {'n': 0}
def advancing_lag():
last_l['n'] += 1
if last_l['n'] == 1:
return (claim._utcnow() - timedelta(hours=2)
).strftime('%Y-%m-%dT%H:%M:%S')
return (claim._utcnow() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S')
claim.fetch_producer_last_claim = advancing_lag
balances = iter([Decimal('20.0000'), Decimal('25.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned('txR4') # send ⊥ raise
dist.verify_txid = lambda txid: False # Hyperion belum indeks (lag)
check('V50 verify False + last MAJU → claimed (⊥ retry-loop)',
claim._try_claim_once() == 'claimed')
check('V50 reward terukur + tercatat',
last_claim_row()[3] == 5.0 and last_claim_row()[2] == 'txR4')
# ————— V45: reward dari isi tx (bpay+vpay) → prioritas —————
fresh_db('txreward')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
dist.fetch_balance = lambda: Decimal('100.0000') # ⊥ dipakai
claim.build_claim_action = lambda: FakeSigned('txR')
@@ -336,7 +372,7 @@ def main():
# ————— V45: reward tak terukur (tx gagal + delta gagal) → fee pending —————
fresh_db('unmeasured')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
claim._utcnow() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
claim.reward_from_tx = lambda txid: None
bal_seq = iter([Decimal('100.0000'), None, None, None]) # before ok, after ⊥
@@ -371,7 +407,7 @@ def main():
check('V32 klaim ditolak → retry',
claim._try_claim_once() == 'retry')
check('V32 deadline lewat → timeout',
claim.poll_claim(datetime.now() - timedelta(seconds=1))
claim.poll_claim(claim._utcnow() - timedelta(seconds=1))
== 'timeout')
# ————— resume: fee failed dari DB → step mengirimnya —————
+41 -6
View File
@@ -67,16 +67,20 @@ def main():
# 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).
# utk first_seen lama, ⊥ sekedar first_seen ada). expire1 ber-first_seen
# lama → pemilih matang (V43: badge peringatan tetap tampil padanya).
# V52: semua acct### di-seed first_seen LAMA (matang) agar tetap di band
# VALID (maturity diukur dari first_seen_at, ⊥ last_vote terkuantisasi).
conn = sqlite3.connect(db)
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen '
'(owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
fs_rows = [('newacct1', _iso(1)), ('revoter1', _iso(60)),
('acct001', _iso(1)), ('expire1', _iso(60))]
fs_rows += [(f'acct{i:03d}', _iso(60)) for i in range(1, 126)]
conn.executemany(
'INSERT OR REPLACE INTO voter_first_seen (owner, first_seen_at) '
'VALUES (?, ?)',
[('newacct1', _iso(1)), ('revoter1', _iso(60)),
('acct001', _iso(1))],
fs_rows,
)
conn.commit()
conn.close()
@@ -107,15 +111,28 @@ def main():
html = resp.get_data(as_text=True)
check('V10 judul halaman', 'DAFTAR PEMILIH' in html)
check('V13 kolom VOTE TERAKHIR', 'VOTE TERAKHIR' in html)
check('V51 akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' in html
and 'target="_blank"' in html and 'owner-link' in html)
check('V15 kolom BOBOT SUARA tak tampil', 'BOBOT SUARA' not in html)
check('V15 tak ada sel .weight', 'class="weight"' not in html)
check('V15 tak ada penanda basi', 'stale-mark' not in html and 'BASI' not in html)
check('V16 4 sel stats (3 pemilih + saldo liquid)',
html.count('spec-cell') == 4)
check('V47 3 sel stats (2 pemilih + saldo liquid)',
html.count('spec-cell') == 3)
check('V47 TOTAL VEX dihapus dari dashboard',
'TOTAL VEX' not in html)
check('V16 sel SALDO LIQUID', 'SALDO LIQUID' in html)
check('V16 SALDO LIQUID paling kiri (sblm TOTAL PEMILIH)',
html.index('SALDO LIQUID') < html.index('TOTAL PEMILIH'))
check('V16 nilai saldo diformat id-ID', '50.691,4676' in html)
check('V47 catatan definisi total vs vexascan',
'SUARA TUNGGAL KE' in html
and 'BUKAN TOTAL VOTE-WEIGHT' not in html)
check('V53 promo VEXWALLET link + logo IDRS',
'play.google.com/store/apps/details?id=kriptoteknologi.io.vexwallet' in html
and 'BY DATABISNISID' in html
and 'UNDUH DI PLAY STORE' in html
and 'idrs.png' in html)
check('V40 TOTAL PEMILIH = baris tampil (129)',
'<span class="cell-value">129</span>' in html)
@@ -134,6 +151,13 @@ def main():
check('V42 REVOTE ⊖ bagian sendiri (menyatu dgn VALID)',
'PEMILIH REVOTE · BELUM MATANG' not in html)
check('V41 caption BARU count', 'PEMILIH BARU · BELUM MATANG (0-3 HARI) · 1' in html)
check('V52 BARU countdown VALID DALAM N HARI',
'maturity" data-label="MATURITY PERIOD">VALID DALAM 2 HARI' in html)
check('V52 BARU band kolom MATURITY PERIOD',
'MATURITY PERIOD' in _slice_band(html))
check('V52 BARU band ⊖ kolom VOTE TERAKHIR & TOTAL REWARD',
'VOTE TERAKHIR' not in _slice_band(html)
and 'TOTAL REWARD' not in _slice_band(html))
check('V42 revoter1 (REVOTE) ada di daftar utama (#rows)',
'revoter1' in _slice_rows(html))
check('V42 newacct1 di BARU, revoter1 di VALID',
@@ -238,6 +262,8 @@ def main():
check('V27 tanggal di judul TRANSFER PER PEMILIH',
f'TRANSFER PER PEMILIH · {today}' in html)
check('V27 transfer per-pemilih tampil', 'acct001' in html)
check('V51 history akun jadi link vexascan',
'href="https://vexascan.com/account/acct001"' in html)
check('V27 hanya run terbaru (transfer lama tak tampil)',
'oldacct' not in html)
check('V27 status SENT tampil', 'SENT' in html)
@@ -253,6 +279,8 @@ def main():
check('V11 hanya akun cocok', 'acct125' in html and 'acct120' in html
and 'acct119' not in html)
check('V11 caption HASIL · 6', 'HASIL · 6 AKUN' in html)
check('V51 search akun jadi link vexascan',
'href="https://vexascan.com/account/acct125"' in html)
q_vals = _extract_stakes(html)
check('V11 hasil search urut DESC', q_vals == sorted(q_vals, reverse=True)
and len(q_vals) == 6)
@@ -442,6 +470,13 @@ def _slice_rows(text):
return text[start:end]
def _slice_band(text):
"""Potong section band BARU (`band-list news-list`) utk cek kolom."""
start = text.index('band-list news-list') + len('band-list news-list')
end = text.index('</section>', start)
return text[start:end]
def _parse_vex(text):
# format '1.749.791,2303' → float
return float(text.replace('.', '').replace(',', '.'))
+12 -4
View File
@@ -55,19 +55,25 @@ def make_voters_db(path, rows):
'VALUES (?, ?, ?, ?, ?)',
rows,
)
# V42: distribusi LEFT JOIN voter_first_seen — tabel harus ada.
# V42/V52: distribusi LEFT JOIN voter_first_seen — tabel harus ada.
# Default: semua pemilih uji MATANG (first_seen 60 hari lalu) agar masuk
# band VALID; kasus BARU/REVOTE ditimpa lewat make_first_seen (V52).
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen ('
'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
mature = (datetime.now() - timedelta(days=60)).isoformat(timespec='seconds')
conn.executemany('INSERT OR IGNORE INTO voter_first_seen '
'(owner, first_seen_at) VALUES (?, ?)',
[(r[0], mature) for r in rows])
conn.commit()
conn.close()
def make_first_seen(path, rows):
"""V42: isi tabel voter_first_seen (owner, first_seen_at)."""
"""V52: timpa first_seen utk pemilih tertentu (mis. BARU/REVOTE)."""
conn = sqlite3.connect(path)
conn.execute('CREATE TABLE IF NOT EXISTS voter_first_seen ('
'owner TEXT PRIMARY KEY, first_seen_at TEXT NOT NULL)')
conn.executemany('INSERT OR IGNORE INTO voter_first_seen '
conn.executemany('INSERT OR REPLACE INTO voter_first_seen '
'(owner, first_seen_at) VALUES (?, ?)', rows)
conn.commit()
conn.close()
@@ -287,7 +293,9 @@ def main():
('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).
# V52: first_seen — new1 baru (umur 1 hr → BARU, ⊥ matang), rev7 lama
# (first_seen 60 hr lalu → matang, VALID langsung). mid5/old9 pakai
# default matang dari make_voters_db.
make_first_seen(db.DB_PATH, [('new1', _iso(1)), ('rev7', _iso(60))])
db.query = _orig_query
dist.fetch_balance = lambda: Decimal('300.0000')
+28 -9
View File
@@ -19,9 +19,10 @@ import get_voters as gv
def _fresh_weight(staked_raw, days_ago=5):
"""Bobot untuk revote ~sekarang: staked_raw × 2^(tahun sejak 2000)."""
t = ((datetime.now() - gv.WEIGHT_EPOCH).days - days_ago) / 365.25
return f'{staked_raw * 2 ** t:.17f}'
"""Bobot utk revote ~sekarang: staked_raw × 2^(pekan/52) — kuantisasi
pekan utuh sejak epoch 2000 (formula stake2vote Vexanium)."""
weeks = ((datetime.now() - gv.WEIGHT_EPOCH).days - days_ago) // 7
return f'{staked_raw * 2 ** (weeks / 52.0):.17f}'
class FakePayload:
@@ -126,11 +127,17 @@ def main():
(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)
lv = gv.derive_last_vote(f'{12000000 * 2 ** 26}.00000000000000000', 1200.0)
# V13: formula — staked_raw × 2^(pekan/52), kuantisasi pekan utuh sejak 2000
# 2^1 → 52 pekan setelah 2000-01-01 = 2000-12-30 (52×7 = 364 hari)
lv = gv.derive_last_vote(f'{12000000 * 2 ** 1:.17f}', 1200.0)
lv_dt = datetime.fromisoformat(lv) if lv else None
check('V13 formula 2^26 → ~2026-01-01',
lv_dt is not None and abs((lv_dt - datetime(2026, 1, 1)).days) <= 2)
check('V13 formula 2^1 (52 pekan) → 2000-12-30',
lv_dt is not None and lv_dt == datetime(2000, 12, 30))
# 2^26 → 26×52 pekan = 1352 pekan setelah 2000 = 2025-11-29
lv = gv.derive_last_vote(f'{12000000 * 2 ** 26:.17f}', 1200.0)
lv_dt = datetime.fromisoformat(lv) if lv else None
check('V13 formula 2^26 (1352 pekan) → 2025-11-29',
lv_dt is not None and lv_dt == datetime(2025, 11, 29))
check('V13 rasio 1.0 → epoch 2000',
gv.derive_last_vote('12000000.00000000000000000', 1200.0) == '2000-01-01T00:00:00')
check('V13 bobot nol → None', gv.derive_last_vote('0', 1200.0) is None)
@@ -138,6 +145,17 @@ def main():
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)
# V13/B12: regresi formula pekan utuh — baris nyata dari chain
# ..tg: staked_raw 9000, weight → t×52 = 1291 pekan → 2024-09-28
lv = gv.derive_last_vote('267849632990.26596069335937500', 9000 / 10000)
check('V13 regresi ..tg (1291 pekan) → 2024-09-28',
lv == '2024-09-28T00:00:00')
# 1.crownz: staked_raw 17115095, t×52 = 1314 pekan → 2025-03-08
lv = gv.derive_last_vote(
f'{17115095 * 2 ** (1314 / 52.0):.17f}', 17115095 / 10000)
check('V13 regresi 1.crownz (1314 pekan) → 2025-03-08',
lv == '2025-03-08T00:00:00')
# V13/V40: normalize menyimpan basi, membuang yang tak dapat diverifikasi
old = gv.normalize({'owner': 'old8', 'producers': [gv.TARGET_BP],
'staked': 20000000,
@@ -154,10 +172,11 @@ def main():
# V13: estimasi yang melewati now (unstake tanpa revote) diklamp ke now
fixed_now = datetime(2026, 8, 5, 12, 0, 0)
t_future = ((fixed_now - gv.WEIGHT_EPOCH).days + 5) / 365.25
weeks_future = ((fixed_now - gv.WEIGHT_EPOCH).days + 14) // 7 # 2 pekan ke depan
fut = gv.normalize({'owner': 'fut9', 'producers': [gv.TARGET_BP],
'staked': 20000000,
'last_vote_weight': f'{20000000 * 2 ** t_future:.17f}'},
'last_vote_weight':
f'{20000000 * 2 ** (weeks_future / 52.0):.17f}'},
now=fixed_now)
check('V13 last_vote masa depan diklamp ke now',
fut is not None