klaim baseline saldo stabil (V56/B18) + endpoint cek akun payout-eligible /api/voter (V57)
V56/B18: pre-claim liquid balance dibaca SEKALI per window (poll_claim) dan dibawa ke tiap _try_claim_once — klaim yang mendarat di attempt 1 membuat before attempt 2 (ditolak already-claimed) sudah termasuk reward → delta 0 → alert palsu KLAIM MENDARAT · REWARD TAK TERUKUR (kasus 10 Agt: bfa9ab02 reward 1679.3365 vs tx ditolak b0a8b2f9) ; oracle B18 attempt-1-mendarat → claimed + reward asli terukur
V57: db.py single source eligible_voters(owner=None) — SQL jendela payout V40/V52 (last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY, cutoff UTC-naive) dipakai BAIK distribute.py run_distribution (refactor, hapus _eligible_cutoffs) MAUPUN endpoint baru GET /api/voter/<owner> → 200 {owner, is_valid_voter} (DB-snapshot, selalu 200, ⊖ format-validation); BARU/kadaluarsa/hidden/asing → false ; docs §I/§V/V57 + T50 + AGENTS ; oracle test_dashboard (V57 true/false + drift endpoint==db pool + pool distribusi==db) + test_distribute (mock eligible_voters utk no-op, distribusi luas)
This commit is contained in:
1 parent
ee4682da00
commit
8e80653df9
9 files changed
+172
-40
No files matched your search
@@ -7,7 +7,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
|
||||
- Node reference script: `node get_voters.js` (no deps, Node 18+).
|
||||
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) + `pyntelope` (distribute signing) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
|
||||
- Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot **restricted to the reward window** (not stale AND account mature, `last_vote > now−28d` AND `first_seen_at ≤ now−3d`, §V40/V52: new voters (`first_seen` recent/NULL) and stale voters get nothing — returning re-voters with mature first_seen are paid regardless of vote age), fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed.
|
||||
- Daily reward claim (production): `./venv/bin/python claim_loop.py` — the `claim` service scheduler. `claim.py` computes the claim window as `last_claim_time + 24h` (from the `vexcore` `producers` table) in **UTC-naive time** (`_utcnow()`, matching the chain's UTC `last_claim_time`; the container's local TZ must not shift the window — V49/B13), sleeps until near it, then polls `vexcore::claimrewards` every `CLAIM_RETRY_SECONDS` (default 60) until the chain accepts; if 24h have already elapsed since the last claim the window clamps to now (missed-window recovery, no permanent spin-timeout). A claim is only treated as "landed" if Hyperion says `executed` OR `last_claim_time` actually advanced past its pre-send value (`_claim_time_advanced`) — on **both** the send-success and send-exception paths (`_confirm_landed`), because pyntelope's `send()` does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11), and Hyperion returning `executed: false` for a just-landed-but-not-yet-indexed tx must NOT count as "didn't land" (V50/B14). Unconfirmed ⇒ silent `retry` (no `claim_runs` row, no success notify) — self-heals when Hyperion indexes the tx on the next poll. The landing baseline (`last_claim_time` before send) is captured **once per poll window** in `poll_claim` and passed to every `_try_claim_once` — it must NOT be re-read per attempt, or a landed-but-stale-first-read claim falls into a permanent retry loop (V54/B16); `_confirm_landed` also retries the post-send `last_claim_time` read so a lagging RPC node doesn't cause a false negative. `next_window` raises if all producer reads fail (never clamps to now prematurely). Reward is measured **from the claim tx itself** (sum of `vex.bpay`+`vex.vpay` → BP transfers via Hyperion, `reward_from_tx`), **retried up to 4× at `SETTLE_SECONDS` (30s)** so a just-landed tx not yet indexed by Hyperion doesn't read as `None` (V55/B17); if Hyperion is down it falls back to the liquid-balance delta, but **only a positive delta (`after > before`) counts as measured** — a 0 delta is ambiguous (stale node balance) and is treated as unmeasured, never as a genuine zero; if neither can be read the claim is recorded with fee `pending` and an internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert is sent (never a misleading 0-reward success — a real 0-reward claim must be evidenced by `reward_from_tx` returning `Decimal('0')` from an executed tx). 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
|
||||
- Daily reward claim (production): `./venv/bin/python claim_loop.py` — the `claim` service scheduler. `claim.py` computes the claim window as `last_claim_time + 24h` (from the `vexcore` `producers` table) in **UTC-naive time** (`_utcnow()`, matching the chain's UTC `last_claim_time`; the container's local TZ must not shift the window — V49/B13), sleeps until near it, then polls `vexcore::claimrewards` every `CLAIM_RETRY_SECONDS` (default 60) until the chain accepts; if 24h have already elapsed since the last claim the window clamps to now (missed-window recovery, no permanent spin-timeout). A claim is only treated as "landed" if Hyperion says `executed` OR `last_claim_time` actually advanced past its pre-send value (`_claim_time_advanced`) — on **both** the send-success and send-exception paths (`_confirm_landed`), because pyntelope's `send()` does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11), and Hyperion returning `executed: false` for a just-landed-but-not-yet-indexed tx must NOT count as "didn't land" (V50/B14). Unconfirmed ⇒ silent `retry` (no `claim_runs` row, no success notify) — self-heals when Hyperion indexes the tx on the next poll. The landing baseline (`last_claim_time` before send) is captured **once per poll window** in `poll_claim` and passed to every `_try_claim_once` — it must NOT be re-read per attempt, or a landed-but-stale-first-read claim falls into a permanent retry loop (V54/B16); `_confirm_landed` also retries the post-send `last_claim_time` read so a lagging RPC node doesn't cause a false negative. The **pre-claim liquid balance** is likewise captured **once per poll window** (`before_balance`) and passed to every `_try_claim_once` (V56/B18) — if a claim lands on attempt 1, the next (rejected "already claimed") attempt must still measure the reward via the window-start balance, not a re-read `before` that already includes the credit (which reads delta 0 → false `KLAIM MENDARAT · REWARD TAK TERUKUR`, e.g. the 2026-08-10 claim `bfa9ab02…` reward 1679.3365 recorded against rejected tx `b0a8b2f9…`). `next_window` raises if all producer reads fail (never clamps to now prematurely). Reward is measured **from the claim tx itself** (sum of `vex.bpay`+`vex.vpay` → BP transfers via Hyperion, `reward_from_tx`), **retried up to 4× at `SETTLE_SECONDS` (30s)** so a just-landed tx not yet indexed by Hyperion doesn't read as `None` (V55/B17); if Hyperion is down it falls back to the liquid-balance delta, but **only a positive delta (`after > before`) counts as measured** — a 0 delta is ambiguous (stale node balance) and is treated as unmeasured, never as a genuine zero; if neither can be read the claim is recorded with fee `pending` and an internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert is sent (never a misleading 0-reward success — a real 0-reward claim must be evidenced by `reward_from_tx` returning `Decimal('0')` from an executed tx). 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
|
||||
- Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
|
||||
- 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`
|
||||
@@ -38,13 +38,13 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
|
||||
|
||||
- `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.
|
||||
- `get_voters.py` — production fetcher: scan → filter (stake-min + BP target, semua umur vote disimpan V40; hanya last_vote tak terverifikasi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history) → `db.record_first_seen` (V41: catat kemunculan pertama tiap owner ke `voter_first_seen`, idempoten).
|
||||
- `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.
|
||||
- `db.py` — storage abstraction (sqlite default | mysql via PyMySQL); `connect/query/queryone/replace_snapshot`; `%s` → `?` for sqlite; `query` returns list. Juga tabel distribusi: `distribute_runs` + `distribute_payments` (append-only) + helper `ensure_distribute_schema/record_run/record_payment/update_payment_status/update_run_status/list_runs/list_payments`. Juga tabel klaim: `claim_runs` + helper `ensure_claim_schema/record_claim/update_claim_fee/pending_claim_fee`. Juga tabel V41 `voter_first_seen` (owner PK + `first_seen_at`, append-only) + helper `ensure_first_seen_schema/record_first_seen` — dipakai dashboard membedakan PEMILIH BARU vs REVOTE. Juga helper V57 `eligible_voters(owner=None)` — single-source SQL jendela reward (payout window, V40/V52): `last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY` (cutoff UTC-naive); tanpa `owner` → semua baris `(owner, staked)` utk distribusi; dgn `owner` → baris akun itu (`[]`|one row) utk endpoint cek.
|
||||
- `get_voters.js` — reference implementation only.
|
||||
- `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V52** (`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 — di-RETRY 4× dgn `SETTLE_SECONDS` (30) beri waktu Hyperion indeks tx baru (V55/B17); selisih saldo fallback HANYA delta positif (`after > before`), delta 0 ambigu → ⊥ dianggap reward 0; reward ⊥ terukur → fee `pending` + notif `KLAIM MENDARAT · REWARD TAK TERUKUR` (V33/B10/V55), ⊖ pernah `KLAIM REWARD SUKSES 0.0000` palsu; fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; parse `get_table_rows` pakai `data.get('rows')` (bentuk asli dict, ⊥ `data[0]` — B7/V39); `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, bentuk respons asli, temp DB).
|
||||
- `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V52** (`db.eligible_voters()` — V57 single source: `last_vote > now−28d AND first_seen_at ≤ now−3d`, pemilih baru (`first_seen` baru/NULL) & basi ⊥ dibayar; akun yang sudah matang dibayar langsung, ⊖ peduli umur vote, V52) → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
|
||||
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`, di-retry V46) → `next_window` = +24 jam, math UTC-naive via `_utcnow()` (V49/B13 — ⊥ `datetime.now()` lokal yang geser +TZ → poll prematur) → (≥24 jam lewat → clamp ke now, recovery B8); tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; deteksi mendarat via `_confirm_landed` di KEDUA jalur (send sukses & exception) = Hyperion `executed` ATAU `last_claim_time` maju dari baseline (V46/B11 — `send()` pyntelope ⊥ raise saat chain menolak, HTTP 500 sbg body; Hyperion `executed: false` utk tx baru-mendarat-belum-terindeks juga dicek lewat `_claim_time_advanced`, V50/B14); tak terkonfirmasi → retry senyap; reward = isi tx via Hyperion (bpay+vpay → BP, `reward_from_tx`) prioritas — di-RETRY 4× dgn `SETTLE_SECONDS` (30) beri waktu Hyperion indeks tx baru (V55/B17); selisih saldo fallback HANYA delta positif (`after > before`), delta 0 ambigu → ⊥ dianggap reward 0; baseline saldo (`before_balance`) dibaca SEKALI per window & dibawa tiap attempt (V56/B18) — klaim mendarat attempt 1 lalu attempt 2 ditolak (already claimed) ⊖ boleh baca-ulang `before` yg sudah termasuk reward → delta 0 → alert palsu; reward ⊥ terukur → fee `pending` + notif `KLAIM MENDARAT · REWARD TAK TERUKUR` (V33/B10/V55/V56), ⊖ pernah `KLAIM REWARD SUKSES 0.0000` palsu; fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; parse `get_table_rows` pakai `data.get('rows')` (bentuk asli dict, ⊥ `data[0]` — B7/V39); `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, bentuk respons asli, temp DB).
|
||||
- `claim_loop.py` — scheduler harian dalam container stack (service `claim`): `_wait_db` (duplikat scan_loop) lalu `claim.step()` terus, loop tak pernah keluar.
|
||||
- `telegram.py` — kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`.
|
||||
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40/V52): the voters list is split into BARU (top, `PEMILIH BARU · BELUM MATANG`, new accounts by `first_seen_at` within 3 days) and the main list (`voter-list` section, `.reward` cells, V29) where KADALUARSA rows are pinned at the top with amber styling + `VOTE ULANG` badge + legend before the paged VALID rows, which LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column; since V42 the VALID list also holds REVOTE (returning re-voters, `voter_first_seen.first_seen_at ≤ now−3d`) tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS`) — no separate band, no maturity wait; only genuinely new voters sit in BARU tagged `BARU`; since V43 VALID rows within `VEX_WARN_DAYS` of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge + legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH` (display-only); search (`?q=` and `/api/search`) covers all bands and tags each result (`group` = `baru|revote|valid|kadaluarsa` + `expired` + `expiring`). Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard); the `SETIAP ... · HH:00` recurrence line renders only for regular weekly rounds — hidden while the next event is the one-off first-run (`_is_first_run_next`, date ≠ weekly day). A `stats-note` caption states the list is single-vote-to-BP accounts ≥ min stake (V47); account names link to `https://vexascan.com/account/{owner}` in the index list, `/history` pay-list, and live search (`.owner-link`, V51); a promo card (`BY DATABISNISID · VEXWALLET`) links the VexWallet Android app on the index page via the IDRS logo (`static/idrs.png`) + `UNDUH DI PLAY STORE` (V53); BARU rows show a `MATURITY PERIOD` countdown column (`VALID DALAM N HARI`) instead of TOTAL REWARD. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
|
||||
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40/V52): the voters list is split into BARU (top, `PEMILIH BARU · BELUM MATANG`, new accounts by `first_seen_at` within 3 days) and the main list (`voter-list` section, `.reward` cells, V29) where KADALUARSA rows are pinned at the top with amber styling + `VOTE ULANG` badge + legend before the paged VALID rows, which LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column; since V42 the VALID list also holds REVOTE (returning re-voters, `voter_first_seen.first_seen_at ≤ now−3d`) tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS`) — no separate band, no maturity wait; only genuinely new voters sit in BARU tagged `BARU`; since V43 VALID rows within `VEX_WARN_DAYS` of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge + legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH` (display-only); search (`?q=` and `/api/search`) covers all bands and tags each result (`group` = `baru|revote|valid|kadaluarsa` + `expired` + `expiring`); `/api/voter/<owner>` (V57) returns `{"owner", "is_valid_voter"}` — payout-eligibility check via the SAME `db.eligible_voters(owner)` window distribute pays from (never a second SQL copy), DB-snapshot only, always 200; Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard); the `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.
|
||||
|
||||
@@ -54,6 +54,7 @@ cmd: `./venv/bin/python get_voters.py` → stdout summary (id-ID)
|
||||
web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 50/halaman, `ORDER BY staked DESC, owner ASC`; ⊥ mutation (read-only)
|
||||
web: GET `/` + `?q=<substring>` → filter owner (server-side, no-JS fallback); pager bawa `q`
|
||||
web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote,total_reward}]}`, rank global, cap 500
|
||||
web: GET `/api/voter/<owner>` → JSON `200 {owner, is_valid_voter}` (payout-eligible check, V57); selalu 200, ⊖ 404; DB-snapshot saja, ⊖ format-validation
|
||||
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `SCAN_HOURS`, `SCAN_RUN_ON_START`, `TZ`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY`, `DISTRIBUTE_FIRST_RUN` (dashboard banner V38; web service di stack menerima TZ + jadwal) — via `config.py` (`.env`)
|
||||
api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_balance` → body {code:`vex.token`, account, symbol:`VEX`} → `["X.XXXX VEX"]`
|
||||
api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_stats` → {supply, max_supply, issuer} (cek precision)
|
||||
@@ -121,6 +122,8 @@ V52: maturity pemilih baru diukur dari `first_seen_at` (stempel scan presisi det
|
||||
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
|
||||
V54: baseline landing klaim STABIL per window (B16): `poll_claim` baca `last_claim_time` SEKALI di awal window dan mengoper ke SETIAP `_try_claim_once(last_before)` (⊥ tiap attempt baca ulang — baseline ikut maju setelah klaim mendarat, membuat `_claim_time_advanced` tak pernah True → retry-loop permanen walau klaim sukses); `_confirm_landed` baca `last_after` dgn retry singkat (node RPC bisa lag mencerminkan `last_claim_time` baru); attempt 1 yg baca basi → retry, attempt 2 (baseline orisinil vs `last_after` maju) → claimed. Oracle test_claim: stale-lalu-maju → poll_claim claimed + reward tercatat | V54,V50,V46
|
||||
V55: reward klaim TAK PERNAH tercatat/notif sbg `0.0000` sukses bila tak terukur (B17): `_measure_reward` (1) RETRY `reward_from_tx` 4× dgn jeda `SETTLE_SECONDS` (default 30) — tx klaim baru mendarat bisa belum terindeks Hyperion sesaat → None palsu; (2) fallback selisih saldo 3× HANYA mengakui delta POSITIF (`after > before`) sbg terukur — delta 0 ambigu (node balik saldo lama / saldo belum ter-update) → tetap None; keduanya gagal → fee `pending` + alert `KLAIM MENDARAT · REWARD TAK TERUKUR` (jalur V45), ⊖ `KLAIM REWARD SUKSES 0.0000` menyesatkan. Reward 0 ASLI (tx executed tanpa transfer bpay/vpay) tetap terbaca via isi tx (`reward_from_tx` → Decimal('0')) → fee `skipped` (V33). Oracle test_claim: V33 reward-0 via isi tx (bukan delta 0); B17-a reward_from_tx None + delta 0 → claimed + fee pending + alert ⊖ sukses-0; B17-b reward_from_tx None lalu berhasil → reward asli tercatat | V55,V45,V33
|
||||
V56: baseline SALDO klaim STABIL per window (B18): `poll_claim` baca `before_balance` (saldo liquid) SEKALI di awal window & oper ke SETIAP `_try_claim_once(last_before, before_balance)` — ⊖ baca ulang per attempt: klaim yang mendarat di attempt 1 membuat `before` attempt 2 (ditolak already-claimed) SUDAH termasuk reward → delta 0 → reward tak terukur → alert palsu (kasus 10 Agt: tx `bfa9ab02` mendarat 15:46:18 reward 1679.3365, app ukur tx ditolak `b0a8b2f9` + before post-credit → alert `KLAIM MENDARAT · REWARD TAK TERUKUR`). Baseline stabil → delta fallback mengukur reward benar walau tx tercatat = attempt ditolak. Bila None (pemanggil langsung) → baca sendiri sbg fallback. Oracle test_claim: B18 attempt-1-mendarat (confirm basi) → retry, attempt-2-ditolak (last maju) → landed + reward 1679.3365/fee 167.9336 terukur, ⊖ alert | V56,V54,V55
|
||||
V57: endpoint `GET /api/voter/<owner>` (web) → `200 {owner, is_valid_voter}` — `is_valid_voter` = akun memenuhi SYARAT REWARD saat ini (jendela payout distribusi V40/V52), BUKAN sekadar ada di tabel `voters` (BARU/KADALUARSA/tersembunyi/akun tak dikenal → `false`). Single source kebenaran: `db.eligible_voters(owner=None)` (db.py) memakai SQL jendela payout yg SAMA — `last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY` (cutoff UTC-naive) — dipakai BAIK `distribute.py:run_distribution` (tanpa owner → semua baris `(owner,staked)`) MAUPUN endpoint (dengan owner → `[]`|one row) → ⊖ duplikasi SQL (drift, gaya V38); endpoint: `is_valid_voter = len(db.eligible_voters(owner)) > 0`; DB-snapshot only (kesegaran = skan harian, V10), selalu 200, ⊖ format-validation nama akun. Oracle test_dashboard (V57): true utk acct###/revoter1/expire1; false utk newacct1 (BARU)/zzzold (kadaluarsa)/zzhide (tersembunyi)/asing; drift-guard endpoint==db + pool distribusi==db
|
||||
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
|
||||
@@ -178,6 +181,7 @@ T45|x|band umur vote V40: scan simpan semua umur (hapus `_is_stale`), `config` +
|
||||
T46|x|BARU vs REVOTE (V41): `db.py` += tabel `voter_first_seen` + `ensure_first_seen_schema`/`record_first_seen` (INSERT OR IGNORE/IGNORE, PK owner, append-only); scan `persist` mencatat first_seen; dashboard `_edge_rows`/`_stats`/`_search_rows` klasifikasi `first_seen > new_cut` → BARU, ≤ → REVOTE (group enum += `revote`), bagian REVOTE sendiri di bawah BARU + tag CSS `--mint`; docs + oracle test_get_voters/test_dashboard/test_mariadb|V41,V7,V15,V40
|
||||
T47|x|REVOTE = VALID langsung (V42): `distribute.py` `run_distribution` `ensure_first_seen_schema` + query LEFT JOIN `voter_first_seen` → `last_vote > now−STALE AND (last_vote ≤ now−MATURITY OR REVOTE)`; dashboard `_edge_rows` → `(new_rows, expired_rows)` (REVOTE ⊖ BARU, masuk daftar utama), `_db_rows`/`_count` jendela sama + flag revote (elemen ke-8) + tag mint `REVOTE` di baris valid, `_stats` partisi n_new+n_revote+n_expired+n_valid == total, `_search_rows` group `revote` di jendela BARU & VALID; template ⊖ bagian REVOTE + legenda mint (`?v=13`); docs + oracle test_distribute/test_dashboard|V42,V40,V41,V15,I.db,I.web
|
||||
T48|x|tag/badge display V43: `config` += `VEX_REVOTE_TAG_DAYS` (1) + `VEX_WARN_DAYS` (3); `_freshness_cutoffs` → 4-tuple `(new_cut, warn_cut, exp_cut, hid_cut)` + `_revote_cut`; `_db_rows` flag revote = `last_vote > revote_cut AND fs ≤ new_cut` + flag expiring (elemen ke-9) `exp_cut < last_vote ≤ warn_cut`; `_stats` 9-tuple (+`n_expiring`, `n_revote` = tag-1-hari); `_search_rows` tag `revote` HANYA bila `last_vote > revote_cut` + field `expiring`; template badge `VOTE ULANG SEGERA` (main + `?q=`) + legenda mint/amber baru (`?v=14`), `app.js` warn-badge, CSS `.warn-badge`; env/.env.example + docs (V15/V42 amend, V43, T48); oracle test_dashboard|V43,V42,V40,V15,I.env,I.web
|
||||
T50|x|endpoint cek akun payout-eligible V57: `db.py` += `eligible_voters(owner=None)` (single-source SQL jendela reward, cutoff UTC-naive) — distribute.py `run_distribution` refactor pakai `db.eligible_voters()` (hapus `_eligible_cutoffs`), dashboard += `GET /api/voter/<owner>` → `{owner, is_valid_voter}` (`len(db.eligible_voters(owner))>0`); docs §I/§V/V57 + T50; oracle test_dashboard (V57 true/false + drift endpoint==db pool) + test_distribute (mock `eligible_voters` utk no-op)|V57,V40,V52,V31
|
||||
|
||||
## §B — Bug log
|
||||
id|date|cause|fix
|
||||
@@ -198,3 +202,4 @@ B14|2026-08-07|setelah fix TZ (V49) klaim BENAR-BENAR mendarat (tx `0e61fcb5…`
|
||||
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
|
||||
B16|2026-08-08|klaim mendarat di chain (tx `48c2066f…`, bpay 463.9816 + vpay 1219.3825 = 1683.3641 VEX) tapi app tetep `[Peringatan] klaim terkirim tapi tak terkonfirmasi mendarat — retry` tanpa henti & ⊖ notif/fee (sama utk klaim 7 Agt `0e61fcb5…`): `_try_claim_once` baca `last_before` ULANG tiap attempt — attempt 1 mendarat tapi baca `last_after` basi (node RPC belum mencerminkan `last_claim_time` baru) → retry; attempt 2 `last_before` sudah = nilai maju → `_claim_time_advanced(maju, maju)` ⊖ pernah True → retry-loop permanen walau V50/V14 sudah menangani Hyperion-False|`poll_claim` baca baseline SEKALI per window & oper ke tiap `_try_claim_once`; `_confirm_landed` retry baca `last_after` dgn jeda singkat (V54); oracle stale-lalu-maju → claimed
|
||||
B17|2026-08-08|notif Telegram `KLAIM REWARD SUKSES / Reward 0.0000 / Fee BP 0.0000 → bpdbsjasprod` (22:47 WIB, ~1 mnt stlh klaim mendarat 15:46:06.5 tx `48c2066f…` reward asli 1683.3641) — `_measure_reward` balikin `Decimal('0')` BUKAN None: `reward_from_tx` → None (Hyperion belum indeks tx yg baru mendarat) lalu fallback selisih saldo baca `after == before` (node balik saldo lama) → `max(δ,0)=0`; guard `if reward is None` (V45) ⊖ pernah memicu (0 ⊖ None) → fee 0 `skipped` + `notify_claim(0,0)` sukses-0 palsu; fee 168.3364 VEX utk siklus itu tak terkirim (sama utk klaim 9 Agt `4b9ec0be…` reward 1683.0126 yg jalan di image V54: delta-0 → sukses-0 → fee 168.3012 tak terkirim)|`_measure_reward` retry `reward_from_tx` 4× dgn `SETTLE_SECONDS` (30) beri waktu Hyperion mengejar indeks; fallback selisih saldo HANYA delta POSITIF (`after > before`); delta 0 / keduanya gagal → None → fee `pending` + alert `KLAIM MENDARAT · REWARD TAK TERUKUR`, ⊖ sukses-0 (V55); reward 0 asli dibuktikan dari isi tx; oracle B17-a/B17-b
|
||||
B18|2026-08-10|notif `KLAIM MENDARAT · REWARD TAK TERUKUR` (txid `b0a8b2f9…`) padahal klaim mendarat & reward 1679.3365 nyata (tx `bfa9ab02…` 15:46:18, bpay 460.3928+vpay 1218.9437): attempt 1 mendarat tapi `_confirm_landed` baca basi (Hyperion False + last_claim_time stale) → retry; attempt 2 re-sign `b0a8b2f9` ditolak chain (already claimed, HTTP 500 ⊥ raise) tapi `last_claim_time` SUDAH maju → dianggap mendarat; `_measure_reward(b0a8b2f9, before)` gagal — `before` dibaca-ULANG per attempt jadi SUDAH termasuk reward → reward_from_tx tx ditolak None + delta 0 → alert; fee 167.9336 tak terkirim (DB claim_runs: 46 baris reward 0 — 44 phantom 7 Agt + 9 Agt + 10 Agt; 8 Agt ⊖ baris sama sekali)|`poll_claim` baca `before_balance` (saldo) SEKALI per window & oper ke tiap `_try_claim_once` — delta fallback mengukur reward benar walau tx tercatat = attempt ditolak (V56); oracle B18 attempt-1-mendarat → claimed + reward asli terukur
|
||||
@@ -237,7 +237,7 @@ def _confirm_landed(txid, last_before):
|
||||
return _claim_time_advanced(last_before, last_after)
|
||||
|
||||
|
||||
def _try_claim_once(last_before=None):
|
||||
def _try_claim_once(last_before=None, before_balance=None):
|
||||
"""Satu percobaan klaim: klaim → konfirmasi mendarat → ukur reward →
|
||||
catat `claim_runs`.
|
||||
|
||||
@@ -254,8 +254,17 @@ def _try_claim_once(last_before=None):
|
||||
yang mendarat tapi baca-pertama-nya basi tetap terdeteksi (⊥ baseline ikut
|
||||
maju tiap attempt → retry-loop permanen). Bila None (pemanggil langsung),
|
||||
baca sendiri sbg fallback.
|
||||
|
||||
V56/B18: `before_balance` (saldo liquid SEBELUM ada attempt) juga dibawa
|
||||
TETAP dari awal window — klaim yang mendarat di attempt 1 lalu attempt 2
|
||||
ditolak (already claimed) membuat `before` baca-ulang per attempt SUDAH
|
||||
termasuk reward → delta 0 → reward tak terukur → alert palsu (kasus 10 Agt:
|
||||
tx bfa9ab02 mendarat, app ukur tx ditolak b0a8b2f9 + before post-credit).
|
||||
Baseline stabil → delta fallback mengukur reward dengan benar. Bila None
|
||||
(pemanggil langsung), baca sendiri sbg fallback.
|
||||
"""
|
||||
before = dist.fetch_balance()
|
||||
before = (before_balance if before_balance is not None
|
||||
else dist.fetch_balance())
|
||||
if before is None:
|
||||
return 'retry'
|
||||
if last_before is None:
|
||||
@@ -310,13 +319,23 @@ def poll_claim(deadline):
|
||||
dipakai utk SEMUA attempt — klaim yang mendarat tapi baca-pertama-nya basi
|
||||
terdeteksi di attempt berikutnya (baseline orisinil vs `last_after` maju),
|
||||
⊥ baseline ikut maju tiap attempt → retry-loop permanen.
|
||||
|
||||
V56/B18: saldo liquid sebelum klaim (`before_balance`) JUGA dibaca SEKALI
|
||||
di awal window dan dipakai utk SEMUA attempt — kalau klaim mendarat di
|
||||
attempt 1, attempt 2 yang ditolak (already claimed) memakai baseline yang
|
||||
BELUM termasuk reward → delta fallback mengukur reward benar (⊥ before
|
||||
baca-ulang per attempt yang sudah post-credit → delta 0 → alert palsu).
|
||||
"""
|
||||
try:
|
||||
last_before = fetch_producer_last_claim()
|
||||
except Exception:
|
||||
last_before = None
|
||||
try:
|
||||
before_balance = dist.fetch_balance()
|
||||
except Exception:
|
||||
before_balance = None
|
||||
while _utcnow() < deadline:
|
||||
status = _try_claim_once(last_before)
|
||||
status = _try_claim_once(last_before, before_balance)
|
||||
if status == 'claimed':
|
||||
return 'claimed'
|
||||
time.sleep(CLAIM_RETRY_SECONDS)
|
||||
|
||||
@@ -592,6 +592,21 @@ def api_search():
|
||||
})
|
||||
|
||||
|
||||
@app.get('/api/voter/<owner>')
|
||||
def api_voter(owner):
|
||||
"""V57: cek apakah akun = pemilih databisnisid yang memenuhi syarat reward.
|
||||
|
||||
Jendela sama persis dgn distribusi (`db.eligible_voters` — satu sumber,
|
||||
dipakai pula `distribute.py`): `last_vote > now−STALE` (⊥ basi) DAN akun
|
||||
matang (`first_seen_at ≤ now−MATURITY`). Akun BARU/KADALUARSA/tersembunyi/
|
||||
tak ada → `false`. Baca snapshot DB saja (kesegaran = skan harian), ⊥ chain
|
||||
live; ⊥ format-validation nama akun. Selalu 200, ⊖ 404.
|
||||
"""
|
||||
_ensure_first_seen_schema()
|
||||
rows = db.eligible_voters(owner)
|
||||
return jsonify({'owner': owner, 'is_valid_voter': len(rows) > 0})
|
||||
|
||||
|
||||
@app.get('/history')
|
||||
def history():
|
||||
"""V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih.
|
||||
|
||||
@@ -8,7 +8,7 @@ skema `voters` yang sama; query berbagi sintaks ditulis dengan placeholder
|
||||
import sqlite3
|
||||
|
||||
from config import (DB_BACKEND, DB_HOST, DB_NAME, DB_PASS, DB_PORT, DB_PATH,
|
||||
DB_USER)
|
||||
DB_USER, VEX_MATURITY_DAYS, VEX_STALE_DAYS)
|
||||
|
||||
# Skema voters — sama untuk kedua backend (I.db)
|
||||
_COLS = (
|
||||
@@ -388,6 +388,33 @@ def record_first_seen(rows, scanned_at):
|
||||
conn.close()
|
||||
|
||||
|
||||
def eligible_voters(owner=None):
|
||||
"""V57: pemilih yang memenuhi syarat reward (single source, dipakai
|
||||
`distribute.py` + endpoint `/api/voter/<owner>`).
|
||||
|
||||
Jendela reward V40/V52 = `last_vote > now−STALE` (⊥ basi) DAN akun MATANG
|
||||
(`first_seen_at IS NOT NULL` DAN `first_seen_at ≤ now−MATURITY`). Maturity
|
||||
diukur dari `first_seen_at` (presisi detik; B15). Tanpa `owner` → semua
|
||||
baris `(owner, staked)` utk distribusi; dengan `owner` → baris akun itu
|
||||
(`[]` bila tak memenuhi syarat) utk cek endpoint.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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')
|
||||
sql = ('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 fs.first_seen_at IS NOT NULL '
|
||||
' AND fs.first_seen_at <= %s')
|
||||
params = [stale_cutoff, mature_cutoff]
|
||||
if owner is not None:
|
||||
sql += ' AND v.owner = %s'
|
||||
params.append(owner)
|
||||
ensure_first_seen_schema()
|
||||
return query(sql, params)
|
||||
|
||||
|
||||
def pending_claim_fee():
|
||||
"""Klaim terbaru dengan fee belum terkirim (fee_amount > 0) → baris | None.
|
||||
|
||||
|
||||
+3
-27
@@ -13,7 +13,7 @@ terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
|
||||
|
||||
import requests
|
||||
@@ -22,28 +22,11 @@ import db
|
||||
import telegram
|
||||
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API,
|
||||
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP,
|
||||
TOKEN_CONTRACT, VEX_MATURITY_DAYS, VEX_STALE_DAYS,
|
||||
VEX_SYMBOL)
|
||||
TOKEN_CONTRACT, VEX_SYMBOL)
|
||||
|
||||
VEX_PREC = Decimal('0.0001')
|
||||
|
||||
|
||||
def _eligible_cutoffs():
|
||||
"""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')
|
||||
return stale_cutoff, mature_cutoff
|
||||
|
||||
|
||||
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):
|
||||
@@ -147,14 +130,7 @@ def run_distribution(dry_run=False):
|
||||
flush=True)
|
||||
return 0
|
||||
db.ensure_first_seen_schema()
|
||||
stale_cutoff, mature_cutoff = _eligible_cutoffs()
|
||||
voters = db.query(
|
||||
'SELECT v.owner, v.staked FROM voters v '
|
||||
'LEFT JOIN voter_first_seen fs ON fs.owner = v.owner '
|
||||
'WHERE v.last_vote > %s '
|
||||
' AND fs.first_seen_at IS NOT NULL '
|
||||
' AND fs.first_seen_at <= %s',
|
||||
[stale_cutoff, mature_cutoff])
|
||||
voters = db.eligible_voters()
|
||||
if not voters:
|
||||
print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True)
|
||||
return 0
|
||||
|
||||
@@ -411,6 +411,56 @@ def main():
|
||||
row[3] == 1683.0126 and row[4] == 168.3012
|
||||
and row[5] == 'pending' and row[2] == 'txC')
|
||||
|
||||
# ————— V56/B18: klaim mendarat attempt 1, attempt 2 ditolak (already
|
||||
# claimed) — before_balance STABIL per window → reward tetap terukur —————
|
||||
# Kasus 2026-08-10: attempt 1 (bfa9ab02) mendarat tapi confirm baca
|
||||
# basi → retry; attempt 2 (b0a8b2f9) ditolak chain tapi last_claim_time
|
||||
# SUDAH maju → dianggap mendarat; before_balance dibaca SEKALI di awal
|
||||
# window (belum termasuk reward) → delta fallback mengukur reward asli.
|
||||
# Tanpa baseline stabil, `before` attempt 2 sudah post-credit → delta 0
|
||||
# → alert KLAIM MENDARAT palsu.
|
||||
fresh_db('b18land')
|
||||
b18 = {'n': 0}
|
||||
|
||||
def b18_confirm():
|
||||
b18['n'] += 1
|
||||
if b18['n'] <= 4: # baseline + 3 baca attempt 1 → basi (stale)
|
||||
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') # attempt 2 → maju
|
||||
|
||||
claim.fetch_producer_last_claim = b18_confirm
|
||||
claim.reward_from_tx = lambda txid: None # tx ditolak → ⊥ terbaca
|
||||
bal_seq = iter([Decimal('1000.0000'), Decimal('2679.3365')])
|
||||
dist.fetch_balance = lambda: next(bal_seq) # 1000 = baseline window
|
||||
sends = {'n': 0}
|
||||
|
||||
def send_land_then_reject():
|
||||
sends['n'] += 1
|
||||
if sends['n'] == 1:
|
||||
return FakeSigned('txA1') # attempt 1: mendarat
|
||||
return FakeSigned('txA2', exc=RuntimeError('already claimed'))
|
||||
|
||||
claim.build_claim_action = send_land_then_reject
|
||||
dist.verify_txid = lambda txid: False # Hyperion lag kedua kali
|
||||
notifs = []
|
||||
_orig_ncA = telegram.notify_claim
|
||||
_orig_ncuA = telegram.notify_claim_unmeasured
|
||||
telegram.notify_claim_unmeasured = lambda t: notifs.append('unmeasured')
|
||||
telegram.notify_claim = lambda r, f, w: notifs.append('claimed')
|
||||
deadline = claim._utcnow() + timedelta(seconds=3600)
|
||||
check('V56 klaim attempt-1-mendarat → poll_claim claimed',
|
||||
claim.poll_claim(deadline) == 'claimed')
|
||||
row = last_claim_row()
|
||||
check('V56 reward asli terukur via delta (before_balance stabil)',
|
||||
row[3] == 1679.3365 and row[4] == 167.9336
|
||||
and row[5] == 'pending' and row[2] == 'txA2')
|
||||
check('V56 ⊖ alert tak-terukur, notif sukses dipakai',
|
||||
'unmeasured' not in notifs and 'claimed' in notifs)
|
||||
telegram.notify_claim = _orig_ncA
|
||||
telegram.notify_claim_unmeasured = _orig_ncuA
|
||||
|
||||
# ————— V45: reward dari isi tx (bpay+vpay) → prioritas —————
|
||||
fresh_db('txreward')
|
||||
claim.fetch_producer_last_claim = lambda: (
|
||||
|
||||
@@ -389,6 +389,45 @@ def main():
|
||||
check('V40 rank ordinal dalam kelompok (1..9)',
|
||||
[r['rank'] for r in data['results']] == list(range(1, 10)))
|
||||
|
||||
# ————— V57: /api/voter/<owner> — cek akun pemilih reward-eligible —————
|
||||
resp = client.get('/api/voter/acct125')
|
||||
check('V57 eligible akun VALID → 200', resp.status_code == 200)
|
||||
check('V57 shape {owner,is_valid_voter}',
|
||||
set(resp.get_json().keys()) == {'owner', 'is_valid_voter'})
|
||||
check('V57 acct125 (matang+segar) → true',
|
||||
resp.get_json() == {'owner': 'acct125', 'is_valid_voter': True})
|
||||
resp = client.get('/api/voter/revoter1')
|
||||
check('V57 revoter1 (REVOTE matang) → true',
|
||||
resp.get_json() == {'owner': 'revoter1', 'is_valid_voter': True})
|
||||
resp = client.get('/api/voter/expire1')
|
||||
check('V57 expire1 (VALID, badge peringatan) → true',
|
||||
resp.get_json() == {'owner': 'expire1', 'is_valid_voter': True})
|
||||
resp = client.get('/api/voter/newacct1')
|
||||
check('V57 newacct1 (BARU, belum matang) → false',
|
||||
resp.get_json() == {'owner': 'newacct1', 'is_valid_voter': False})
|
||||
resp = client.get('/api/voter/zzzold')
|
||||
check('V57 zzzold (kadaluarsa) → false',
|
||||
resp.get_json() == {'owner': 'zzzold', 'is_valid_voter': False})
|
||||
resp = client.get('/api/voter/zzhide')
|
||||
check('V57 zzhide (tersembunyi) → false',
|
||||
resp.get_json() == {'owner': 'zzhide', 'is_valid_voter': False})
|
||||
resp = client.get('/api/voter/tidakada1')
|
||||
check('V57 akun tak ada → false',
|
||||
resp.get_json() == {'owner': 'tidakada1', 'is_valid_voter': False})
|
||||
# V57: konsisten dgn db.eligible_voters + hasil distribusi (single source)
|
||||
for who in ('acct125', 'revoter1', 'expire1', 'newacct1',
|
||||
'zzzold', 'zzhide', 'tidakada1'):
|
||||
want = dbm.eligible_voters(who) # [] or one row
|
||||
got = client.get(f'/api/voter/{who}').get_json()['is_valid_voter']
|
||||
check(f'V57 endpoint == db.eligible_voters({who})',
|
||||
got == (len(want) > 0))
|
||||
legit = {r[0] for r in dbm.eligible_voters()}
|
||||
check('V57 distribusi-eligible == endpoint pool (semua acct + revote + expire)',
|
||||
legit == {f'acct{i:03d}' for i in range(1, 126)}
|
||||
| {'revoter1', 'expire1'})
|
||||
check('V57 pool ⊖ BARU/kadaluarsa/sembunyi/asing',
|
||||
legit.isdisjoint({'newacct1', 'zzzold', 'zzhide', 'tidakada1'}))
|
||||
|
||||
# ————— env config: default = konstanta lama —————
|
||||
import config
|
||||
check('V11 config default TARGET_BP', config.TARGET_BP == 'databisnisid')
|
||||
|
||||
+5
-4
@@ -88,6 +88,7 @@ def main():
|
||||
tmp = tempfile.mkdtemp()
|
||||
db.DB_PATH = os.path.join(tmp, 'test.db')
|
||||
_orig_query = db.query
|
||||
_orig_eligible = db.eligible_voters
|
||||
# V40: stempel last_vote SEJAK SEKARANG (relatif) agar umur jatuh di band
|
||||
# VALID (3..28 hari) — stempel hardcode berumur <3 hari justru dibuang.
|
||||
fresh = (datetime.now() - timedelta(days=5)).isoformat(timespec='seconds')
|
||||
@@ -149,7 +150,7 @@ def main():
|
||||
|
||||
# ————— V25: no-op — pemilih kosong / saldo kecil —————
|
||||
dist.fetch_balance = lambda: Decimal('100.0000')
|
||||
db.query = lambda sql, params=(): []
|
||||
db.eligible_voters = lambda owner=None: []
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = dist.run_distribution(dry_run=False)
|
||||
@@ -158,7 +159,7 @@ def main():
|
||||
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
||||
fresh)])
|
||||
dist.fetch_balance = lambda: Decimal('0.0000')
|
||||
db.query = _orig_query
|
||||
db.eligible_voters = _orig_eligible
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
rc = dist.run_distribution(dry_run=False)
|
||||
@@ -374,11 +375,11 @@ def main():
|
||||
and 'Gagal: 0' in intern[1][2])
|
||||
|
||||
# no-op (pemilih kosong) → ⊥ notif baru
|
||||
db.query = lambda sql, params=(): []
|
||||
db.eligible_voters = lambda owner=None: []
|
||||
with redirect_stdout(io.StringIO()):
|
||||
dist.run_distribution(dry_run=False)
|
||||
check('V30 no-op ⊥ notif', len(tg['posts']) == 4)
|
||||
db.query = _orig_query
|
||||
db.eligible_voters = _orig_eligible
|
||||
|
||||
# failure (⊥ key) → notif gagal ke INTERNAL saja (ops detail)
|
||||
dist.BP_PRIVATE_KEY = ''
|
||||
|
||||
Reference in new issue
Block a user