Compare commits
4
Commits
8e80653df9
...
d6b5927307
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6b5927307 | ||
|
|
69704fa23d | ||
|
|
663770f51b | ||
|
|
fb16eeafaa |
No files matched your search
+14
-2
@@ -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
|
||||
|
||||
@@ -58,8 +63,15 @@ VEX_REVOTE_TAG_DAYS=1
|
||||
# SEGERA` (segera kadaluarsa, masih dibayar).
|
||||
VEX_WARN_DAYS=3
|
||||
|
||||
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard)
|
||||
DATABISNIS_API=https://api.databisnis.id
|
||||
# Hyperion API untuk saldo liquid akun + verifikasi txid (balance BP,
|
||||
# disajikan dashboard; verifikasi distribusi/klaim). Nama lama `DATABISNIS_API`
|
||||
# masih dibaca utk backward-compat deploy lama.
|
||||
HYPERION_API=https://api.databisnis.id
|
||||
|
||||
# Pool Hyperion (V60, failover): CSV host yang melayani /v2/* — verifikasi
|
||||
# txid distribusi/klaim + saldo liquid dashboard. Default = HYPERION_API +
|
||||
# API_NODES (keduanya mainnet terverifikasi layani /v2). Kosongkan → default.
|
||||
# VEX_HYPERION_NODES=https://api.databisnis.id,https://v2.vexascan.com:2096
|
||||
|
||||
# Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown
|
||||
DASH_LIQUID_TTL=60
|
||||
|
||||
@@ -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/V61)**: if a transfer broadcasts but Hyperion can't confirm it, the payment is held for real — marked `failed` at the first attempt and never re-signed (a resend would carry a fresh tapos/expiration → new txid → possible double-send). `verify_txid → None` (Hyperion unreachable) and `executed: false` (index lag, V50/B14) BOTH hold now — the send-loop exception path runs `_verify_settled` (retries the verify 3× with 2s delay to let Hyperion catch up; None → False immediately, pool fully dead) and only `True` marks `sent` + breaks, anything else holds (V61/B21). Chain rejections that come back as HTTP-500 bodies (pyntelope does NOT raise on them — duplicate/insufficient-balance, pattern V46/B11) are detected by `_send_rejected` (V61: WHITELIST — success is only a dict with `transaction_id` + receipt `executed`/no status; any other 500 body or soft_fail → rejected) and routed through the verify/retry path, never recorded `sent` for a tx that didn't land. All `/v2/*` reads guard `isinstance(data, dict)` so a non-dict 200 (proxy misconfig) is skipped, never a crash (V61/B23). **Node resilience (V58)**: all chain RPC calls fail over across the `API_NODES` pool (`VEX_API_NODES` CSV, default `v2.vexascan.com:2096` + `https://api.databisnis.id`) — balance fetch, ABI+TAPOS, and broadcast; the real-run balance fetch retries with backoff ~10 min before aborting to the next schedule, `--dry-run` fails fast. **Hyperion resilience (V60)**: every `/v2/*` read (`verify_txid`, `claim.reward_from_tx`, dashboard SALDO LIQUID) fails over across the `HYPERION_NODES` pool (`VEX_HYPERION_NODES` CSV, default = `HYPERION_API` + `API_NODES` dedup — both hosts verified to serve Hyperion) via `distribute._get_json` (GET mirror of the V58 `_post_json`, 3 rounds, 2s), so a single Hyperion blip no longer force-holds payments or fails reward measurement.
|
||||
- Daily 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_HYPERION_NODES` (CSV pool Hyperion failover V60, default = `HYPERION_API` + `API_NODES` dedup), `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `VEX_REVOTE_TAG_DAYS`, `VEX_WARN_DAYS`, `HYPERION_API` (rename dari `DATABISNIS_API`; nama lama masih dibaca utk backward-compat), `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_WEEKDAY` (CSV multi-hari V44, default `wed,sat`), `DISTRIBUTE_FIRST_RUN` (default baked `2026-08-17`), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
|
||||
- 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.
|
||||
|
||||
@@ -29,10 +29,10 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
|
||||
- Freshness filter (spec §V13/V40): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(weeks since 2000 / 52)` → `last_vote = 2000-01-01 + round(52 × log2(weight / (staked×10000))) weeks` — the chain (`stake2vote`) quantizes the exponent to whole weeks (`int64((now−2000-01-01)/(86400×7))/52.0`), so derived dates land on 7-day boundaries (B12; earlier 365.25-day divisor drifted ~33 days forward for 2026 votes). Since V40 `normalize()` stores **every** BP voter with a derivable `last_vote` — any age; only unverifiable votes (zero/empty weight) are dropped. Banding (BARU/VALID/KADALUARSA), display and payout are computed at query time (dashboard cuts 28/31 days on `last_vote` age; distribution pays `last_vote > now−28d`). **Maturity (3 days) is measured from `first_seen_at`, not `last_vote`** — `last_vote` is week-quantized so a new voter voting Mon–Thu would look 4–6 days old and bypass the wait (B15); V52 uses `voter_first_seen.first_seen_at` (scan-precise to the second) for the new-voter wait. The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`. Since V41, `voter_first_seen.first_seen_at` separates PEMILIH BARU (new, first-seen within the 3-day maturity) from REVOTE (returning re-voters); since V42 REVOTE are VALID immediately (paid + shown in the main list, mint tag), only new voters wait out maturity; since V43 the mint tag lasts only `VEX_REVOTE_TAG_DAYS` (1) and VALID rows nearing the KADALUARSA cutoff (within `VEX_WARN_DAYS`, 3) get a `VOTE ULANG SEGERA` badge — display-only.
|
||||
- The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of `distribute_payments.amount` where `status='sent'` (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
|
||||
- Token contract is `vex.token` (NOT `eosio.token` — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f`. Distribution signs `vex.token::transfer` with the BP `active` key.
|
||||
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {DATABISNIS_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next run. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
|
||||
- Distribution (spec §V19–V27): each run pays the whole liquid balance pro-rata by stored `staked`, shares floored at 4 decimals with dust left in the account, one transfer per voter. A txid that can't be confirmed (`GET {HYPERION_API}/v2/history/get_transaction?id=<txid>` returns no `executed`) is never re-sent the same run — that payment stays `failed` and rejoins the next run. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
|
||||
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam; a window already ≥24h past clamps to now so a missed claim is caught up, never spin-timeout (B8). `get_table_rows` responses are always `{"rows":[...]}` — parse `data.get('rows')`, never `data[0]` (B7, V39). Landed detection = Hyperion `executed` OR `last_claim_time` advanced past pre-send value, checked on BOTH the send-success and send-exception paths (`_confirm_landed`) because pyntelope's `send()` returns HTTP 500 as a body instead of raising on a chain rejection (V46, B11); Hyperion `executed: false` on a not-yet-indexed tx still falls back to the `last_claim_time` advance check (V50, B14). `next_window` raises if all producer reads fail so a flaky node never triggers a premature claim. Reward = sum of `vex.bpay`+`vex.vpay` → BP transfers read from the claim tx via Hyperion (`reward_from_tx`), balance-delta as fallback (V33); if neither can be read → fee `pending` + internal `KLAIM MENDARAT · REWARD TAK TERUKUR` alert, never a misleading 0-reward success (B10). 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run.
|
||||
- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr); `/history` uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to `https://vexascan.com/transaction/{txid}` — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
|
||||
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {DATABISNIS_API}/v2/state/get_account?account=<BP>`. The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
|
||||
- Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (`account.core_liquid_balance`) fetched from `GET {node}/v2/state/get_account?account=<BP>` across the `HYPERION_NODES` pool (V60 — host mati → cadangan, SALDO tetap live). The dashboard fetches it live but caches per-worker in memory for `DASH_LIQUID_TTL` (default 60s); a failed fetch keeps the last value (or renders `—` if none ever succeeded) and the failure is also cooled-down so the API isn't hammered. The dashboard still never writes to the DB.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -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).
|
||||
- `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).
|
||||
- `distribute.py` — pembayaran harian: fetch liquid balance → baca pemilih **band reward V40/V52** (`db.eligible_voters()` — V57 single source: `last_vote > now−28d AND first_seen_at ≤ now−3d`, pemilih baru (`first_seen` baru/NULL) & basi ⊥ dibayar; akun yang sudah matang dibayar langsung, ⊖ peduli umur vote, V52) → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). **V58 failover node pool**: `_post_json` coba SEMUA `API_NODES` per putaran (semua gagal → RuntimeError); `build_signed_transfer` coba tiap node utk link+sign (net ter-bind → send ikut node sama); `fetch_balance_with_retry` backoff `[30,60,90,120,150,180]s` (~10 mnt) di real-run sebelum menyerah ke jadwal berikutnya, dry-run satu attempt. **V59 send aman**: `verify_txid` → None (Hyperion down) = TAHAN sungguhan (break + `failed` di attempt 1, ⊖ resend dgn tapos baru yang bisa ganda). **V61 send-path hardening**: `_send_rejected(resp)` WHITELIST — sukses HANYA dict ber-`transaction_id` + receipt `executed`/tanpa-status, body HTTP-500 apa pun (pyntelope ⊥ raise, pola V46/B11) → tolak → raise → jalur verify/retry, ⊖ pernah `sent` utk tx yg ⊥ mendarat; `_verify_settled(txid)` retry `verify_txid` 3× dgn delay utk lag indeks (None → False langsung, pool mati semua) — jalur exception send-loop hold (`failed`+break) pada apa pun selain True, ⊖ RESEND utk `executed:false` (B21). **V60 Hyperion failover**: `_get_json` (GET mirror `_post_json`, 3 putaran, 2s, coba semua `HYPERION_NODES` per putaran → semua gagal → None) dipakai `verify_txid` — None kini berarti SEMUA host Hyperion gagal, jadi hold V59 jarang palsu; semua pembaca `/v2/*` guard `isinstance(data, dict)` (non-dict 200 → skip/skip-node/None, ⊖ crash run, B23). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
|
||||
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`, di-retry V46) → `next_window` = +24 jam, math UTC-naive via `_utcnow()` (V49/B13 — ⊥ `datetime.now()` lokal yang geser +TZ → poll prematur) → (≥24 jam lewat → clamp ke now, recovery B8); tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; deteksi mendarat via `_confirm_landed` di KEDUA jalur (send sukses & exception) = Hyperion `executed` ATAU `last_claim_time` maju dari baseline (V46/B11 — `send()` pyntelope ⊥ raise saat chain menolak, HTTP 500 sbg body; Hyperion `executed: false` utk tx baru-mendarat-belum-terindeks juga dicek lewat `_claim_time_advanced`, V50/B14); tak terkonfirmasi → retry senyap; reward = isi tx via Hyperion (bpay+vpay → BP, `reward_from_tx` via `dist._get_json` pool `HYPERION_NODES`, V60) prioritas — di-RETRY 4× dgn `SETTLE_SECONDS` (30) beri waktu Hyperion indeks tx baru (V55/B17); selisih saldo fallback HANYA delta positif (`after > before`), delta 0 ambigu → ⊥ dianggap reward 0; baseline saldo (`before_balance`) dibaca SEKALI per window & dibawa tiap attempt (V56/B18) — klaim mendarat attempt 1 lalu attempt 2 ditolak (already claimed) ⊖ boleh baca-ulang `before` yg sudah termasuk reward → delta 0 → alert palsu; reward ⊥ terukur → fee `pending` + notif `KLAIM MENDARAT · REWARD TAK TERUKUR` (V33/B10/V55/V56), ⊖ pernah `KLAIM REWARD SUKSES 0.0000` palsu; fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; fee yang ditolak chain (HTTP-500 body via `dist._send_rejected`, V61/B20) → `failed` + retry, ⊖ pernah `sent` utk fee yg ⊖ mendarat; parse `get_table_rows` pakai `data.get('rows')` (bentuk asli dict, ⊥ `data[0]` — B7/V39); `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, bentuk respons asli, temp DB).
|
||||
- `claim_loop.py` — scheduler harian dalam container stack (service `claim`): `_wait_db` (duplikat scan_loop) lalu `claim.step()` terus, loop tak pernah keluar.
|
||||
- `telegram.py` — kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal: `TELEGRAM_COMMUNITY_CHAT_IDS` (CSV) = brief mulai/selesai distribusi saja; `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36). `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal; dari env/`.env`/NFS `config.env`.
|
||||
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. Three vote-age bands (V40/V52): the voters list is split into BARU (top, `PEMILIH BARU · BELUM MATANG`, new accounts by `first_seen_at` within 3 days) and the main list (`voter-list` section, `.reward` cells, V29) where KADALUARSA rows are pinned at the top with amber styling + `VOTE ULANG` badge + legend before the paged VALID rows, which LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column; since V42 the VALID list also holds REVOTE (returning re-voters, `voter_first_seen.first_seen_at ≤ now−3d`) tagged with a mint `REVOTE` tag (legend `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, shows only while `last_vote > now−VEX_REVOTE_TAG_DAYS`) — no separate band, no maturity wait; only genuinely new voters sit in BARU tagged `BARU`; since V43 VALID rows within `VEX_WARN_DAYS` of the KADALUARSA cutoff get an amber dashed `VOTE ULANG SEGERA` badge + legend `TAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH` (display-only); search (`?q=` and `/api/search`) covers all bands and tags each result (`group` = `baru|revote|valid|kadaluarsa` + `expired` + `expiring`); `/api/voter/<owner>` (V57) returns `{"owner", "is_valid_voter"}` — payout-eligibility check via the SAME `db.eligible_voters(owner)` window distribute pays from (never a second SQL copy), DB-snapshot only, always 200; Banner BERITA (V38) shows the next distribution run from `DISTRIBUTE_HOUR`/`DISTRIBUTE_WEEKDAY`/`DISTRIBUTE_FIRST_RUN` + `TZ` (mirror of `distribute_loop.next_boundary`, ⊥ impor lintas-image; drift guarded in test_dashboard); the `SETIAP ... · HH:00` recurrence line renders only for regular weekly rounds — hidden while the next event is the one-off first-run (`_is_first_run_next`, date ≠ weekly day). A `stats-note` caption states the list is single-vote-to-BP accounts ≥ min stake (V47); account names link to `https://vexascan.com/account/{owner}` in the index list, `/history` pay-list, and live search (`.owner-link`, V51); a promo card (`BY DATABISNISID · VEXWALLET`) links the VexWallet Android app on the index page via the IDRS logo (`static/idrs.png`) + `UNDUH DI PLAY STORE` (V53); BARU rows show a `MATURITY PERIOD` countdown column (`VALID DALAM N HARI`) instead of TOTAL REWARD. `/history` = riwayat distribusi read-only; `run-list` = six-column desktop/tablet grid, `pay-list` = four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.
|
||||
- `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`), `HYPERION_API`, `HYPERION_NODES` (pool Hyperion V60, CSV `VEX_HYPERION_NODES`, default `HYPERION_API` + `API_NODES` dedup), `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `VEX_MATURITY_DAYS`, `VEX_EXPIRED_DAYS`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
|
||||
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar.
|
||||
- `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.
|
||||
|
||||
@@ -12,7 +12,7 @@ Auto-claim: tiap hari di jendela klaim (`last_claim_time` + 24 jam) → `vexcore
|
||||
- config via env/`.env` (`config.py`, python-dotenv); default = konstanta lama
|
||||
- db: `db.py` abstraksi backend — `sqlite` (default, stdlib `sqlite3` → `DB_PATH`, WAL → pembaca tak terblokir) atau `mysql` (MariaDB/MySQL via PyMySQL, `VEX_DB_*`); container uji via `docker-compose.dev.yml`
|
||||
- api: `API_NODE` (default `https://v2.vexascan.com:2096`, public, flaky → retry)
|
||||
- saldo liquid akun BP: API databisnis (`DATABISNIS_API`, default `https://api.databisnis.id`) — diambil dashboard, cache TTL `DASH_LIQUID_TTL` (default 60s), cooldown kegagalan
|
||||
- saldo liquid akun BP: API databisnis (`HYPERION_API`, default `https://api.databisnis.id`) — diambil dashboard, cache TTL `DASH_LIQUID_TTL` (default 60s), cooldown kegagalan
|
||||
- contract=scope=`vexcore` (≠ `vexio`, EOS convention ⊥) — ⊥ env-able
|
||||
- filter: ∃ voter where `producers.length==1 & producers[0]==TARGET_BP`
|
||||
- `staked` scaled ×10000 → store ÷10000
|
||||
@@ -43,7 +43,7 @@ Auto-claim: tiap hari di jendela klaim (`last_claim_time` + 24 jam) → `vexcore
|
||||
- service `claim` stack (image `-claim`): `claim_loop.py` tidur sampai mendekati jendela (margin), lalu poll `claimrewards` tiap `CLAIM_RETRY_SECONDS` (default 60) sampai diterima chain; ⊥ spam 1440 tx gagal/hari
|
||||
- siklus klaim selesai ⊥ bila klaim tercatat di `claim_runs` DAN fee terkirim; fee pending/failed diulang tiap siklus + dilanjutkan saat restart (crash-safe, ⊥ fee hilang); setelah klaim sukses jendela dihitung ulang dari `last_claim_time` segar
|
||||
- ⊥ mutex dgn distribute: distribute beku saldo di awal run (V20) → klaim yang jatuh di tengah run didefer ke run berikutnya
|
||||
- klaim butuh `VEX_BP_PRIVATE_KEY` + `DATABISNIS_API` (verifikasi txid V22); Telegram notif klaim sukses/gagal (best-effort V30)
|
||||
- klaim butuh `VEX_BP_PRIVATE_KEY` + `HYPERION_API` (verifikasi txid V22); Telegram notif klaim sukses/gagal (best-effort V30)
|
||||
|
||||
## §I — Interfaces
|
||||
api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows`
|
||||
@@ -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_HYPERION_NODES` (CSV pool Hyperion failover V60, default `HYPERION_API` + `API_NODES` dedup), `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `HYPERION_API` (nama lama `DATABISNIS_API` masih dibaca, backward-compat), `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
|
||||
@@ -90,13 +90,13 @@ V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensit
|
||||
V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config`
|
||||
V13: `last_vote` = epoch 2000-01-01 + `round(52 × log2(last_vote_weight / (staked×10000)))` PEKAN (Vexanium/EOSIO `stake2vote`: eksponen = `int64((now − epoch)/(86400×7)) / 52.0` → bobot dikuantisasi pekan utuh; jadi tanggal kelipatan 7 hari, 00:00:00; regresi oracle baris nyata `..tg` 1291 pekan → 2024-09-28, `1.crownz` 1314 pekan → 2025-03-08); None bila bobot/stake nol; estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` dibuang di `normalize` (bobot tak bisa diverifikasi → tanggal tak diketahui, ⊥ bisa diklasifikasi); V40: vote basi (> VEX_STALE_DAYS) TIDAK lagi dibuang di scan — SEMUA umur disimpan; banding/tampil/bayar menghitung umur saat query (⊥ peran scan)
|
||||
V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA; stats = SALDO LIQUID/TOTAL PEMILIH/STAKE TERTINGGI atas semua baris TAMPIL (V40: ⊖ yang tersembunyi > 31 hari); TOTAL VEX dihapus (V48: raw single-vote stake mudah disalahartikan); V40/V42/V43: BARU (`PEMILIH BARU · BELUM MATANG`, new saja) + daftar utama — kadaluarsa menyatu DI ATAS baris VALID (dipin, sorot amber + badge `VOTE ULANG` + legenda `BARIS AMBER = VOTE LEWAT …`), lalu VALID (pager) = matang-baru + REVOTE (tag mint `REVOTE` + legenda `TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG`, V43) + badge amber dashed `VOTE ULANG SEGERA` (V43) pada baris `exp_cut < last_vote ≤ warn_cut` + legenda `TAG AMBER = KADALUARSA DALAM {WARN} HARI · N PEMILIH`; REVOTE ⊖ lagi bagian sendiri (V42); `/api/search` hasil `{owner,staked,weight,rank,last_vote,total_reward,group,expired,expiring}` — `group` = `baru|revote|valid|kadaluarsa` (weight disimpan & di-API, ⊥ dirender); V47: catatan `stats-note` menyatakan TOTAL PEMILIH = akun suara tunggal ke BP stake ≥ `MIN_STAKED_VEX`; V51: nama akun (index + history pay-list + search live) jadi link `https://vexascan.com/account/{owner}` (`.owner-link`, `target=_blank rel=noopener`) — display-only
|
||||
V16: saldo liquid akun `TARGET_BP` dari `GET {DATABISNIS_API}/v2/state/get_account` (parse `account.core_liquid_balance`) → sel stat SALDO LIQUID; cache in-memory per worker TTL `DASH_LIQUID_TTL`; gagal fetch → nilai lama (atau `—` bila belum pernah sukses) + `at` ikut diset (cooldown, ⊥ pukulan berulang); read-only, ⊥ tulis DB
|
||||
V16: saldo liquid akun `TARGET_BP` dari `GET {HYPERION_API}/v2/state/get_account` (parse `account.core_liquid_balance`) → sel stat SALDO LIQUID; cache in-memory per worker TTL `DASH_LIQUID_TTL`; gagal fetch → nilai lama (atau `—` bila belum pernah sukses) + `at` ikut diset (cooldown, ⊥ pukulan berulang); read-only, ⊥ tulis DB
|
||||
V17: penyimpanan lewat `db.py` (backend `VEX_DB_BACKEND` = `sqlite` default | `mysql`); query berbagi sintaks pakai placeholder `%s` (diterjemahkan `?` utk sqlite); LIKE escape pakai `ESCAPE '!'` — backslash memutus literal string MySQL (B2); `db.query` → list (fetchall sqlite=list / PyMySQL=tuple diseragamkan, B3); replace snapshot mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi (MVCC → pembaca dapat snapshot konsisten saat ganti harian, tak kena torn read); oracle utama (test_get_voters/test_dashboard) tetap sqlite hermetik; `test_mariadb.py` opt-in (skip exit 0 bila `VEX_DB_BACKEND != mysql`)
|
||||
V18: stack produksi = Docker Swarm (`docker-compose.yml`, `docker stack deploy -c docker-compose.yml databisnisid`): mariadb bind mount `/data/db/mariadb/databisnisid/data` pd node `server5.saltis.id` + placement constraint `node.hostname == server5.saltis.id` (data node-lokal → mariadb harus selalu di node itu; di swarm yg ⊥ punya node itu task-nya Pending, bukan bug); web (gunicorn, publik `:5000`) + scan (`scan_loop.py`) + distribute (`distribute_loop.py`, image `databisnisid-dist`) di node mana pun, terhubung via overlay `appnet`; image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` & `-dist` (⊥ `build:`); scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, lokal `TZ` default Asia/Jakarta via `datetime.now()`) + skan-awal `SCAN_RUN_ON_START` (setelah DB siap) → tabel voters langsung ada (dashboard ⊥ 500); distribute loop harian `DISTRIBUTE_HOUR` (default 10, lokal) — ⊥ distribusi-awal saat start (snapshot bisa basi); key `VEX_BP_PRIVATE_KEY` dari bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` dibaca `config.py` `load_dotenv` (⊥ interpolasi env compose); loop tak pernah keluar; `stack deploy` ⊥ `env_file` (env inline via `${VAR}` interpolasi `.env`); image web set `DASH_HOST=0.0.0.0` agar ingress menjangkau; healthcheck web = cek socket TCP (⊥ ketergantungan isi DB)
|
||||
V19: target transfer = kontrak `vex.token` (konstanta chain hardcoded; ⊥ diubah ke `eosio.token`)
|
||||
V20: saldo distribusi dari chain `get_currency_balance` pd `vex.token`; share = floor(balance×stake÷total_stake) 4 desimal; Σshare ≤ balance; sisa (dust) tetap di akun
|
||||
V21: ∀ transfer → dicatat di `distribute_payments` sbg `pending` SEBELUM kirim; status → `sent`|`failed` dr hasil tx; ⊥ hapus baris (append)
|
||||
V22: retry → hanya baris `failed`; baris `sent` ⊥ dikirim ulang; sebelum resend, verifikasi txid via `GET {DATABISNIS_API}/v2/history/get_transaction` → `executed` → tandai `sent` (⊥ duplikat)
|
||||
V22: retry → hanya baris `failed`; baris `sent` ⊥ dikirim ulang; sebelum resend, verifikasi txid via `GET {HYPERION_API}/v2/history/get_transaction` → `executed` → tandai `sent` (⊥ duplikat)
|
||||
V23: memo per run = `DATABISNISID PROFIT SHARE YYYY-MM-DD` — sama utk seluruh transfer run, hanya on-chain (⊖ disimpan DB)
|
||||
V24: run harian `DISTRIBUTE_HOUR` (default 10) waktu lokal `TZ`; ⊥ beku/carryover — hitung ulang tiap hari dari snapshot tersimpan
|
||||
V25: pemilih kosong | saldo < 1 unit shareable → no-op senyap, ⊥ tulis DB
|
||||
@@ -124,6 +124,10 @@ 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 HYPERION_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
|
||||
V60: failover pool Hyperion utk SEMUA pembacaan `/v2/*`: `config` += `HYPERION_NODES` (CSV `VEX_HYPERION_NODES`, default = `HYPERION_API` + `API_NODES` dedup, utama di depan — kedua host mainnet terverifikasi layani `/v2`, probe 2026-08-13). `distribute._get_json(url, params, max_retries=3, timeout=10)` — GET mirror `_post_json` V58: coba SETIAP node per putaran, `max_retries` putaran penuh, jeda 2s; SEMUA gagal → None (⊥ raise — pemanggil butuh None utk jalur tak-terukur/hold). `verify_txid` pakai `_get_json` → None kini berarti SEMUA host gagal SEMUA putaran (bukan satu host hiccup) → hold V59 jarang palsu. `claim.reward_from_tx` pakai `dist._get_json` (tiap attempt settle V55 jadi pool-robust). `dashboard._get_liquid_vex` loop `HYPERION_NODES` sendiri (web ⊥ impor distribute, V31) — host mati → cadangan, SALDO LIQUID tetap live. Net-effect: blip Hyperion sesaat ⊖ lagi menahan payout / ⊖ gagal ukur reward / ⊖ kosongkan saldo dashboard. Oracle: test_distribute V60 verify_txid failover (host mati → cadangan `executed`; semua mati → None), test_claim V60 reward_from_tx failover, test_dashboard V60 saldo dari cadangan + config default HYPERION_NODES | V60,V22,V45,V16
|
||||
V59: kirim per-pemilih ⊖ pernah menyesatkan: (1) TAHAN yang sungguhan saat `verify_txid` → None (Hyperion tak terjangkau): break + tandai `failed` di ATTEMPT PERTAMA (bukan hanya di attempt terakhir — B19: kode lama print `ditahan` tapi lanjut retry → re-sign dgn tapos/expiration baru → txid BARU → re-broadcast berpotensi ganda walau tx pertama mendarat; oracle lama lolos karena set `DISTRIBUTE_MAX_ATTEMPTS=1`); (2) deteksi penolakan chain dari body HTTP-500: pyntelope `send()` ⊥ raise pada 500 (pola V46/B11), respons `{"code":500,"error":...}` datang sbg dict — `_send_rejected(resp)` mengenali bentuk error → raise RuntimeError → jalur verify/retry, ⊖ tandai `sent` utk tx yang ⊥ pernah mendarat (B19 juga: tanpa deteksi, penolakan duplicate/insufficient-balance dicatat `sent` dan ⊥ pernah dikejar). Oracle test_distribute: V59 hold-atau-retry di attempt 1 (3 attempt default, build dipanggil sekali per pemilih), V59 rejection-500 → `failed` + partial (⊖ `sent`) | V59,V22,V21
|
||||
V61: red-team send-path hardening (audit V58/V59/V60, B20–B23): (1) `_send_rejected(resp)` = WHITELIST, bukan blacklist: sukses HANYA bila resp dict ber-`transaction_id` DAN receipt `executed`/tanpa-status; body HTTP-500 apa pun (ber-`error` ATAU bentuk proxy lain) → dianggap DITOLAK → jalur verify/retry, ⊖ pernah `sent` utk tx yang ⊥ mendarat (F3/B22; B19-b hanya mengejar bentuk `{"code":500,"error":...}`, bentuk lain lolos sbg sukses); (2) `_verify_settled(txid, retries=3, delay=2)`: retry `verify_txid` beri Hyperion waktu mengejar indeks (pola B14), True → mendarat; None (pool mati SEMUA) → False langsung (⊥ tidur sia-sia); False konsisten → False — jalur exception send-loop sekarang hold (`failed`, break) pada True→sent / None|False→hold, ⊖ RESEND apa pun hasil verify selain True — tx yang `executed:false` (lag indeks) ⊖ boleh re-sign dgn tapos baru → txid baru → ganda (B21, perluasan V59-b: V59 hold hanya saat `verify_txid → None`, False FALLTHROUGH ke resend); (3) `_send_fee` (claim fee) pakai `_send_rejected(resp)` — penolakan chain fee (duplicate/insufficient-balance, V46/B11) → `failed` + retry siklus berikutnya, ⊖ `sent` untuk fee yang ⊖ pernah mendarat (F1/B20); (4) SEMUA respons `/v2/*` di-guard `isinstance(data, dict)` — `_get_json`, `verify_txid`, `reward_from_tx`, `dashboard._get_liquid_vex` → bentuk non-dict (200-an list/string dari proxy) → skip/skip-node/None, ⊖ `AttributeError` di dalam `except` yang membatalkan run (F5/B23). Oracle: test_distribute V61 (verify-False → hold attempt-1 dgn MAX_ATTEMPTS=3 default, `_send_rejected` whitelist 5 bentuk, `_get_json`/`verify_txid` non-dict → None), test_claim V61 (fee rejection-500 → `failed` ⊖ `sent`), test_dashboard V60 (override `VEX_HYPERION_NODES`). F4 (`_get_json` first-200-wins: primary basi menaungi cadangan — hari ini kedua host = backend vexascan.com sama, efek nol) & F7 (hold latency ~64 s/pembayaran saat SEMUA Hyperion mati; jalur claim chain node tunggal) — DITERIMA, ⊖ diubah | V61,V60,V59,V56,V46
|
||||
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 +186,10 @@ 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
|
||||
T53|x|failover pool Hyperion V60: `config.py` += `HYPERION_NODES` (CSV `VEX_HYPERION_NODES`, default `HYPERION_API` + `API_NODES` dedup); `distribute.py` `_get_json(url, params, max_retries=3, timeout=10)` GET mirror `_post_json` — coba semua `HYPERION_NODES` per putaran, semua gagal → None; `verify_txid` + `claim.reward_from_tx` (via `dist._get_json`) + `dashboard._get_liquid_vex` (loop lokal, web ⊥ impor distribute V31) pindah ke pool; docs §I/§V/V60 + T53 + AGENTS; oracle test_distribute (failover verify_txid + semua-mati None), test_claim (reward_from_tx failover), test_dashboard (saldo cadangan + config default)|V60,V22,V45,V16
|
||||
T54|x|red-team send-path hardening V61 (B20–B23): `distribute.py` — `_send_rejected` jadi WHITELIST (sukses = dict + transaction_id + receipt executed/tanpa-status), `_verify_settled(txid)` retry verify utk lag indeks, jalur exception send-loop hold pada None|False (⊖ resend, perluasan V59-b), guard `isinstance(data, dict)` di `_get_json`+`verify_txid`; `claim.py` `_send_fee` + `_send_rejected` (rejection-500 → failed ⊖ sent) + guard non-dict di `reward_from_tx`; `dashboard._get_liquid_vex` guard non-dict; docs §V/V61 + §B/B20-B23 + T54 + AGENTS; oracle test_distribute (verify-False hold attempt-1 MAX_ATTEMPTS=3, whitelist 5 bentuk, non-dict → None), test_claim (fee rejection → failed), test_dashboard (override VEX_HYPERION_NODES); F4/F7 diterima (dokumentasi)|V61,V60,V59,V56,V46
|
||||
|
||||
## §B — Bug log
|
||||
id|date|cause|fix
|
||||
@@ -203,3 +211,8 @@ 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
|
||||
B20|2026-08-13|fee klaim (claim.py `_send_fee`) ⊖ punya guard `_send_rejected` — `signed.send()` balikin body HTTP-500 (penolakan duplicate/insufficient-balance, V46/B11) sbg dict dan return-nya di-ABA → fee dicatat `sent` walau chain TOLAK → fee 10% hilang, ⊖ pernah dikejar (V34 resume hanya mengejar status `failed`/`pending`, yg sudah `sent` ⊖ pernah diulang)|`_send_fee` tangkap `resp = signed.send()` + `_send_rejected(resp)` → raise → jalur verify/failed → `failed` + retry siklus berikutnya (V61, F1); oracle test_claim V61 fee rejection-500 → `failed` ⊖ `sent`
|
||||
B21|2026-08-13|V59 hanya menahan saat `verify_txid → None` (Hyperion tak terjangkau) — `False` (`executed:false`, lag indeks V50/B14) FALLTHROUGH ke jalur retry → re-sign tapos BARU → txid BARU → broadcast ulang → GANDA kalau tx pertama sebenarnya mendarat; risiko nyata setiap Hyperion tertinggal indeks sesaat (pola yang sudah dibuktikan di klaim B14)|jalur exception send-loop: `_verify_settled(txid)` (retry 3× dgn delay utk lag indeks), True → sent+break; None (pool mati semua) ATAU False konsisten → hold `failed` di attempt 1, ⊖ RESEND (V61, F2); oracle test_distribute V61 verify-False → hold attempt-1 dgn MAX_ATTEMPTS=3 default
|
||||
B22|2026-08-13|`_send_rejected` BLACKLIST (deteksi `error` di body) — body 500 bentuk lain (proxy tanpa `error`, `{"code":500,"message":...}`) atau 200 `soft_fail`/`delayed` receipt dianggap SUKSES → dicatat `sent` walau tx gagal/ditolak|whitelist: sukses HANYA dict ber-`transaction_id` + receipt `executed`/tanpa-status; sisanya → tolak (V61, F3); oracle `_send_rejected` 5 bentuk (sukses nodeos, 500 ber-error, 500 tanpa error, soft_fail, non-dict)
|
||||
B23|2026-08-13|respons `/v2/*` bentuk non-dict (200-an `[...]`/`"..."` dari proxy salah) → `AttributeError: 'list' object has no attribute 'get'` DII DALAM `except RequestException` → run distribusi BEBENTI total; sama di `verify_txid`/`reward_from_tx`/`dashboard._get_liquid_vex`|guard `isinstance(data, dict)` di semua pembaca `/v2/*` → non-dict → skip/skip-node/None (V61, F5); oracle `_get_json`/`verify_txid` non-dict → None
|
||||
@@ -30,8 +30,7 @@ import db
|
||||
import telegram
|
||||
import distribute as dist
|
||||
from config import (API_NODE, BP_FEE_PERCENT, BP_FEE_WALLET, BP_PRIVATE_KEY,
|
||||
CLAIM_RETRY_SECONDS, DATABISNIS_API, TARGET_BP,
|
||||
TOKEN_CONTRACT)
|
||||
CLAIM_RETRY_SECONDS, TARGET_BP, TOKEN_CONTRACT)
|
||||
|
||||
VEX_PREC = Decimal('0.0001')
|
||||
SETTLE_SECONDS = 30 # tunggu finalitas sebelum baca saldo setelah klaim
|
||||
@@ -155,17 +154,14 @@ def reward_from_tx(txid):
|
||||
"""Reward BP dari isi tx klaim via Hyperion: jumlah transfer `vex.bpay`/
|
||||
`vex.vpay` → TARGET_BP. → Decimal | None (Hyperion tak terjangkau / tx tak
|
||||
ada / bentuk tak dikenal). Prioritas pengukuran (V45) — ⊥ selisih saldo
|
||||
yang flaky/stale. Tx yang tak `executed` → None."""
|
||||
yang flaky/stale. Tx yang tak `executed` → None. V60: jalan lewat
|
||||
`dist._get_json` — failover pool `HYPERION_NODES` (bukan satu host)."""
|
||||
if not txid:
|
||||
return None
|
||||
try:
|
||||
resp = dist.requests.get(f'{DATABISNIS_API}/v2/history/get_transaction',
|
||||
params={'id': txid}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = dist._get_json('/v2/history/get_transaction', params={'id': txid})
|
||||
if not isinstance(data, dict): # V61: None/daftar/bentuk tak dikenal
|
||||
return None
|
||||
if not (data or {}).get('executed'):
|
||||
if not data.get('executed'):
|
||||
return None
|
||||
total = Decimal('0')
|
||||
for act in (data.get('actions') or []):
|
||||
@@ -361,7 +357,14 @@ def _send_fee(claim):
|
||||
try:
|
||||
signed = dist.build_signed_transfer(BP_FEE_WALLET, fee, memo)
|
||||
txid = signed.id()
|
||||
signed.send()
|
||||
resp = signed.send()
|
||||
# V61: pyntelope ⊥ raise pada HTTP 500 — penolakan chain (duplicate /
|
||||
# insufficient-balance) datang sbg body JSON (pola V46/B11). Deteksi →
|
||||
# perlakukan sbg gagal, ⊖ tandai 'sent' utk fee yang ⊥ pernah mendarat.
|
||||
if dist._send_rejected(resp):
|
||||
reason = (resp.get('error', {}).get('what')
|
||||
if isinstance(resp, dict) else resp)
|
||||
raise RuntimeError(f'chain menolak fee: {reason}')
|
||||
except Exception as exc:
|
||||
if txid is not None and dist.verify_txid(txid) is True:
|
||||
pass # mendarat walau timeout — lanjut tandai sent
|
||||
|
||||
@@ -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 HYPERION_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')
|
||||
|
||||
@@ -66,8 +77,24 @@ VEX_REVOTE_TAG_DAYS = int(os.getenv('VEX_REVOTE_TAG_DAYS', '1'))
|
||||
# SEGERA` (segera kadaluarsa, masih dibayar).
|
||||
VEX_WARN_DAYS = int(os.getenv('VEX_WARN_DAYS', '3'))
|
||||
|
||||
# API databisnis untuk saldo liquid akun (balance BP, disajikan dashboard)
|
||||
DATABISNIS_API = os.getenv('DATABISNIS_API', 'https://api.databisnis.id')
|
||||
# Hyperion API untuk saldo liquid akun + verifikasi txid (balance BP,
|
||||
# disajikan dashboard; verifikasi distribusi/klaim). `HYPERION_API` nama baru
|
||||
# (V-rename); `DATABISNIS_API` masih dibaca utk backward-compat deploy lama.
|
||||
HYPERION_API = (os.getenv('HYPERION_API')
|
||||
or os.getenv('DATABISNIS_API')
|
||||
or 'https://api.databisnis.id')
|
||||
|
||||
# Node pool Hyperion (V60): CSV `VEX_HYPERION_NODES` — host yang melayani
|
||||
# `/v2/*` utk failover GET Hyperion (verifikasi txid, reward klaim, saldo
|
||||
# liquid dashboard). Default = `HYPERION_API` (utama) + semua `API_NODES`
|
||||
# (keduanya mainnet terverifikasi layani /v2, probe 2026-08-13), dedup,
|
||||
# utama di depan. Dipakai `distribute._get_json` + dashboard `_get_liquid_vex`.
|
||||
HYPERION_NODES = [
|
||||
n.strip() for n in os.getenv(
|
||||
'VEX_HYPERION_NODES',
|
||||
','.join([HYPERION_API] + [n for n in API_NODES if n != HYPERION_API]),
|
||||
).split(',') if n.strip()
|
||||
]
|
||||
|
||||
# Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown
|
||||
DASH_LIQUID_TTL = int(os.getenv('DASH_LIQUID_TTL', '60'))
|
||||
|
||||
+19
-8
@@ -14,7 +14,7 @@ import requests
|
||||
from flask import Flask, abort, jsonify, render_template, request
|
||||
|
||||
import db
|
||||
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
|
||||
from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, HYPERION_NODES,
|
||||
DISTRIBUTE_FIRST_RUN, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY,
|
||||
PAGE_SIZE, TARGET_BP, VEX_EXPIRED_DAYS, VEX_MATURITY_DAYS,
|
||||
VEX_REVOTE_TAG_DAYS, VEX_STALE_DAYS, VEX_WARN_DAYS)
|
||||
@@ -203,26 +203,37 @@ def _is_first_run_next(now=None):
|
||||
def _get_liquid_vex():
|
||||
"""V16: saldo liquid VEX akun TARGET_BP, cache TTL + cooldown kegagalan.
|
||||
|
||||
Ambil `account.core_liquid_balance` dari API databisnis. Bila TTL belum
|
||||
lewat → nilai cache. Bila API gagal → nilai lama tetap dipakai (atau None
|
||||
bila belum pernah sukses), dan `at` ikut diset → API tak dipukul berulang.
|
||||
Ambil `account.core_liquid_balance` dari Hyperion. Bila TTL belum lewat →
|
||||
nilai cache. V60: coba SEMUA `HYPERION_NODES` (failover pool) — host mati
|
||||
→ lanjut cadangan, SALDO LIQUID tetap live. Bila SEMUA gagal → nilai lama
|
||||
tetap dipakai (atau None bila belum pernah sukses), dan `at` ikut diset →
|
||||
API tak dipukul berulang.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if _liquid_cache['value'] is not None and now - _liquid_cache['at'] < DASH_LIQUID_TTL:
|
||||
return _liquid_cache['value']
|
||||
value = None
|
||||
for node in HYPERION_NODES:
|
||||
try:
|
||||
resp = requests.get(
|
||||
f'{DATABISNIS_API}/v2/state/get_account',
|
||||
f'{node}/v2/state/get_account',
|
||||
params={'account': TARGET_BP},
|
||||
timeout=8,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json().get('account', {}).get('core_liquid_balance')
|
||||
data = resp.json()
|
||||
# V61: bentuk tak dikenal (list/string/proxy salah) → lewati host
|
||||
raw = None
|
||||
if isinstance(data, dict):
|
||||
raw = data.get('account', {}).get('core_liquid_balance')
|
||||
if raw:
|
||||
value = float(raw.split()[0])
|
||||
except (requests.RequestException, KeyError, TypeError, ValueError):
|
||||
value = _liquid_cache['value'] # gagal → nilai lama bila ada
|
||||
break
|
||||
except (requests.RequestException, KeyError, TypeError, ValueError,
|
||||
AttributeError):
|
||||
continue
|
||||
if value is None:
|
||||
value = _liquid_cache['value'] # semua gagal → nilai lama bila ada
|
||||
_liquid_cache.update(at=now, value=value)
|
||||
return value
|
||||
|
||||
|
||||
+149
-25
@@ -20,24 +20,37 @@ import requests
|
||||
|
||||
import db
|
||||
import telegram
|
||||
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API,
|
||||
from config import (API_NODES, BP_PRIVATE_KEY, HYPERION_NODES,
|
||||
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):
|
||||
for node in API_NODES:
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=timeout)
|
||||
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
|
||||
print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... '
|
||||
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)
|
||||
|
||||
@@ -45,9 +58,10 @@ def _post_json(url, payload, max_retries=3, timeout=30):
|
||||
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.
|
||||
|
||||
@@ -75,36 +109,106 @@ def compute_shares(balance, voters):
|
||||
return shares
|
||||
|
||||
|
||||
def _get_json(url, params=None, max_retries=3, timeout=10):
|
||||
"""GET JSON dengan retry + failover pool Hyperion (V60).
|
||||
|
||||
`url` = path di belakang host (mis. `/v2/history/get_transaction`) — coba
|
||||
SETIAP `HYPERION_NODES` per putaran (semua mainnet terverifikasi layani
|
||||
`/v2`), `max_retries` putaran penuh, jeda 2s antar putaran. → dict bila
|
||||
sukses, None bila SEMUA host gagal SEMUA putaran (⊥ raise — pemanggil
|
||||
butuh None utk jalan tak-terukur / hold, pola `verify_txid`).
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
for node in HYPERION_NODES:
|
||||
try:
|
||||
resp = requests.get(f'{node}{url}', params=params, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
# V61: 200 tapi bentuk tak dikenal (list/string/proxy salah)
|
||||
# ⊖ dianggap sukses — lewati host ini, coba cadangan.
|
||||
continue
|
||||
except requests.RequestException:
|
||||
continue
|
||||
if attempt < max_retries - 1:
|
||||
print(f'[Peringatan] Semua node Hyperion gagal, mencoba lagi... '
|
||||
f'({attempt + 1}/{max_retries})', flush=True)
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
def verify_txid(txid):
|
||||
"""V22: cek tx benar-benar mendarat via Hyperion `get_transaction`.
|
||||
|
||||
→ True bila `executed`, False bila tak ditemukan/diproses, None bila
|
||||
Hyperion tak terjangkau (⊥ kirim ulang bila tak bisa dipastikan).
|
||||
Hyperion TAK TERJANGKAU (⊥ kirim ulang bila tak bisa dipastikan).
|
||||
V60: None kini berarti SEMUA `HYPERION_NODES` gagal semua putaran
|
||||
(bukan satu host hiccup) — hold V59 jadi jarang palsu.
|
||||
"""
|
||||
try:
|
||||
resp = requests.get(f'{DATABISNIS_API}/v2/history/get_transaction',
|
||||
params={'id': txid}, timeout=10)
|
||||
resp.raise_for_status()
|
||||
return bool(resp.json().get('executed'))
|
||||
except requests.RequestException:
|
||||
data = _get_json('/v2/history/get_transaction', params={'id': txid})
|
||||
if not isinstance(data, dict): # V61: bentuk tak dikenal → ⊖ asumsi apapun
|
||||
return None
|
||||
return bool(data.get('executed'))
|
||||
|
||||
|
||||
def _verify_settled(txid, retries=3, delay=2):
|
||||
"""V61: verifikasi txid dgn settle-retry singkat — tx yang BARU mendarat
|
||||
bisa belum terindeks Hyperion (`executed: false` sesaat, pola V50/B14 di
|
||||
klaim). Baca `verify_txid` beberapa kali sebelum menyimpulkan tak-mendarat.
|
||||
|
||||
None (SEMUA host Hyperion mati) → langsung False (⊖ buang waktu sleep utk
|
||||
pool yang mati — pemanggil TAHAN). → True bila pernah `executed`; False
|
||||
bila tak bisa dikonfirmasi (pemanggil TAHAN, ⊖ resend). """
|
||||
for _ in range(retries):
|
||||
landed = verify_txid(txid)
|
||||
if landed is True:
|
||||
return True
|
||||
if landed is None:
|
||||
return False
|
||||
time.sleep(delay)
|
||||
return False
|
||||
|
||||
|
||||
def _send_rejected(resp):
|
||||
"""V61: deteksi penolakan chain dari respons `send()` — WHITELIST.
|
||||
|
||||
pyntelope ⊥ raise pada HTTP 500 — chain rejection datang sbg body JSON
|
||||
(pola V46/B11). Sukses HANYA bila respons berbentuk dict YANG MEMILIKI
|
||||
`transaction_id` (nodeos selalu menyertakan pada push yang diterima);
|
||||
bentuk penolakan (`{"code":500,"error":...}`), bentuk tak dikenal, maupun
|
||||
`soft_fail`/`delayed` (200 tapi receipt ⊥ `executed`) → dianggap TOLAK.
|
||||
→ True bila harus diperlakukan sbg gagal (⊥ tandai `sent` utk tx yang
|
||||
belum tentu mendarat); False bila terbukti sukses sah.
|
||||
"""
|
||||
if not isinstance(resp, dict) or not resp.get('transaction_id'):
|
||||
return True
|
||||
receipt = ((resp.get('processed') or {}).get('receipt') or {}).get('status')
|
||||
if receipt is not None and receipt != 'executed':
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
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)
|
||||
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')],
|
||||
authorization=[Authorization(actor=TARGET_BP,
|
||||
permission='active')],
|
||||
data=[
|
||||
Data(name='from', value=types.Name(TARGET_BP)),
|
||||
Data(name='to', value=types.Name(to)),
|
||||
@@ -116,6 +220,11 @@ def build_signed_transfer(to, amount, 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 +243,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,28 +292,43 @@ 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). V61: HANYA
|
||||
# `executed` yang boleh dilanjutkan; False (Hyperion bilang tak
|
||||
# mendarat — bisa lag indeks V50/B14) MAUPUN None (Hyperion
|
||||
# down) → TAHAN (tandai failed, ⊖ resend): resend membawa
|
||||
# tapos/expiration baru → txid baru → ganda.
|
||||
if txid is not None:
|
||||
landed = verify_txid(txid)
|
||||
landed = _verify_settled(txid)
|
||||
if landed is True:
|
||||
db.update_payment_status(pid, 'sent', txid=txid)
|
||||
sent_amount += float(share)
|
||||
print(f'[OK] {owner}: {share} VEX '
|
||||
f'(terverifikasi, {txid[:16]}...)', flush=True)
|
||||
break
|
||||
if landed is None:
|
||||
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))
|
||||
|
||||
+3
-3
@@ -48,7 +48,7 @@ services:
|
||||
DASH_HOST: "0.0.0.0" # wajib di container → gunicorn bind terbuka utk ingress
|
||||
DASH_PORT: "5000"
|
||||
DASH_WORKERS: "2"
|
||||
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
|
||||
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
|
||||
DASH_LIQUID_TTL: "60"
|
||||
TZ: ${TZ:-Asia/Jakarta}
|
||||
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
|
||||
@@ -112,7 +112,7 @@ services:
|
||||
DISTRIBUTE_FIRST_RUN: ${DISTRIBUTE_FIRST_RUN:-2026-08-17}
|
||||
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
|
||||
DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false}
|
||||
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
|
||||
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
|
||||
volumes:
|
||||
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
|
||||
networks:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
CLAIM_RETRY_SECONDS: ${CLAIM_RETRY_SECONDS:-60}
|
||||
VEX_BP_FEE_WALLET: ${VEX_BP_FEE_WALLET:-bpdbsjasprod}
|
||||
VEX_BP_FEE_PERCENT: ${VEX_BP_FEE_PERCENT:-0.10}
|
||||
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
|
||||
HYPERION_API: ${HYPERION_API:-https://api.databisnis.id}
|
||||
volumes:
|
||||
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
|
||||
networks:
|
||||
|
||||
+56
-1
@@ -39,7 +39,10 @@ class FakeSigned:
|
||||
def send(self):
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
return {'processed': True}
|
||||
# V61: bentuk sukses nodeos asli (whitelist `_send_rejected` di
|
||||
# `_send_fee` butuh `transaction_id` + receipt `executed`).
|
||||
return {'transaction_id': self._txid,
|
||||
'processed': {'receipt': {'status': 'executed'}}}
|
||||
|
||||
|
||||
def fresh_db(tag):
|
||||
@@ -509,6 +512,33 @@ def main():
|
||||
dist.requests.get = _req_get
|
||||
claim.reward_from_tx = lambda txid: None
|
||||
|
||||
# ————— V60: reward_from_tx failover pool Hyperion —————
|
||||
_orig_hynodes = list(dist.HYPERION_NODES)
|
||||
claim.reward_from_tx = _orig_reward_from_tx # fungsi asli (bukan stub)
|
||||
|
||||
def fake_hyperion_failover(url, params=None, timeout=None):
|
||||
if url.startswith('http://hyperion-dead'):
|
||||
raise dist.requests.RequestException('hyperion mati')
|
||||
return type('R', (), {
|
||||
'ok': True,
|
||||
'raise_for_status': lambda self: None,
|
||||
'json': lambda self: {
|
||||
'executed': True,
|
||||
'actions': [
|
||||
{'act': {'account': 'vex.token', 'name': 'transfer',
|
||||
'data': {'from': 'vex.bpay', 'to': 'databisnisid',
|
||||
'quantity': '10.0000 VEX'}}},
|
||||
],
|
||||
},
|
||||
})()
|
||||
|
||||
dist.HYPERION_NODES = ['http://hyperion-dead', 'http://hyperion-alive']
|
||||
dist.requests.get = fake_hyperion_failover
|
||||
check('V60 reward_from_tx failover → host cadangan',
|
||||
claim.reward_from_tx('txF') == Decimal('10.0000'))
|
||||
dist.requests.get = _req_get
|
||||
dist.HYPERION_NODES = _orig_hynodes
|
||||
|
||||
# ————— V45: reward tak terukur (tx gagal + delta gagal) → fee pending —————
|
||||
fresh_db('unmeasured')
|
||||
claim.fetch_producer_last_claim = lambda: (
|
||||
@@ -560,6 +590,31 @@ def main():
|
||||
check('V34 resume → sent',
|
||||
last_claim_row()[5] == 'sent' and last_claim_row()[6] == 'txr1')
|
||||
|
||||
# ————— V61: fee ditolak chain (HTTP-500 body, V46/B11) → ⊖ 'sent' —————
|
||||
# `_send_fee` ⊖ boleh tandai 'sent' utk fee yang chain TOLAK — pyntelope
|
||||
# mengembalikan 500 sbg body, ⊖ raise (V61-F1). Fee harus tersisa
|
||||
# 'failed' utk diulang, ⊖ pernah 'sent' dgn txid yg ⊥ mendarat.
|
||||
fresh_db('feereject')
|
||||
db.record_claim('2026-08-05', 'txz', 10.0, 1.0, 'failed',
|
||||
'2026-08-05T08:00:00')
|
||||
|
||||
class FakeFeeReject:
|
||||
def id(self):
|
||||
return 'txfeej'
|
||||
|
||||
def send(self):
|
||||
return {'code': 500, 'error': {'what': 'duplicate transaction'}}
|
||||
|
||||
dist.build_signed_transfer = (lambda to, amount, memo:
|
||||
FakeFeeReject())
|
||||
dist.verify_txid = lambda txid: False # txid penolakan ⊥ mendarat
|
||||
check('V61 fee rejection-500 → fee_failed (⊥ sent)',
|
||||
claim.step() == 'fee_failed')
|
||||
row = last_claim_row()
|
||||
check('V61 fee rejection → status failed (diulang), ⊖ sent',
|
||||
row[5] == 'failed')
|
||||
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txr1')
|
||||
|
||||
# ————— V36: klaim reward → kanal INTERNAL saja, ⊥ komunitas —————
|
||||
tg = {'posts': []}
|
||||
|
||||
|
||||
+48
-1
@@ -441,17 +441,44 @@ def main():
|
||||
check('V40 config default VEX_EXPIRED_DAYS', config.VEX_EXPIRED_DAYS == 3)
|
||||
check('V43 config default VEX_REVOTE_TAG_DAYS', config.VEX_REVOTE_TAG_DAYS == 1)
|
||||
check('V43 config default VEX_WARN_DAYS', config.VEX_WARN_DAYS == 3)
|
||||
check('V16 config default DATABISNIS_API', config.DATABISNIS_API == 'https://api.databisnis.id')
|
||||
check('V16 config default HYPERION_API', config.HYPERION_API == 'https://api.databisnis.id')
|
||||
check('V60 config default HYPERION_NODES',
|
||||
config.HYPERION_NODES == ['https://api.databisnis.id',
|
||||
'https://v2.vexascan.com:2096'])
|
||||
check('V16 config default DASH_LIQUID_TTL', config.DASH_LIQUID_TTL == 60)
|
||||
check('V17 config default DB_BACKEND', config.DB_BACKEND == 'sqlite')
|
||||
check('V17 config default DB_HOST/PORT', config.DB_HOST == '127.0.0.1' and config.DB_PORT == 3306)
|
||||
check('V17 config default DB_USER/PASS/NAME',
|
||||
(config.DB_USER, config.DB_PASS, config.DB_NAME) == ('', '', ''))
|
||||
|
||||
# ————— backward-compat: env lama DATABISNIS_API masih dihormati —————
|
||||
import importlib
|
||||
os.environ['DATABISNIS_API'] = 'http://hyperion-lama'
|
||||
importlib.reload(config)
|
||||
check('rename fallback env lama DATABISNIS_API dihormati',
|
||||
config.HYPERION_API == 'http://hyperion-lama')
|
||||
os.environ.pop('DATABISNIS_API', None)
|
||||
importlib.reload(config)
|
||||
check('rename default pulih stlh env lama dibuang',
|
||||
config.HYPERION_API == 'https://api.databisnis.id')
|
||||
|
||||
# ————— V60: env override pool Hyperion —————
|
||||
os.environ['VEX_HYPERION_NODES'] = 'http://hyperion-a,http://hyperion-b'
|
||||
importlib.reload(config)
|
||||
check('V60 override VEX_HYPERION_NODES dipakai',
|
||||
config.HYPERION_NODES == ['http://hyperion-a', 'http://hyperion-b'])
|
||||
os.environ.pop('VEX_HYPERION_NODES', None)
|
||||
importlib.reload(config)
|
||||
check('V60 default pulih stlh override dibuang',
|
||||
config.HYPERION_NODES == ['https://api.databisnis.id',
|
||||
'https://v2.vexascan.com:2096'])
|
||||
|
||||
# ————— V16: saldo liquid — cache TTL + cooldown kegagalan —————
|
||||
dashboard._get_liquid_vex = real_get_liquid
|
||||
dashboard._liquid_cache.update(at=0.0, value=None)
|
||||
calls = {'n': 0}
|
||||
_orig_hynodes = list(dashboard.HYPERION_NODES)
|
||||
dashboard.HYPERION_NODES = ['http://single'] # hitungan V16 dgn 1 host
|
||||
|
||||
def fake_ok(url, params=None, timeout=None):
|
||||
calls['n'] += 1
|
||||
@@ -482,6 +509,26 @@ def main():
|
||||
check('V16 gagal tanpa cache → None',
|
||||
dashboard._get_liquid_vex() is None and calls['n'] == 3)
|
||||
|
||||
# ————— V60: failover pool Hyperion — host mati → host cadangan —————
|
||||
dashboard._liquid_cache.update(at=0.0, value=None)
|
||||
fl_calls = {'n': 0}
|
||||
|
||||
def fake_failover(url, params=None, timeout=None):
|
||||
fl_calls['n'] += 1
|
||||
if url.startswith('http://dead'):
|
||||
raise requests.exceptions.ConnectionError('hyperion mati')
|
||||
return type('R', (), {
|
||||
'ok': True,
|
||||
'raise_for_status': lambda self: None,
|
||||
'json': lambda self: {'account': {'core_liquid_balance': '999.0000 VEX'}},
|
||||
})()
|
||||
|
||||
dashboard.HYPERION_NODES = ['http://dead', 'http://alive']
|
||||
dashboard.requests.get = fake_failover
|
||||
check('V60 saldo dari host cadangan',
|
||||
dashboard._get_liquid_vex() == 999.0 and fl_calls['n'] == 2)
|
||||
dashboard.HYPERION_NODES = _orig_hynodes
|
||||
|
||||
|
||||
def _extract_stakes(text):
|
||||
"""Ambil nilai stake (dalam urutan kemunculan) dari baris HTML — HANYA dari
|
||||
|
||||
+237
-5
@@ -40,7 +40,10 @@ class FakeSigned:
|
||||
if self._fails > 0:
|
||||
self._fails -= 1
|
||||
raise RuntimeError('broadcast timeout (mock)')
|
||||
return {'processed': True}
|
||||
# V61: bentuk sukses nodeos asli (whitelist `_send_rejected` butuh
|
||||
# `transaction_id` + receipt `executed`).
|
||||
return {'transaction_id': self._txid,
|
||||
'processed': {'receipt': {'status': 'executed'}}}
|
||||
|
||||
|
||||
def make_voters_db(path, rows):
|
||||
@@ -84,11 +87,16 @@ 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')
|
||||
_orig_query = db.query
|
||||
_orig_eligible = db.eligible_voters
|
||||
_orig_get = dist.requests.get # V60: restore utk oracle failover Hyperion
|
||||
# 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')
|
||||
@@ -113,6 +121,135 @@ 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
|
||||
|
||||
# ————— V60: failover pool Hyperion — host mati → host cadangan —————
|
||||
_orig_hynodes = list(dist.HYPERION_NODES)
|
||||
dist.time.sleep = lambda s: None
|
||||
dist.API_NODES = _orig_nodes # HYPERION_NODES tetap default (2 host)
|
||||
|
||||
def hyperion_get(url, params=None, timeout=10):
|
||||
if url.startswith('http://hyperion-dead'):
|
||||
raise dist.requests.RequestException('hyperion mati')
|
||||
return fake_post({'executed': True})
|
||||
|
||||
dist.requests.get = hyperion_get
|
||||
dist.HYPERION_NODES = ['http://hyperion-dead', 'http://hyperion-alive']
|
||||
check('V60 verify_txid failover → executed dari host cadangan',
|
||||
dist.verify_txid('tx1') is True)
|
||||
|
||||
dist.HYPERION_NODES = ['http://hyperion-dead']
|
||||
check('V60 semua host Hyperion mati → None (bukan False)',
|
||||
dist.verify_txid('tx1') is None)
|
||||
dist.HYPERION_NODES = _orig_hynodes
|
||||
dist.requests.get = _orig_get # restore (set di awal suite)
|
||||
dist.time.sleep = _orig_sleep
|
||||
|
||||
# ————— V20: compute_shares — floor 4 desimal, sisa tetap di akun —————
|
||||
shares = dist.compute_shares(Decimal('100.0000'),
|
||||
[('a', 300.0), ('b', 700.0)])
|
||||
@@ -179,6 +316,7 @@ def main():
|
||||
fresh),
|
||||
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
|
||||
fresh)])
|
||||
dist.time.sleep = lambda s: None # V61 `_verify_settled` tidur — mock
|
||||
dist.fetch_balance = lambda: Decimal('100.0000')
|
||||
dist.BP_PRIVATE_KEY = '5Ktest'
|
||||
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
|
||||
@@ -227,6 +365,7 @@ def main():
|
||||
calls['run_updates'] == [(7, 'ok', 100.0)])
|
||||
|
||||
# ————— V22: guard anti-duplikat — timeout lalu terverifikasi mendarat —————
|
||||
_real_verify = dist.verify_txid # sandi utk oracle non-dict V61
|
||||
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txL' + to, fails=1)
|
||||
dist.verify_txid = lambda txid: True # tx sebenarnya mendarat
|
||||
calls['updates'] = []
|
||||
@@ -240,19 +379,112 @@ 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 → tetep ditahan (V61)
|
||||
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')
|
||||
|
||||
# ————— V61: verify_txid False (Hyperion bilang tak mendarat) → TAHAN —————
|
||||
# False bisa lag indeks utk tx yang BARU mendarat (pola V50/B14 di klaim):
|
||||
# resend = tapos baru = txid baru = ganda. → hold di attempt 1, ⊖ resend.
|
||||
dist.DISTRIBUTE_MAX_ATTEMPTS = 3 # default; hold harus jalan di attempt 1
|
||||
dist.time.sleep = lambda s: None
|
||||
sends_false = {'n': 0}
|
||||
|
||||
def counting_sign_false(to, amount, memo):
|
||||
sends_false['n'] += 1
|
||||
return FakeSigned('txF' + to, fails=1) # broadcast timeout (mungkin mendarat)
|
||||
|
||||
dist.build_signed_transfer = counting_sign_false
|
||||
dist.verify_txid = lambda txid: False # Hyperion: executed:false
|
||||
calls['updates'] = []
|
||||
calls['run_updates'] = []
|
||||
with redirect_stdout(io.StringIO()):
|
||||
dist.run_distribution(dry_run=False)
|
||||
check('V61 verify False → ditahan (failed), ⊖ resend',
|
||||
all(u[1] == 'failed' for u in calls['updates']))
|
||||
check('V61 hold attempt-1 → build sekali per pemilih (⊥ resend)',
|
||||
sends_false['n'] == 2)
|
||||
check('V61 run status partial',
|
||||
calls['run_updates'][0][1] == 'partial')
|
||||
|
||||
# ————— V61: `_send_rejected` whitelist — bentuk tak dikenal = tolak —————
|
||||
check('V61 sukses nodeos (transaction_id + executed) → ⊖ tolak',
|
||||
dist._send_rejected({'transaction_id': 'tx1',
|
||||
'processed': {'receipt': {'status': 'executed'}}})
|
||||
is False)
|
||||
check('V61 rejection 500 ber-error → tolak',
|
||||
dist._send_rejected({'code': 500, 'error': {'what': 'duplicate'}})
|
||||
is True)
|
||||
check('V61 rejection 500 TANPA error (bentuk proxy) → tolak',
|
||||
dist._send_rejected({'code': 500, 'message': 'Internal Error'}) is True)
|
||||
check('V61 soft_fail receipt → tolak',
|
||||
dist._send_rejected({'transaction_id': 'tx1',
|
||||
'processed': {'receipt': {'status': 'soft_fail'}}})
|
||||
is True)
|
||||
check('V61 non-dict response → tolak', dist._send_rejected(['x']) is True)
|
||||
|
||||
# ————— V61: `_get_json`/`verify_txid` ⊖ crash pada bentuk non-dict —————
|
||||
_orig_verify = dist.verify_txid # restore dr mock False di uji V61-F2
|
||||
dist.verify_txid = _real_verify
|
||||
dist.time.sleep = lambda s: None
|
||||
dist.HYPERION_NODES = ['http://hyperion-list']
|
||||
dist.requests.get = lambda url, params=None, timeout=10: (
|
||||
fake_post(['bukan', 'dict'])) # 200 tapi list (proxy salah)
|
||||
check('V61 _get_json non-dict 200 → None (⊥ crash)',
|
||||
dist._get_json('/v2/history/get_transaction') is None)
|
||||
check('V61 verify_txid non-dict → None',
|
||||
dist.verify_txid('tx1') is None)
|
||||
dist.HYPERION_NODES = _orig_hynodes
|
||||
dist.requests.get = _orig_get
|
||||
dist.verify_txid = _orig_verify
|
||||
dist.time.sleep = _orig_sleep
|
||||
|
||||
# ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
|
||||
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
|
||||
fresh)])
|
||||
|
||||
Reference in new issue
Block a user