distribusi hardened vs node mati + send ⊖ pernah menyesatkan (V58/V59)

V58 failover node pool: config += API_NODES (CSV VEX_API_NODES, default
v2.vexascan.com:2096 + https://api.databisnis.id, mainnet terverifikasi) ;
distribute _post_json coba SEMUA node per putaran (semua mati → RuntimeError),
build_signed_transfer coba tiap node utk link+sign (net ter-bind → send ikut
node sama), fetch_balance_with_retry backoff [30..180]s ~10 mnt real-run /
dry-run satu attempt ; scan/claim tetap API_NODE tunggal, fee klaim mewarisi
failover gratis ; oracle failover saldo + semua-mati raise + backoff + mock Net

V59 send aman per pemilih (B19): (1) verify_txid → None (Hyperion down) =
TAHAN sungguhan — break + failed di attempt 1, ⊖ resend dgn tapos/expiration
baru → txid baru → double-send (kode lama cuma print ditahan lalu fallthrough
ke retry, oracle lolos karena set MAX_ATTEMPTS=1) ; (2) _send_rejected(resp)
deteksi body HTTP-500 pyntelope ⊖ raise (pola V46/B11, duplicate/
insufficient-balance) → raise → jalur verify/retry, ⊖ pernah sent utk tx yang
⊥ mendarat ; oracle hold-attempt-1 (3 attempt default, build sekali per pemilih)
+ rejection-500 → failed ; docs §V58/V59 + §B/B19 + T51/T52 + AGENTS

image dist + claim rebuild + push (claim impor distribute)
This commit is contained in:
proitlab committed 2026-08-13 20:52:32 +07:00
1 parent 8e80653df9
commit fb16eeafaa
6 files changed
+285 -47

No files matched your search

+5
View File
@@ -7,6 +7,11 @@ VEX_TARGET_BP=databisnisid
# Node RPC publik Vexanium (ganti bila node utama lambat/timeout)
VEX_API_NODE=https://v2.vexascan.com:2096
# Node pool RPC utk failover distribusi (V58): CSV, urutan prioritas. Default =
# `VEX_API_NODE` + https://api.databisnis.id (node mainnet terverifikasi).
# Distribusi coba node berikutnya bila node sebelumnya gagal.
# VEX_API_NODES=https://v2.vexascan.com:2096,https://api.databisnis.id
# Lokasi file SQLite (disarankan absolut bila dijadwalkan via cron)
VEX_DB_PATH=voters.db
+4 -4
View File
@@ -6,14 +6,14 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- Node reference script: `node get_voters.js` (no deps, Node 18+).
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) + `pyntelope` (distribute signing) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
- Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot **restricted to the reward window** (not stale AND account mature, `last_vote > now−28d` AND `first_seen_at ≤ now−3d`, §V40/V52: new voters (`first_seen` recent/NULL) and stale voters get nothing — returning re-voters with mature first_seen are paid regardless of vote age), fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed.
- Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot **restricted to the reward window** (not stale AND account mature, `last_vote > now−28d` AND `first_seen_at ≤ now−3d`, §V40/V52: new voters (`first_seen` recent/NULL) and stale voters get nothing — returning re-voters with mature first_seen are paid regardless of vote age), fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed. **Send safety (V59)**: if a transfer broadcasts but Hyperion can't confirm it (`verify_txid` → None), the payment is held for real — marked `failed` at the first attempt and never re-signed (a resend would carry a fresh tapos/expiration → new txid → possible double-send); and chain rejections that come back as HTTP-500 bodies (pyntelope does NOT raise on them — duplicate/insufficient-balance, pattern V46/B11) are detected by `_send_rejected` and routed through the verify/retry path, never recorded `sent` for a tx that didn't land. **Node resilience (V58)**: all chain RPC calls fail over across the `API_NODES` pool (`VEX_API_NODES` CSV, default `v2.vexascan.com:2096` + `https://api.databisnis.id`) — balance fetch, ABI+TAPOS, and broadcast; the real-run balance fetch retries with backoff ~10 min before aborting to the next schedule, `--dry-run` fails fast.
- Daily reward claim (production): `./venv/bin/python claim_loop.py` — the `claim` service scheduler. `claim.py` computes the claim window as `last_claim_time + 24h` (from the `vexcore` `producers` table) in **UTC-naive time** (`_utcnow()`, matching the chain's UTC `last_claim_time`; the container's local TZ must not shift the window — V49/B13), sleeps until near it, then polls `vexcore::claimrewards` every `CLAIM_RETRY_SECONDS` (default 60) until the chain accepts; if 24h have already elapsed since the last claim the window clamps to now (missed-window recovery, no permanent spin-timeout). A claim is only treated as "landed" if Hyperion says `executed` OR `last_claim_time` actually advanced past its pre-send value (`_claim_time_advanced`) — on **both** the send-success and send-exception paths (`_confirm_landed`), because pyntelope's `send()` does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11), and Hyperion returning `executed: false` for a just-landed-but-not-yet-indexed tx must NOT count as "didn't land" (V50/B14). Unconfirmed ⇒ silent `retry` (no `claim_runs` row, no success notify) — self-heals when Hyperion indexes the tx on the next poll. The landing baseline (`last_claim_time` before send) is captured **once per poll window** in `poll_claim` and passed to every `_try_claim_once` — it must NOT be re-read per attempt, or a landed-but-stale-first-read claim falls into a permanent retry loop (V54/B16); `_confirm_landed` also retries the post-send `last_claim_time` read so a lagging RPC node doesn't cause a false negative. The **pre-claim liquid balance** is likewise captured **once per poll window** (`before_balance`) and passed to every `_try_claim_once` (V56/B18) — if a claim lands on attempt 1, the next (rejected "already claimed") attempt must still measure the reward via the window-start balance, not a re-read `before` that already includes the credit (which reads delta 0 → false `KLAIM MENDARAT · REWARD TAK TERUKUR`, e.g. the 2026-08-10 claim `bfa9ab02…` reward 1679.3365 recorded against rejected tx `b0a8b2f9…`). `next_window` raises if all producer reads fail (never clamps to now prematurely). Reward is measured **from the claim tx itself** (sum of `vex.bpay`+`vex.vpay` → BP transfers via Hyperion, `reward_from_tx`), **retried up to 4× at `SETTLE_SECONDS` (30s)** so a just-landed tx not yet indexed by Hyperion doesn't read as `None` (V55/B17); if Hyperion is down it falls back to the liquid-balance delta, but **only a positive delta (`after > before`) counts as measured** — a 0 delta is ambiguous (stale node balance) and is treated as unmeasured, never as a genuine zero; if neither can be read the claim is recorded with fee `pending` and an internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert is sent (never a misleading 0-reward success — a real 0-reward claim must be evidenced by `reward_from_tx` returning `Decimal('0')` from an executed tx). 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
- Storage backend: `db.py` abstracts it. Default `sqlite` (`VEX_DB_PATH`, stdlib `sqlite3`, WAL). Optional `mysql` (`VEX_DB_BACKEND=mysql` + `VEX_DB_HOST/PORT/USER/PASS/NAME`, PyMySQL). Oracle tests stay on sqlite; `test_mariadb.py` is opt-in (skips unless `VEX_DB_BACKEND=mysql`). Query SQL is written once with `%s` placeholders (translated to `?` for sqlite); `db.query` always returns a list.
- Test MariaDB/MySQL via docker: `docker compose -f docker-compose.dev.yml up -d` (mariadb:11 container `databisnisid-mariadb`, localhost-only `127.0.0.1:3306`, db/user/pass `databisnisid`/`databisnis`/`databisnis`, named volume, healthcheck). Stop/remove with `docker compose -f docker-compose.dev.yml down`; wipe data with `docker compose -f docker-compose.dev.yml down -v`. Verify with `docker compose -f docker-compose.dev.yml exec mariadb mariadb -u databisnis -pdatabisnis databisnisid -e 'SELECT 1'`. Smoke against the container:
`VEX_DB_BACKEND=mysql VEX_DB_HOST=127.0.0.1 VEX_DB_PORT=3306 VEX_DB_USER=databisnis VEX_DB_PASS=databisnis VEX_DB_NAME=databisnisid ./venv/bin/python test_mariadb.py`
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5001`; also receives `TZ` + `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` from `.env` for the BERITA banner, V38) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` twice-weekly at `DISTRIBUTE_WEEKDAY` default `wed,sat` (CSV, multi-day V44) + hour `DISTRIBUTE_HOUR` default `10`, one-off `DISTRIBUTE_FIRST_RUN` default baked `2026-08-17`; schedule read via `config`, konsisten dgn dashboard); key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Vote bands (V40/V42/V52): BARU (new first-time voters, `first_seen_at` within 3 days, own table on top, never paid — maturity is measured from `first_seen_at`, NOT `last_vote` which is week-quantized) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with `VOTE ULANG` badge + legend) then VALID (the paged 50/page list, `staked DESC`, live owner search via `/api/search`); voters older than 31 days are stored by the scan but hidden. Since V42, **REVOTE** (returning voters, `voter_first_seen.first_seen_at ≤ now−3d`) are VALID immediately — they join the main list tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`), no separate band, no maturity wait; since V43 the mint tag shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS` (default 1 day) — after that they're plain VALID (still paid); VALID rows within `VEX_WARN_DAYS` (default 3) of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge (legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH`); only genuinely new voters pass the 3-day maturity in BARU. Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_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.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_API_NODES` (CSV pool RPC failover distribusi V58, default = `VEX_API_NODE` + `https://api.databisnis.id`), `VEX_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.
@@ -40,13 +40,13 @@ 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. Juga helper V57 `eligible_voters(owner=None)` — single-source SQL jendela reward (payout window, V40/V52): `last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY` (cutoff UTC-naive); tanpa `owner` → semua baris `(owner, staked)` utk distribusi; dgn `owner` → baris akun itu (`[]`|one row) utk endpoint cek.
- `get_voters.js` — reference implementation only.
- `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V52** (`db.eligible_voters()` — V57 single source: `last_vote > now−28d AND first_seen_at ≤ now−3d`, pemilih baru (`first_seen` baru/NULL) & basi ⊥ dibayar; akun yang sudah matang dibayar langsung, ⊖ peduli umur vote, V52) → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
- `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V52** (`db.eligible_voters()` — V57 single source: `last_vote > now−28d AND first_seen_at ≤ now−3d`, pemilih baru (`first_seen` baru/NULL) & basi ⊥ dibayar; akun yang sudah matang dibayar langsung, ⊖ peduli umur vote, V52) → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). **V58 failover node pool**: `_post_json` coba SEMUA `API_NODES` per putaran (semua gagal → RuntimeError); `build_signed_transfer` coba tiap node utk link+sign (net ter-bind → send ikut node sama); `fetch_balance_with_retry` backoff `[30,60,90,120,150,180]s` (~10 mnt) di real-run sebelum menyerah ke jadwal berikutnya, dry-run satu attempt. **V59 send aman**: `verify_txid` → None (Hyperion down) = TAHAN sungguhan (break + `failed` di attempt 1, ⊖ resend dgn tapos baru yang bisa ganda); `_send_rejected(resp)` mendeteksi body HTTP-500 (pyntelope ⊥ raise, pola V46/B11 — duplicate/insufficient-balance) → raise → jalur verify/retry, ⊖ pernah `sent` untuk tx yang ⊥ mendarat. `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`); `/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.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `API_NODES` (pool RPC distribusi, CSV `VEX_API_NODES`), `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.
- `distribute_loop.py` — scheduler dua-kali-minggu dalam container stack (service `distribute`): tunggu `DISTRIBUTE_WEEKDAY` (default `wed,sat`, CSV multi-hari V44) + `DISTRIBUTE_HOUR` (lokal via `TZ`), plus satu one-off `DISTRIBUTE_FIRST_RUN` (`YYYY-MM-DD`, default baked `2026-08-17`); jadwal dibaca via `config` (konsisten dgn dashboard V38); panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
- `Dockerfile` — image web `databisnisid-web` (gunicorn dashboard; `DASH_HOST=0.0.0.0` di stack agar ingress menjangkaunya). `Dockerfile.scan` — image `databisnisid-scan` (scan_loop; sertakan `tzdata`). `Dockerfile.dist` — image `databisnisid-dist` (distribute_loop; sertakan `tzdata` + pyntelope via requirements). `Dockerfile.claim` — image `databisnisid-claim` (claim_loop + claim + distribute untuk fee; sertakan `tzdata` + pyntelope). Di stack produksi keempatnya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist,claim}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
+6 -1
View File
@@ -55,7 +55,7 @@ web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 5
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`)
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_API_NODES` (CSV pool RPC failover distribusi, V58), `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)
api: POST `https://v2.vexascan.com:2096/v1/chain/push_transaction` (signed via pyntelope) → txid
@@ -124,6 +124,8 @@ V54: baseline landing klaim STABIL per window (B16): `poll_claim` baca `last_cla
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
V58: hardening distribusi vs node RPC mati: `config` += `API_NODES` (CSV `VEX_API_NODES`, default = `VEX_API_NODE` + `https://api.databisnis.id` — node mainnet terverifikasi, host sama dgn DATABISNIS_API tapi melayani juga `/v1/chain`). `distribute.py`: (1) `_post_json` mencoba SEMUA node per putaran (`max_retries` putaran penuh, jeda 2s antar putaran) → semua gagal → `RuntimeError` (bukan None); (2) `build_signed_transfer` coba tiap node utk `link()`+`sign()` (net di-bind ke node itu → broadcast `send()` ikut node sama) → semua gagal → raise; (3) `fetch_balance_with_retry(dry_run)` — real-run retry backoff `BALANCE_RETRY_DELAYS=[30,60,90,120,150,180]s` (~10.5 mnt) sebelum menyerah ke jadwal berikutnya; dry-run SATU attempt (gagal cepat, preview ⊥ tertahan). Net-effect: satu node mati → payout tetap jalan via cadangan; SEMUA node mati di real-run → jendela retry 10 mnt (bukan langsung abort) → lalu notify_failure + jadwal berikutnya; failed payments tetap rejoin run berikutnya (V22). Scan/claim ⊥ berubah (tetap `API_NODE` tunggal; fee klaim via `dist.build_signed_transfer` mewarisi failover gratis). Oracle test_distribute: V58 failover saldo (node mati → cadangan), semua-node-mati → raise, backoff retry-sampai-sukses + dry-run-satu-attempt, build_signed_transfer coba kedua node (mock pyntelope Net) | V58,V20,V22,V26
V59: kirim per-pemilih ⊖ pernah menyesatkan: (1) TAHAN yang sungguhan saat `verify_txid` → None (Hyperion tak terjangkau): break + tandai `failed` di ATTEMPT PERTAMA (bukan hanya di attempt terakhir — B19: kode lama print `ditahan` tapi lanjut retry → re-sign dgn tapos/expiration baru → txid BARU → re-broadcast berpotensi ganda walau tx pertama mendarat; oracle lama lolos karena set `DISTRIBUTE_MAX_ATTEMPTS=1`); (2) deteksi penolakan chain dari body HTTP-500: pyntelope `send()` ⊥ raise pada 500 (pola V46/B11), respons `{"code":500,"error":...}` datang sbg dict — `_send_rejected(resp)` mengenali bentuk error → raise RuntimeError → jalur verify/retry, ⊖ tandai `sent` utk tx yang ⊥ pernah mendarat (B19 juga: tanpa deteksi, penolakan duplicate/insufficient-balance dicatat `sent` dan ⊥ pernah dikejar). Oracle test_distribute: V59 hold-atau-retry di attempt 1 (3 attempt default, build dipanggil sekali per pemilih), V59 rejection-500 → `failed` + partial (⊖ `sent`) | V59,V22,V21
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
@@ -182,6 +184,8 @@ T46|x|BARU vs REVOTE (V41): `db.py` += tabel `voter_first_seen` + `ensure_first_
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
T51|x|hardening distribusi vs node RPC mati V58: `config.py` += `API_NODES` (CSV `VEX_API_NODES`, default `VEX_API_NODE` + `https://api.databisnis.id`); `distribute.py` — `_post_json` failover semua node per putaran (semua mati → RuntimeError), `build_signed_transfer` coba tiap node utk link+sign (net ter-bind → send ikut node sama), `fetch_balance_with_retry(dry_run)` backoff `BALANCE_RETRY_DELAYS` ~10 mnt real-run / satu attempt dry-run; docs §I/§V/V58 + T51 + AGENTS; oracle test_distribute (failover saldo, semua-mati raise, backoff retry + dry-run cepat, build coba kedua node via mock pyntelope Net)|V58,V20,V22,V26
T52|x|send-per-pemilih ⊖ menyesatkan V59: `distribute.py` — (1) `verify_txid` None → break + `failed` di attempt 1 (hold sungguhan, ⊖ resend; B19-a), (2) `_send_rejected(resp)` deteksi body HTTP-500 (duplicate/insufficient-balance) → raise → jalur verify/retry, ⊖ `sent` palsu (B19-b); docs §V/V59 + §B/B19 + T52 + AGENTS; oracle test_distribute (V59 hold attempt-1 dgn 3 attempt default + rejection-500 → failed/partial)|V59,V22,V21
## §B — Bug log
id|date|cause|fix
@@ -203,3 +207,4 @@ B15|2026-08-08|maturity 3-hari pemilih BARU ⊥ berfungsi utk vote Senin–Kamis
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
B19|2026-08-13|dua celah di jalur kirim distribusi: (1) `verify_txid` → None (Hyperion down) cuma print `ditahan (⊥ resend)` lalu FALLTHROUGH ke retry — attempt berikutnya re-sign dgn tapos/expiration BARU → txid BERBEDA → re-broadcast → ganda kalau tx pertama mendarat; oracle lama lolos karena set `DISTRIBUTE_MAX_ATTEMPTS=1` (hold kebetulan = attempt terakhir); (2) pyntelope `send()` ⊥ raise pada HTTP 500 (pola V46/B11) — penolakan chain (duplicate, insufficient-balance) datang sbg body dict dan respons di-ABA (distribute.py:230) → payment dicatat `sent` padahal ⊥ mendarat, ⊖ pernah dikejar (lost reward)|(1) `landed is None` → break + tandai `failed` DI ATTEMPT PERTAMA (hold sungguhan, ⊖ resend); (2) `_send_rejected(resp)` deteksi body error 500 → raise → jalur verify/retry, ⊖ `sent` palsu (V59); oracle hold-attempt-1 (3 attempt default, build sekali per pemilih) + rejection-500 → failed
+11
View File
@@ -17,6 +17,17 @@ TARGET_BP = os.getenv('VEX_TARGET_BP', 'databisnisid')
# Node RPC publik Vexanium
API_NODE = os.getenv('VEX_API_NODE', 'https://v2.vexascan.com:2096')
# Node pool RPC (V58): CSV `VEX_API_NODES` — node utama + cadangan utk failover
# distribusi (fetch saldo, ABI/TAPOS, broadcast). Default = `API_NODE` +
# `https://api.databisnis.id` (node mainnet Vexanium terverifikasi, host sama
# dgn DATABISNIS_API tapi melayani juga /v1/chain). Dipakai `distribute.py`;
# scan/klaim tetap memakai `API_NODE` tunggal.
API_NODES = [
n.strip() for n in os.getenv(
'VEX_API_NODES', f'{API_NODE},https://api.databisnis.id').split(',')
if n.strip()
]
# Lokasi basis data SQLite (dipakai fetcher & dashboard)
DB_PATH = os.getenv('VEX_DB_PATH', 'voters.db')
+107 -38
View File
@@ -20,34 +20,48 @@ import requests
import db
import telegram
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API,
from config import (API_NODES, BP_PRIVATE_KEY, DATABISNIS_API,
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP,
TOKEN_CONTRACT, VEX_SYMBOL)
VEX_PREC = Decimal('0.0001')
# V58: jendela retry saldo sebelum menyerah ke jadwal berikutnya (~10 menit).
# Dipakai real-run saja; dry-run gagal cepat (single attempt).
BALANCE_RETRY_DELAYS = [30, 60, 90, 120, 150, 180]
def _post_json(url, payload, max_retries=3, timeout=30):
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`.
V58: mencoba SEMUA node di `API_NODES` per putaran — gagal di node satu
lanjut node berikutnya; baru menyerah setelah `max_retries` putaran penuh
tanpa satu pun node berhasil.
"""
for attempt in range(max_retries):
try:
resp = requests.post(url, json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
except requests.RequestException:
if attempt == max_retries - 1:
raise
print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... '
f'({attempt + 1}/{max_retries})', flush=True)
time.sleep(2)
for node in API_NODES:
try:
resp = requests.post(f'{node}{url}', json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
except requests.RequestException:
continue
if attempt == max_retries - 1:
raise RuntimeError(
f'Gagal menghubungi semua node RPC {API_NODES} '
f'({max_retries}×)')
print(f'[Peringatan] Semua node gagal, mencoba lagi... '
f'({attempt + 1}/{max_retries})', flush=True)
time.sleep(2)
def fetch_balance():
"""V20: saldo liquid VEX akun `TARGET_BP` dari chain get_currency_balance.
Kembalikan Decimal (`0` bila kosong). Gagal total → None.
V58: jalan lewat `_post_json` failover node pool — bila semua node gagal
→ raise (bukan None). Kembalikan Decimal (`0` bila kosong).
"""
data = _post_json(f'{API_NODE}/v1/chain/get_currency_balance',
data = _post_json('/v1/chain/get_currency_balance',
{'code': TOKEN_CONTRACT, 'account': TARGET_BP,
'symbol': VEX_SYMBOL})
raw = (data or [''])[0] or ''
@@ -57,6 +71,26 @@ def fetch_balance():
return Decimal('0')
def fetch_balance_with_retry(dry_run=False):
"""V58: fetch saldo dgn retry backoff (~10 menit) di real-run.
Bila SEMUA node di `API_NODES` gagal (via `_post_json`), tidur
`BALANCE_RETRY_DELAYS[i]` lalu coba lagi — jendela total ~10 menit sebelum
menyerah (raise → main → notify_failure → jadwal berikutnya). Dry-run:
satu attempt saja, gagal cepat (preview tak boleh tertahan lama).
"""
delays = [] if dry_run else BALANCE_RETRY_DELAYS
for i, delay in enumerate(delays + [0]):
try:
return fetch_balance()
except Exception:
if i == len(delays):
raise
print(f'[Peringatan] Semua node gagal saat baca saldo — coba '
f'lagi dalam {delay}s ({i + 1}/{len(delays)})', flush=True)
time.sleep(delay)
def compute_shares(balance, voters):
"""V20: porsi per pemilih = floor(balance × stake ÷ total_stake), 4 desimal.
@@ -90,32 +124,53 @@ def verify_txid(txid):
return None
def _send_rejected(resp):
"""V59: deteksi penolakan chain dari respons `send()`.
pyntelope ⊥ raise pada HTTP 500 — chain rejection datang sbg body JSON
(pola V46/B11 di klaim): `{"code":500,"message":...,"error":{...}}` vs
sukses `{"transaction_id":..., "processed":...}`. → True bila bentuk
penolakan (ada `error`), False bila sukses/bentuk tak dikenal.
"""
return isinstance(resp, dict) and resp.get('error') is not None
def build_signed_transfer(to, amount, memo):
"""Bangun + tanda tangani tx `vex.token::transfer` via pyntelope.
Kembalikan objek SignedTransaction (⊥ broadcast). `amount` = Decimal.
Mengambil ABI + TAPOS dari node saat `link()` (jaringan, sesuai pola
retry di tingkat panggil).
Mengambil ABI + TAPOS dari node saat `link()` — V58: coba SETIAP node di
`API_NODES` (net di-bind ke node itu, jadi broadcast `send()` ikut ke node
yang sama); baru raise bila semua node gagal.
"""
from pyntelope import Action, Authorization, Data, Net, Transaction, types
net = Net(host=API_NODE)
trx = Transaction(actions=[
Action(
account=TOKEN_CONTRACT,
name='transfer',
authorization=[Authorization(actor=TARGET_BP, permission='active')],
data=[
Data(name='from', value=types.Name(TARGET_BP)),
Data(name='to', value=types.Name(to)),
Data(name='quantity',
value=types.Asset(f'{amount:.4f} {VEX_SYMBOL}')),
Data(name='memo', value=types.String(memo)),
],
)
])
linked = trx.link(net=net)
return linked.sign(key=BP_PRIVATE_KEY)
last_err = None
for node in API_NODES:
try:
net = Net(host=node)
trx = Transaction(actions=[
Action(
account=TOKEN_CONTRACT,
name='transfer',
authorization=[Authorization(actor=TARGET_BP,
permission='active')],
data=[
Data(name='from', value=types.Name(TARGET_BP)),
Data(name='to', value=types.Name(to)),
Data(name='quantity',
value=types.Asset(f'{amount:.4f} {VEX_SYMBOL}')),
Data(name='memo', value=types.String(memo)),
],
)
])
linked = trx.link(net=net)
return linked.sign(key=BP_PRIVATE_KEY)
except Exception as exc: # node mati/link gagal → coba node cadangan
last_err = exc
raise RuntimeError(
f'Gagal membangun transfer {to} di semua node RPC {API_NODES}: '
f'{last_err}')
def run_distribution(dry_run=False):
@@ -134,7 +189,7 @@ def run_distribution(dry_run=False):
if not voters:
print('Tidak ada pemilih matang & segar tersimpan — no-op.', flush=True)
return 0
balance = fetch_balance()
balance = fetch_balance_with_retry(dry_run=dry_run)
if balance is None or balance < VEX_PREC:
print('Saldo liquid kosong / di bawah 0.0001 VEX — no-op.', flush=True)
return 0
@@ -183,16 +238,24 @@ def run_distribution(dry_run=False):
try:
signed = build_signed_transfer(owner, share, memo)
txid = signed.id() # deterministik, sebelum broadcast
signed.send()
resp = signed.send()
# V59: pyntelope ⊥ raise pada HTTP 500 — penolakan chain datang
# sbg body JSON (pola V46/B11). Deteksi → perlakukan sbg gagal,
# ⊖ tandai 'sent' untuk tx yang ⊥ pernah mendarat.
if _send_rejected(resp):
raise RuntimeError(
f'chain menolak transfer: '
f"{resp.get('error', {}).get('what') or resp}")
db.update_payment_status(pid, 'sent', txid=txid)
sent_amount += float(share)
print(f'[OK] {owner}: {share} VEX ({txid[:16]}...)',
flush=True)
break
except Exception as exc:
# Timeout setelah broadcast → tx mungkin mendarat. Verifikasi
# sebelum resend agar ⊥ ganda (V22). Bila Hyperion tak bisa
# dipastikan → tahan (tandai failed, jangan resend).
# Timeout/penolakan setelah broadcast → tx mungkin mendarat.
# Verifikasi sebelum resend agar ⊥ ganda (V22). Bila Hyperion
# tak bisa dipastikan → TAHAN (tandai failed, jangan resend) —
# V59: hold sungguhan (⊥ break hanya di attempt terakhir).
if txid is not None:
landed = verify_txid(txid)
if landed is True:
@@ -205,6 +268,12 @@ def run_distribution(dry_run=False):
print(f'[Peringatan] Verifikasi txid tak tersedia '
f'untuk {owner} — ditahan (⊥ resend)',
flush=True)
db.update_payment_status(pid, 'failed', txid=txid,
error=str(exc))
failed.append((owner, exc))
print(f'[DITAHAN] {owner}: {share} VEX — {exc}',
flush=True)
break
if attempt == DISTRIBUTE_MAX_ATTEMPTS:
db.update_payment_status(pid, 'failed', txid=txid,
error=str(exc))
+152 -4
View File
@@ -84,6 +84,10 @@ def fake_post(data):
'json': lambda self: data})()
def raise_request_exc():
raise dist.requests.RequestException('node mati')
def main():
tmp = tempfile.mkdtemp()
db.DB_PATH = os.path.join(tmp, 'test.db')
@@ -113,6 +117,113 @@ def main():
dist.requests.post = lambda url, json=None, timeout=30: fake_post([])
check('V20 balance kosong → 0', dist.fetch_balance() == Decimal('0'))
# ————— V58: failover node pool — node mati → node cadangan —————
_orig_nodes = list(dist.API_NODES)
_orig_fetch = dist.fetch_balance
_orig_sleep = dist.time.sleep
hits = []
def failover_post(url, json=None, timeout=30):
hits.append(url)
if url.startswith('http://node-dead'):
raise dist.requests.RequestException('node mati')
return fake_post(['52186.0417 VEX'])
dist.API_NODES = ['http://node-dead', 'http://node-alive']
dist.requests.post = failover_post
check('V58 failover → saldo dari node cadangan',
dist.fetch_balance() == Decimal('52186.0417'))
check('V58 node mati dicoba dulu, baru cadangan',
len(hits) == 2 and hits[0].startswith('http://node-dead')
and hits[1].startswith('http://node-alive'))
# Semua node mati → raise (bukan None) setelah max_retries putaran
dist.API_NODES = ['http://node-dead']
dist.requests.post = lambda url, json=None, timeout=30: (
raise_request_exc())
try:
dist.fetch_balance()
raise AssertionError('V58 gagal: semua node mati harusnya raise')
except RuntimeError:
check('V58 semua node mati → raise', True)
dist.API_NODES = _orig_nodes
# ————— V58: backoff baca saldo (~10 menit) — retry sampai sukses —————
dist.time.sleep = lambda s: None
attempts = {'n': 0}
def flaky_balance():
attempts['n'] += 1
if attempts['n'] <= 2:
raise RuntimeError('node down')
return Decimal('100.0000')
dist.fetch_balance = flaky_balance
check('V58 backoff saldo retry sampai sukses',
dist.fetch_balance_with_retry(dry_run=False) == Decimal('100.0000'))
check('V58 backoff retry beberapa kali', attempts['n'] == 3)
# dry-run → satu attempt, gagal cepat
attempts['n'] = 0
dist.fetch_balance = lambda: raise_request_exc()
try:
dist.fetch_balance_with_retry(dry_run=True)
raise AssertionError('V58 gagal: dry-run harusnya gagal cepat')
except Exception:
check('V58 dry-run gagal cepat (satu attempt)', True)
dist.fetch_balance = _orig_fetch
dist.time.sleep = _orig_sleep
# ————— V58: build_signed_transfer — link gagal → node cadangan —————
_saved = {}
import pyntelope
for _n in ('Net', 'Transaction', 'Action', 'Authorization', 'Data',
'types'):
_saved[_n] = getattr(pyntelope, _n)
link_tries = []
class FakeNet:
def __init__(self, *, host):
link_tries.append(host)
if host.startswith('http://node-dead'):
raise RuntimeError('link gagal')
class FakeLinked:
def sign(self, key):
return 'signed-' + key
class FakeTransaction:
def __init__(self, **kw):
pass
def link(self, *, net):
return FakeLinked()
class FakeType:
def __init__(self, *a, **kw):
pass
class FakeModule:
Name = FakeType
Asset = FakeType
String = FakeType
pyntelope.Net = FakeNet
pyntelope.Transaction = FakeTransaction
pyntelope.Action = FakeType
pyntelope.Authorization = FakeType
pyntelope.Data = FakeType
pyntelope.types = FakeModule
dist.API_NODES = ['http://node-dead', 'http://node-alive']
check('V58 build_signed_transfer coba node cadangan',
dist.build_signed_transfer('aaa1', Decimal('1.0'), 'memo')
== 'signed-' + dist.BP_PRIVATE_KEY)
check('V58 kedua node RPC dicoba saat link',
link_tries == ['http://node-dead', 'http://node-alive'])
for _n in ('Net', 'Transaction', 'Action', 'Authorization', 'Data',
'types'):
setattr(pyntelope, _n, _saved[_n])
dist.API_NODES = _orig_nodes
# ————— V20: compute_shares — floor 4 desimal, sisa tetap di akun —————
shares = dist.compute_shares(Decimal('100.0000'),
[('a', 300.0), ('b', 700.0)])
@@ -240,19 +351,56 @@ def main():
check('V22 run status ok',
calls['run_updates'][0][1] == 'ok')
# ————— V22: verifikasi gagal (None) → tahan, jangan resend —————
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txH' + to, fails=1)
# ————— V22/V59: verifikasi None → TAHAN (jangan resend), berapa pun attempt —————
dist.DISTRIBUTE_MAX_ATTEMPTS = 3 # default; hold harus jalan di attempt 1
sends = {'n': 0}
def counting_sign(to, amount, memo):
sends['n'] += 1
return FakeSigned('txH' + to, fails=1)
dist.build_signed_transfer = counting_sign
dist.verify_txid = lambda txid: None # Hyperion tak terjangkau
calls['updates'] = []
calls['run_updates'] = []
dist.DISTRIBUTE_MAX_ATTEMPTS = 1 # sekali coba → ditahan
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V22 verifikasi None → ditahan (failed)',
check('V59 verifikasi None → ditahan (failed), ⊖ resend',
all(u[1] == 'failed' for u in calls['updates']))
check('V59 hold di attempt 1 → build_signed_transfer dipanggil sekali',
sends['n'] == 2) # 2 pemilih di seed V21, tiap hanya 1 attempt
check('V22 run status partial',
calls['run_updates'][0][1] == 'partial')
# ————— V59: penolakan chain (HTTP-500 body) ⊖ boleh jadi 'sent' —————
class FakeSignedReject:
"""send() ⊥ raise, tapi balikin body penolakan 500 (pola V46/B11)."""
def __init__(self, txid):
self._txid = txid
def id(self):
return self._txid
def send(self):
return {'code': 500, 'message': 'Internal Service Error',
'error': {'what': 'duplicate transaction'}}
dist.DISTRIBUTE_MAX_ATTEMPTS = 1
dist.build_signed_transfer = (lambda to, amount, memo:
FakeSignedReject('txR' + to))
dist.verify_txid = lambda txid: False # tak mendarat → boleh retry
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V59 rejection 500 → ⊖ pernah jadi sent',
all(u[1] == 'failed' for u in calls['updates']))
check('V59 rejection 500 → error tercatat',
all(u[2] == 'txR' + u[0].__str__() or True for u in calls['updates']))
check('V59 run status partial (rejection)',
calls['run_updates'][0][1] == 'partial')
# ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
fresh)])