Compare commits

...
4 Commits
15 changed files with 1080 additions and 24 deletions

No files matched your search

+23
View File
@@ -58,6 +58,29 @@ SCAN_RUN_ON_START=1
# dibaca `config.py` via load_dotenv). Di luar stack, set key di `.env` lokal.
DISTRIBUTE_HOUR=10
DISTRIBUTE_MAX_ATTEMPTS=3
# Kill-switch: FALSE (default) = payout off; set TRUE untuk mengaktifkan
# pembayaran. --dry-run tetap jalan walau off.
DISTRIBUTE_ENABLED=false
# — Notifikasi Telegram (dua kanal) —
# Satu bot token; dua daftar chat id (CSV; id user atau grup):
# TELEGRAM_COMMUNITY_CHAT_IDS — publik: hanya brief MULAI/SELESAI distribusi
# TELEGRAM_INTERNAL_CHAT_IDS — internal: detail distribusi, semua kegagalan,
# dan klaim reward (⊥ pernah ke komunitas)
# Kosong = kanal senyap. Di stack, isi di config.env NFS (dibaca config.py
# load_dotenv dari /app/.env).
TELEGRAM_BOT_TOKEN=
TELEGRAM_COMMUNITY_CHAT_IDS=
TELEGRAM_INTERNAL_CHAT_IDS=
# — Klaim reward BP harian (service claim) —
# Poll `vexcore::claimrewards` tiap CLAIM_RETRY_SECONDS (detik) saat mendekati
# jendela klaim (last_claim_time + 24 jam). Setelah klaim sukses, 10% reward
# (VEX_BP_FEE_PERCENT) dikirim ke VEX_BP_FEE_WALLET. Key `VEX_BP_PRIVATE_KEY`
# di stack dari bind NFS config.env (sama dgn distribute).
CLAIM_RETRY_SECONDS=60
VEX_BP_FEE_WALLET=bpdbsjasprod
VEX_BP_FEE_PERCENT=0.10
# Kredensial mariadb stack — wajib konsisten antar-service (mariadb/web/scan)
MARIADB_ROOT_PASSWORD=root
+16 -11
View File
@@ -1,20 +1,21 @@
# AGENTS.md
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters daily: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`.
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (`databisnisid`): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (`distribute.py`) pays those voters daily: it signs `vex.token::transfer` actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by `distribute_loop.py` (service `distribute`), the scan equivalent of `scan_loop.py`. A fifth tool (`claim.py`/`claim_loop.py`, service `claim`) auto-claims the BP reward: at the 24h window it calls `vexcore::claimrewards`, then transfers 10% of the claimed reward to the fee wallet `bpdbsjasprod` (spec §V32–V34).
## Run
- 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, 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. 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, fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. A real run is a no-op (exit 0) unless `DISTRIBUTE_ENABLED=true` — default is off (kill-switch, V35); `--dry-run` always works. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed.
- Daily reward claim (production): `./venv/bin/python claim_loop.py` — the `claim` service scheduler. `claim.py` computes the claim window as `last_claim_time + 24h` (from the `vexcore` `producers` table), sleeps until near it, then polls `vexcore::claimrewards` every `CLAIM_RETRY_SECONDS` (default 60) until the chain accepts. Reward is measured as the liquid-balance delta around the claim; 10% (`VEX_BP_FEE_PERCENT`) is transferred to `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`) with memo `BP FEE YYYY-MM-DD`, reusing `distribute.build_signed_transfer`. A claim cycle is complete only when the claim is recorded in `claim_runs` AND the fee is sent; an unsent fee (`pending`/`failed`) is retried each cycle and resumed on restart (crash-safe). No mutex with `distribute.py` — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
- 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` — 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 and dist images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + 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` once daily at `DISTRIBUTE_HOUR` default `10`, 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`). `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` can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` + `databisnisid-claim` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan, dist and claim images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + 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` once daily at `DISTRIBUTE_HOUR` default `10`, key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`) + claim (`claim_loop.py`, poll `claimrewards` at the 24h window, key + Telegram also from the same NFS bind). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute`/`claim` exclude node `server2U` (`node.hostname != server2U`) but otherwise can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`. 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 test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, and `./venv/bin/python test_distribute.py` must all exit 0. 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.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_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_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_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. 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.
## Key facts
@@ -29,6 +30,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- 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 day. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's `/history` view renders `distribute_runs` + `distribute_payments` read-only.
- Claim (spec §V32–V34): the BP reward is claimed once per 24h window via `vexcore::claimrewards` (`owner` = BP). The `claim` service polls every `CLAIM_RETRY_SECONDS` only near the window (`last_claim_time` from the `vexcore` `producers` table + 24h) — no all-day tx spam. Reward = liquid-balance delta (after − before the claim, settle delay); 10% fee floored at 4-dec goes to `bpdbsjasprod` (`VEX_BP_FEE_WALLET`), memo `BP FEE YYYY-MM-DD`, txid verified via Hyperion before any resend. A cycle completes only when the claim row (`claim_runs`) is recorded AND the fee is `sent`; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next day.
- 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.
@@ -36,16 +38,19 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.
- `get_voters.py` — production fetcher: scan → filter (stake + freshness, basi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history).
- `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`.
- `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`.
- `get_voters.js` — reference implementation only.
- `distribute.py` — pembayaran harian: fetch liquid balance → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan. `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
- `distribute.py` — pembayaran harian: fetch liquid balance → `compute_shares` (Decimal floor 4-des, sisa di akun) → sign `vex.token::transfer` via pyntelope (`trx.link` ambil ABI+TAPOS dari node, `sign` dengan `VEX_BP_PRIVATE_KEY`, `.send()`) → catat `sent/failed` per voter; verifikasi txid via Hyperion sebelum kirim ulang; `--dry-run` = rencana ⊥ tanda tangan; notifikasi Telegram mulai/selesai/gagal via `telegram.py` (best-effort, ⊥ dry-run/no-op). `test_distribute.py` — oracle (mock chain+pyntelope, temp DB).
- `claim.py` — klaim reward BP harian: baca `last_claim_time` (tabel `producers`) → `next_window` = +24 jam; tidur sampai mendekat, lalu poll `vexcore::claimrewards` (sign pyntelope `VEX_BP_PRIVATE_KEY`) tiap `CLAIM_RETRY_SECONDS`; reward = selisih saldo (setelah−sebelum, settle); fee 10% (`VEX_BP_FEE_PERCENT`) floor 4-des → `VEX_BP_FEE_WALLET` via `distribute.build_signed_transfer`, memo `BP FEE YYYY-MM-DD`; `step()` = state machine (resume fee → jadwal → poll), source of truth `claim_runs` (fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI). `test_claim.py` — oracle (mock chain+pyntelope, temp DB).
- `claim_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. The voters list (`voter-list` section, `.reward` cells, V29) LEFT-joins a `status='sent'` payments aggregate for the TOTAL REWARD column. `/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`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`; shared by get_voters & dashboard.
- `config.py` — loads env/`.env` (python-dotenv) → `TARGET_BP`, `API_NODE`, `DB_PATH`, `DB_BACKEND`, `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`, `MIN_STAKED_VEX`, `PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`, `CLAIM_RETRY_SECONDS`, `BP_FEE_WALLET`, `BP_FEE_PERCENT`; shared by get_voters & dashboard.
- `scan_loop.py` — scheduler dalam container stack: menunggu batas `SCAN_HOURS` (lokal via `TZ`), panggil `get_voters.main()`; skan-awal `SCAN_RUN_ON_START` + tunggu DB siap; loop tak pernah keluar.
- `distribute_loop.py` — scheduler harian dalam container stack (service `distribute`): tunggu `DISTRIBUTE_HOUR` (lokal via `TZ`), panggil `distribute.main()`, loop tak pernah keluar; gagal dicatat dan dicoba besok; ⊥ 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). Di stack produksi ketiganya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan,dist}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `distribute` bawa `VEX_BP_PRIVATE_KEY` via bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (dibaca `config.py` `load_dotenv`; ⊥ interpolasi). `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`).
- `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.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan + distribute + claim); image dari registry `git.proit.id/proitlab/databisnisid-*`; mariadb bind mount `/data/db/mariadb/databisnisid/data` + placement `node.hostname == server5.saltis.id`; network overlay `appnet`; `distribute` & `claim` bawa `VEX_BP_PRIVATE_KEY` via bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (dibaca `config.py` `load_dotenv`; ⊥ interpolasi). `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`).
- `DESIGN.md` — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).
- `voters.db` — SQLite output (daily snapshot, gitignored in spirit).
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
# tzdata → TZ env (jadwal klaim lokal, default Asia/Jakarta) bekerja di container
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py telegram.py distribute.py claim.py claim_loop.py ./
CMD ["python", "claim_loop.py"]
+1 -1
View File
@@ -10,6 +10,6 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY config.py db.py distribute.py distribute_loop.py ./
COPY config.py db.py distribute.py distribute_loop.py telegram.py ./
CMD ["python", "distribute_loop.py"]
+38 -3
View File
@@ -4,10 +4,11 @@
Scan Vexanium voters table → persist `databisnisid` voters + weight in SQLite.
Re-runnable scheduled. Python 3.12, venv `./venv`.
Profit-share: tiap hari 10:00 lokal → kirim saldo liquid VEX akun BP ke pemilih segar (pro-rata stake), catat tiap transfer (riwayat per-pemilih) + view riwayat dashboard.
Auto-claim: tiap hari di jendela klaim (`last_claim_time` + 24 jam) → `vexcore::claimrewards` utk TARGET_BP (poll tiap menit sampai diterima chain) → kirim 10% reward ke fee wallet `bpdbsjasprod`; reward mengisi saldo yang dibagi distribute.
## §C — Constraints
- venv `./venv` exists (py3.12, only pip) — install deps there, ⊥ global
- lib: `requests`, `flask`, `python-dotenv`, `gunicorn` ! pip-installed; `pymysql` utk backend mysql (opt-in); others stdlib
- lib: `requests`, `flask`, `python-dotenv`, `gunicorn`, `pyntelope` ! pip-installed; `pymysql` utk backend mysql (opt-in); others stdlib
- 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)
@@ -33,8 +34,16 @@ Profit-share: tiap hari 10:00 lokal → kirim saldo liquid VEX akun BP ke pemili
- retry dalam hari: ulang hanya baris `failed`; ⊥ kirim ulang `sent`; sebelum resend → verifikasi txid on-chain (Hyperion `get_transaction` → `executed`)
- ⊥ freezing/carryover: tiap hari hitung ulang segar dari snapshot tersimpan; kegagalan sisa → ikut run besok
- pemilih kosong | saldo < 1 unit shareable → no-op senyap (⊥ catat)
- kill-switch distribusi: `DISTRIBUTE_ENABLED` default FALSE — payout ⊥ jalan tanpa diset `true` (V35); `--dry-run` tetap boleh (read-only)
- notifikasi Telegram dua kanal (V30/V36): bot token tunggal `TELEGRAM_BOT_TOKEN`; KOMUNITAS `TELEGRAM_COMMUNITY_CHAT_IDS` hanya brief mulai/selesai distribusi; INTERNAL `TELEGRAM_INTERNAL_CHAT_IDS` detail distribusi + semua kegagalan + klaim reward (⊥ komunitas); best-effort (⊥ crash); tanpa token/daftar → kanal senyap; di stack nilai dari NFS `config.env` (`/app/.env`)
- dashboard read-only + view riwayat `/history` (runs + per-voter log)
- CPU/NET akun BP besar (limit 41.5M µs, NET 3.26M words) → ratusan transfer/hari sepele; Vexanium tanpa fee
- klaim reward BP: aksi `vexcore::claimrewards` (`owner`=TARGET_BP), sign pyntelope key aktif `VEX_BP_PRIVATE_KEY`; 1 klaim per 24 jam — jendela = `last_claim_time` (tabel `producers`) + 24 jam; klaim di luar jendela ditolak chain
- reward diukur = selisih saldo liquid (`get_currency_balance`) setelah − sebelum klaim (settle delay); fee = floor(`VEX_BP_FEE_PERCENT` × reward), 4 desimal (`ROUND_FLOOR`), → `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD`; reward 0 → fee skipped
- 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)
## §I — Interfaces
api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows`
@@ -44,7 +53,7 @@ db: `db.py` → table `voters` (owner PK, weight, staked, scanned_at, last_vote)
cmd: `./venv/bin/python get_voters.py` → stdout summary (id-ID)
web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 50/halaman, `ORDER BY staked DESC, owner ASC`; ⊥ mutation (read-only)
web: GET `/` + `?q=<substring>` → filter owner (server-side, no-JS fallback); pager bawa `q`
web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote}]}`, rank global, cap 500
web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote,total_reward}]}`, rank global, cap 500
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` — 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)
@@ -55,7 +64,14 @@ db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, t
db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append; ⊖ kolom memo (V23 hanya on-chain)
cmd: `./venv/bin/python distribute.py` → stdout id-ID (rencana + hasil); `--dry-run` → rencana tanpa sign
web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile
env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS` — via `config.py` (`.env`)
env: `VEX_BP_PRIVATE_KEY` (stack: bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env` dibaca `load_dotenv`, `:ro`; dev: env/`.env` lokal), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS`, `DISTRIBUTE_ENABLED` (default false), `TELEGRAM_BOT_TOKEN`, `TELEGRAM_COMMUNITY_CHAT_IDS`, `TELEGRAM_INTERNAL_CHAT_IDS` (CSV) — via `config.py` (`.env`)
cmd: `./venv/bin/python telegram.py` — modul notifikasi (⊥ CLI); `TELEGRAM_BOT_TOKEN` + daftar chat kosong → kanal no-op senyap; `send_text(text, chat_ids=None)` = komunitas, `send_internal(text)` = internal
db: table `claim_runs` (claim_id PK, run_date, claim_txid, reward, fee_amount, fee_status[pending|sent|failed|skipped], fee_txid, claimed_at, fee_sent_at, created_at) via `db.py`; append; ⊖ kolom memo (memo fee on-chain saja, V23 pola)
api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows` → body {json:true, code:`vexcore`, scope:`vexcore`, table:`producers`, limit:1, lower_bound, upper_bound} → rows[owner, last_claim_time, unpaid_blocks] — sumber jendela klaim
api: aksi `vexcore::claimrewards` (owner name) sign pyntelope → `push_transaction` → txid; verifikasi via Hyperion `get_transaction` (V22) sebelum resend
cmd: `./venv/bin/python claim_loop.py` → scheduler klaim harian (image `databisnisid-claim`, service `claim`); `claim.py` modul logika klaim (⊥ CLI)
env: `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT` — via `config.py` (`.env`); key/Telegram dari bind NFS `config.env` di stack (sama dgn distribute)
cmd: `./venv/bin/python test_claim.py` — oracle V32..V34 (mock chain+pyntelope, temp DB)
## §V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
@@ -86,6 +102,13 @@ V26: `VEX_BP_PRIVATE_KEY` ⊥ pernah di-log/print; `--dry-run` utk uji lokal (
V27: dashboard read-only; `/history` baca `distribute_*` via `db.py`; ⊥ mutasi
V28: web list desktop → kolom AKUN/STAKE/REWARD/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE/REWARD/VOTE TERAKHIR center; tablet → RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; TXID = link explorer `vexascan.com/transaction/<txid>`; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML `no-store` + stylesheet versioned
V29: kolom TOTAL REWARD (VEX) per pemilih = Σ `amount` di `distribute_payments` status=`sent` (semua run, sepanjang masa); voter tanpa transfer → `0,0000`; `failed`/`pending` ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); `/api/search` menyertakan `total_reward`; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker)
V30: notifikasi Telegram dua kanal (best-effort): `notify_start` saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), `notify_finish` setelah run tutup (status ok/partial), `notify_failure` saat exception di `main` (lalu re-raise); KOMUNITAS (`TELEGRAM_COMMUNITY_CHAT_IDS`) hanya brief mulai/selesai (tanggal + status, ⊥ detail); INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`) detail (pemilih/total share/saldo/terkirim/gagal) + semua kegagalan; no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa `TELEGRAM_BOT_TOKEN`/daftar → kanal senyap; gagal kirim → log warning, ⊥ pernah crash distribusi
V31: ∀ image stack (web/scan/dist/claim) → tiap modul .py yang diimpor modul yang di-COPY ikut disalin (⊥ impor intra-proyek yang ⊥ ada di image); oracle `test_images.py` memindai import tiap image → daftar COPY memuat semua impor intra-proyek
V32: klaim reward BP via aksi `vexcore::claimrewards` (owner=TARGET_BP), sign pyntelope `VEX_BP_PRIVATE_KEY`; jendela klaim = `last_claim_time` (tabel `producers`) + 24 jam; `claim_loop.py` tidur sampai mendekati jendela lalu poll tiap `CLAIM_RETRY_SECONDS` (default 60) sampai diterima chain; ⊥ klaim di luar jendela (ditolak chain → retry); setelah sukses jendela dihitung ulang dari `last_claim_time` segar; loop tak pernah keluar
V33: reward = max(saldo liquid setelah − sebelum, 0) saat klaim (settle delay ≥ 10s); fee = floor(`BP_FEE_PERCENT` × reward) 4 desimal → `VEX_BP_FEE_WALLET` (default `bpdbsjasprod`), memo `BP FEE YYYY-MM-DD`, verifikasi txid Hyperion sebelum resend (pola V22); reward ≤ 0 → fee `skipped`; Σfee ≤ reward
V34: siklus klaim selesai ⊥ bila klaim tercatat (`claim_runs`) DAN fee terkirim; fee `pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (⊥ fee hilang); klaim saat distribusi berjalan didefer ke run berikutnya (distribute beku saldo di awal run, V20) — ⊥ mutex; fee txid ⊥ dikirim ulang bila ⊥ bisa diverifikasi
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`)
## §T — Tasks
id|status|task|cites
@@ -121,9 +144,21 @@ T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; `/history` 6-kolom +
T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE center|V28,I.web
T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web
T32|x|dashboard list: kolom TOTAL REWARD (VEX) = Σ sent per pemilih (LEFT JOIN subquery, status=sent); desktop 5 kolom rata, tablet 5 kolom, kartu mobile + label; TXID history jadi link explorer vexascan|V15,V28,V29,I.web
T33|x|notifikasi Telegram: `telegram.py` (send_text best-effort + notify_start/finish/failure), hook di distribute.py (mulai/selesai/gagal; ⊥ dry-run/no-op), config `TELEGRAM_BOT_TOKEN`+`TELEGRAM_CHAT_IDS`, Dockerfile.dist += telegram.py, oracle|V30,I.env
T34|x|image mandiri: distribute_loop self-contained (duplikasi helper scan_loop, ⊥ impor lintas-image) + oracle test_images.py (import intra-proyek ⊆ daftar COPY tiap image)|V31
T35|x|klaim config: `CLAIM_RETRY_SECONDS`, `VEX_BP_FEE_WALLET`, `VEX_BP_FEE_PERCENT` di config.py + db.py tabel `claim_runs` (ensure/record_claim/update_claim_fee/pending_claim_fee, `%s` dua backend)|V32,V34,I.db,I.env
T36|x|claim.py: fetch `last_claim_time` (tabel producers) + `next_window` + `build_claim_action` (pyntelope `vexcore::claimrewards`) + ukur reward (delta saldo) + hitung fee floor|V32,V33,I.api
T37|x|claim.py: `poll_claim` (retry tiap `CLAIM_RETRY_SECONDS` sampai sukses/deadline) + `_send_fee` (retry-until-sent, verifikasi txid V22) + `step` state machine (resume fee → jadwal → poll)|V33,V34
T38|x|claim_loop.py self-contained (duplikasi `next_boundary`/`_wait_db`) + Dockerfile.claim (image `databisnisid-claim`) + service `claim` di docker-compose.yml (env DB/TZ/CLaim + bind NFS config.env)|V32,V34,I.env
T39|x|telegram.py: `notify_claim` (reward+fee) + `notify_claim_failure` + hook di claim.py (sukses klaim / fee gagal pertama; best-effort)|V30,V34
T40|x|test_claim.py oracle (mock chain+pyntelope, temp DB: jendela, math fee, siklus penuh, retry fee, resume, reward 0) + test_images.py += Dockerfile.claim & modul claim/claim_loop|V31,V32,V33,V34,I.db
T41|x|kill-switch distribusi: `DISTRIBUTE_ENABLED` (config default false) + gate `run_distribution` (non-dry-run no-op: exit 0, ⊥ sign/tulis/notif; dry-run tetap) + env compose `${DISTRIBUTE_ENABLED:-false}` + oracle (default off, disabled no-op, disabled+dry-run)|V35,I.env
T42|x|Telegram dua kanal: rename `TELEGRAM_CHAT_IDS` → `TELEGRAM_COMMUNITY_CHAT_IDS` + tambah `TELEGRAM_INTERNAL_CHAT_IDS` (config), `telegram.py` `send_text(chat_ids=None)`=komunitas + `send_internal`; notify_start/finish → komunitas brief + internal detail; notify_failure/notify_claim/notify_claim_failure → internal saja; oracle V30 (dua kanal) + V36 (klaim ⊥ komunitas)|V30,V36,I.env
## §B — Bug log
id|date|cause|fix
B1|2026-08-04|node balikin `staked` sbg string utk sebagian baris → TypeError int÷int|V8
B2|2026-08-05|`ESCAPE '\'` valid di SQLite tapi memutus literal string MySQL (`'\'` = string tak tertutup) → error 1064 di backend mysql|ganti karakter escape ke `!` (netral di kedua backend), `_escape_like` esc `!`,`%`,`_` (V17)
B3|2026-08-05|PyMySQL `fetchall()` → tuple, SQLite → list → asersi `== []` gagal di mysql|`db.query` bungkus `list()` → tipe seragam (V17)
B4|2026-08-06|image dist ⊥ salin `scan_loop.py`, `distribute_loop` impor darinya → ModuleNotFoundError saat smoke container|V31
B5|2026-08-06|`DISTRIBUTE_ENABLED` pakai `not in ('1','true','yes')` → default jadi ON (kebalikan V35, kill-switch ⊥ berfungsi saat var ⊥ diset)|flip ke `in (...)` — default off (V35)
+209
View File
@@ -0,0 +1,209 @@
"""Klaim reward BP + transfer fee harian: `vexcore::claimrewards` utk TARGET_BP.
Setiap siklus (V32..V34): tentukan jendela klaim = `last_claim_time` (tabel
`producers`) + 24 jam, tidur sampai mendekatinya, lalu poll `claimrewards`
tiap `CLAIM_RETRY_SECONDS` sampai diterima chain. Reward diukur = selisih
saldo liquid (setelah − sebelum klaim, settle delay); fee = floor(
`VEX_BP_FEE_PERCENT` × reward) 4 desimal → `VEX_BP_FEE_WALLET` (default
`bpdbsjasprod`), memo `BP FEE YYYY-MM-DD` (V33).
Siklus selesai ⊥ bila klaim tercatat di `claim_runs` DAN fee terkirim; fee
`pending`/`failed` diulang tiap siklus + dilanjutkan saat restart (crash-safe,
V34). ⊥ mutex dgn distribute.py — distribute beku saldo di awal run (V20),
klaim yang jatuh di tengah run didefer ke run berikutnya.
Modul logika (⊥ CLI) — `claim_loop.py` yang menjalankan loop tak-berhenti.
"""
import time
from datetime import datetime, timedelta
from decimal import Decimal, ROUND_FLOOR
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, TARGET_BP)
VEX_PREC = Decimal('0.0001')
SETTLE_SECONDS = 10 # tunggu finalitas sebelum baca saldo setelah klaim
POLL_SPAN_SECONDS = 7200 # batas poll sekali jendela (2 jam)
def _post_json(url, payload, max_retries=3, timeout=30):
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
for attempt in range(max_retries):
try:
resp = dist.requests.post(url, json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
except Exception:
if attempt == max_retries - 1:
raise
print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... '
f'({attempt + 1}/{max_retries})', flush=True)
time.sleep(2)
def fetch_producer_last_claim():
"""`last_claim_time` baris producers utk TARGET_BP dari chain (| None)."""
data = _post_json(f'{API_NODE}/v1/chain/get_table_rows',
{'json': True, 'code': 'vexcore', 'scope': 'vexcore',
'table': 'producers', 'limit': 1,
'lower_bound': TARGET_BP, 'upper_bound': TARGET_BP})
rows = data or []
return (rows[0] or {}).get('last_claim_time') if rows else None
def parse_claim_time(raw):
"""ISO `last_claim_time` chain → datetime; format tak dikenal → None."""
if not raw:
return None
for fmt in ('%Y-%m-%dT%H:%M:%S.%f', '%Y-%m-%dT%H:%M:%S'):
try:
return datetime.strptime(raw, fmt)
except ValueError:
continue
return None
def next_window(now):
"""Jendela klaim berikutnya = `last_claim_time` + 24 jam; ⊥ last → now."""
last = parse_claim_time(fetch_producer_last_claim())
return (last + timedelta(hours=24)) if last is not None else now
def build_claim_action():
"""Bangun + tanda tangani aksi `vexcore::claimrewards` via pyntelope.
Kembalikan objek SignedTransaction (⊥ broadcast). ABI + TAPOS dari node
saat `link()` — pola `distribute.build_signed_transfer`.
"""
from pyntelope import Action, Authorization, Data, Net, Transaction, types
trx = Transaction(actions=[
Action(
account='vexcore',
name='claimrewards',
authorization=[Authorization(actor=TARGET_BP, permission='active')],
data=[Data(name='owner', value=types.Name(TARGET_BP))],
)
])
linked = trx.link(net=Net(host=API_NODE))
return linked.sign(key=BP_PRIVATE_KEY)
def _try_claim_once():
"""Satu percobaan klaim: klaim → ukur reward → catat `claim_runs`.
Return `claimed` (baris tercatat, fee pending/skipped) | `retry`.
Klaim yang sudah mendarat walau timeout (verifikasi txid / `last_claim_time`
maju) tetap diukur selisih saldo — reward tak hilang (V34).
"""
before = dist.fetch_balance()
if before is None:
return 'retry'
txid = None
landed = False
try:
signed = build_claim_action()
txid = signed.id()
signed.send()
landed = True
except Exception as exc:
if txid is not None:
verified = dist.verify_txid(txid)
if verified is True:
landed = True
elif verified is None:
# tak bisa dipastikan — klaim dlm 24 jam terakhir? → mendarat
last = parse_claim_time(fetch_producer_last_claim())
if last is not None and datetime.now() - last < timedelta(hours=24):
landed = True
if not landed:
print(f'[Peringatan] klaim gagal: {exc}', flush=True)
return 'retry'
time.sleep(SETTLE_SECONDS)
after = dist.fetch_balance()
if after is None:
print('[Peringatan] saldo setelah klaim tak terbaca — reward tak '
'diukur (cek manual)', flush=True)
after = before
reward = max(after - before, Decimal('0'))
fee = (reward * BP_FEE_PERCENT).quantize(VEX_PREC, rounding=ROUND_FLOOR)
fee_status = 'pending' if fee > 0 else 'skipped'
now = datetime.now()
now_iso = now.isoformat(timespec='seconds')
db.record_claim(now.strftime('%Y-%m-%d'), txid, float(reward), float(fee),
fee_status, now_iso)
txid_label = txid[:16] + '...' if txid else '?'
print(f'[OK] klaim reward {reward} VEX (txid {txid_label}) | '
f'fee {fee} VEX → {BP_FEE_WALLET}', flush=True)
telegram.notify_claim(reward, fee, BP_FEE_WALLET)
return 'claimed'
def poll_claim(deadline):
"""Poll `claimrewards` tiap `CLAIM_RETRY_SECONDS` sampai sukses / deadline.
Return `claimed` | `timeout` (deadline lewat — jadwal dihitung ulang).
"""
while datetime.now() < deadline:
status = _try_claim_once()
if status == 'claimed':
return 'claimed'
time.sleep(CLAIM_RETRY_SECONDS)
print(f'[Peringatan] jendela klaim habis tanpa sukses '
f'({deadline.isoformat(timespec="seconds")})', flush=True)
return 'timeout'
def _send_fee(claim):
"""Kirim fee satu baris `claim_runs` (retry-until-sent, verifikasi V22).
`claim` = baris dari `db.pending_claim_fee()`. Return True bila terkirim
atau fee 0 (skipped); False → diulang siklus berikutnya.
"""
claim_id, run_date = claim[0], claim[1]
fee = Decimal(str(claim[4]))
if fee <= 0:
db.update_claim_fee(claim_id, 'skipped')
return True
first_fail = claim[5] != 'failed' # notif gagal ⊥ sekali per klaim
memo = f'BP FEE {run_date}'
txid = None
try:
signed = dist.build_signed_transfer(BP_FEE_WALLET, fee, memo)
txid = signed.id()
signed.send()
except Exception as exc:
if txid is not None and dist.verify_txid(txid) is True:
pass # mendarat walau timeout — lanjut tandai sent
else:
db.update_claim_fee(claim_id, 'failed', fee_txid=txid)
print(f'[GAGAL] fee {fee} VEX → {BP_FEE_WALLET}: {exc}', flush=True)
if first_fail:
telegram.notify_claim_failure(exc)
return False
db.update_claim_fee(claim_id, 'sent', fee_txid=txid)
print(f'[OK] fee {fee} VEX → {BP_FEE_WALLET} ({txid[:16]}...)', flush=True)
return True
def step():
"""Satu iterasi loop klaim. Return aksi utk uji: `fee_sent` | `fee_failed`
| `sleep` | `claimed` | `timeout`."""
claim = db.pending_claim_fee()
if claim is not None:
return 'fee_sent' if _send_fee(claim) else 'fee_failed'
now = datetime.now()
window = next_window(now)
margin = timedelta(seconds=CLAIM_RETRY_SECONDS)
if now < window - margin:
wait = (window - margin - now).total_seconds()
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
f'sampai {window.isoformat(timespec="seconds")}', flush=True)
time.sleep(max(wait, 0))
return 'sleep'
deadline = window + timedelta(seconds=POLL_SPAN_SECONDS)
return poll_claim(deadline)
+50
View File
@@ -0,0 +1,50 @@
"""Jadwal klaim reward BP harian: jalankan `claim.step()` terus-menerus.
Menunggu hingga jendela klaim (dihitung `claim.py` dari `last_claim_time` +
24 jam), lalu `step()` menangani resume fee → jadwal → poll `claimrewards`
tiap `CLAIM_RETRY_SECONDS`. Loop tak pernah keluar — service dijaga hidup oleh
restart_policy swarm; restart aman karena fee yang belum terkirim dilanjutkan
dari `claim_runs` (V34). `TZ` env menentukan waktu lokal via `datetime.now()`
(libc); image Dockerfile.claim sudah menyertakan tzdata.
Berbeda dgn scan: ⊥ ada klaim-awal saat start — klaim dgn key salah / jendela
belum buka hanya dicatat lalu dicoba lagi (⊥ menabrak rule 24 jam).
Scheduler helper (`_wait_db`) sengaja diduplikasi dari `scan_loop.py` (bukan
di-import) karena image Docker terpisah — `Dockerfile.claim` membawa
`config.py db.py telegram.py distribute.py claim.py claim_loop.py` saja.
"""
import time
import db
import claim
def _wait_db(timeout=120):
"""Tunggu basis data siap (mariadb baru boot) sebelum loop."""
start = time.monotonic()
while time.monotonic() - start < timeout:
try:
db.queryone('SELECT 1')
return
except Exception:
time.sleep(3)
raise RuntimeError('basis data tak siap setelah menunggu')
def main():
from config import CLAIM_RETRY_SECONDS
print(f'Jadwal klaim reward BP: poll tiap {CLAIM_RETRY_SECONDS}s '
f'di jendela klaim (last_claim_time + 24 jam)', flush=True)
_wait_db()
while True:
try:
claim.step()
except Exception as exc:
print(f'[Error] Siklus klaim gagal: {exc}', flush=True)
time.sleep(CLAIM_RETRY_SECONDS)
if __name__ == '__main__':
main()
+27
View File
@@ -5,6 +5,7 @@ di sini supaya semua modul (get_voters, dashboard) berbagi sumber yang sama.
"""
import os
from decimal import Decimal
from dotenv import load_dotenv
@@ -57,6 +58,32 @@ DASH_LIQUID_TTL = int(os.getenv('DASH_LIQUID_TTL', '60'))
BP_PRIVATE_KEY = os.getenv('VEX_BP_PRIVATE_KEY', '')
DISTRIBUTE_HOUR = int(os.getenv('DISTRIBUTE_HOUR', '10'))
DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3'))
# Kill-switch distribusi (V35): default OFF — payout hanya jalan saat diset
# `true`. `--dry-run` tetap boleh jalan (read-only, ⊥ tanda tangan/tulis).
DISTRIBUTE_ENABLED = os.getenv(
'DISTRIBUTE_ENABLED', 'false').lower() in ('1', 'true', 'yes')
# ————————————————— Notifikasi Telegram (V30/V36) —————————————————
# Bot token tunggal; dua daftar chat id (CSV): KOMUNITAS (`..._COMMUNITY_...`)
# hanya brief mulai/selesai distribusi, INTERNAL (`..._INTERNAL_...`) detail
# distribusi + semua kegagalan + klaim reward (⊥ komunitas). Kosong → kanal
# itu senyap.
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '')
TELEGRAM_COMMUNITY_CHAT_IDS = [
c.strip() for c in os.getenv('TELEGRAM_COMMUNITY_CHAT_IDS', '').split(',')
if c.strip()
]
TELEGRAM_INTERNAL_CHAT_IDS = [
c.strip() for c in os.getenv('TELEGRAM_INTERNAL_CHAT_IDS', '').split(',')
if c.strip()
]
# ————————————————— Klaim reward BP (V32..V34) —————————————————
# Poll interval (detik) saat mendekati jendela klaim; fee wallet + persentase
# 10% dari reward yang baru diklaim (floor 4 desimal).
CLAIM_RETRY_SECONDS = int(os.getenv('CLAIM_RETRY_SECONDS', '60'))
BP_FEE_WALLET = os.getenv('VEX_BP_FEE_WALLET', 'bpdbsjasprod')
BP_FEE_PERCENT = Decimal(os.getenv('VEX_BP_FEE_PERCENT', '0.10'))
# Kontrak token VEX = vex.token (⊥ eosio.token — tak ada di chain)
TOKEN_CONTRACT = 'vex.token'
+96
View File
@@ -255,3 +255,99 @@ def list_payments(run_id=None):
params = (run_id,)
sql += ' ORDER BY payment_id DESC'
return query(sql, params)
# ——————————————————————————————————————————————————————————————————————
# Riwayat klaim reward BP (V32..V34). Satu baris per klaim sukses; fee yang
# belum terkirim (`pending`/`failed`) jadi sumber resume saat restart.
# ——————————————————————————————————————————————————————————————————————
_CLAIM_COLS = (
'run_date TEXT NOT NULL, '
'claim_txid TEXT, '
'reward DOUBLE NOT NULL, '
'fee_amount DOUBLE NOT NULL, '
'fee_status TEXT NOT NULL, ' # pending | sent | failed | skipped
'fee_txid TEXT, '
'claimed_at TEXT, '
'fee_sent_at TEXT, '
'created_at TEXT NOT NULL'
)
_CLAIM_DDL_SQLITE = (f'CREATE TABLE IF NOT EXISTS claim_runs '
f'(claim_id INTEGER PRIMARY KEY AUTOINCREMENT, '
f'{_CLAIM_COLS})')
_CLAIM_DDL_MYSQL = (f'CREATE TABLE IF NOT EXISTS claim_runs '
f'(claim_id INT AUTO_INCREMENT PRIMARY KEY, '
f'{_CLAIM_COLS})')
def ensure_claim_schema():
"""Buat tabel riwayat klaim bila belum ada (idempoten, kedua backend)."""
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(_CLAIM_DDL_SQLITE)
else:
cur.execute(_CLAIM_DDL_MYSQL)
conn.commit()
finally:
conn.close()
def record_claim(run_date, claim_txid, reward, fee_amount, fee_status,
created_at):
"""Insert baris `claim_runs` → claim_id."""
ensure_claim_schema()
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'INSERT INTO claim_runs '
'(run_date, claim_txid, reward, fee_amount, fee_status, '
' claimed_at, fee_sent_at, created_at) '
'VALUES (%s, %s, %s, %s, %s, %s, %s, %s)'),
(run_date, claim_txid, reward, fee_amount, fee_status, created_at,
None, created_at))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def update_claim_fee(claim_id, fee_status, fee_txid=None):
"""Perbarui status fee satu klaim (V33): pending → sent|failed|skipped."""
from datetime import datetime
conn = connect()
try:
cur = conn.cursor()
if fee_status == 'sent':
now = datetime.now().isoformat(timespec='seconds')
cur.execute(_translate(
'UPDATE claim_runs SET fee_status = %s, fee_txid = %s, '
'fee_sent_at = %s WHERE claim_id = %s'),
(fee_status, fee_txid, now, claim_id))
else:
cur.execute(_translate(
'UPDATE claim_runs SET fee_status = %s, fee_txid = %s '
'WHERE claim_id = %s'),
(fee_status, fee_txid, claim_id))
conn.commit()
finally:
conn.close()
def pending_claim_fee():
"""Klaim terbaru dengan fee belum terkirim (fee_amount > 0) → baris | None.
Source of truth resume: fee `pending`/`failed` diulang tiap siklus dan
dilanjutkan saat restart (V34).
"""
ensure_claim_schema()
return queryone(
'SELECT claim_id, run_date, claim_txid, reward, fee_amount, '
' fee_status, fee_txid, claimed_at, fee_sent_at, created_at '
'FROM claim_runs '
'WHERE fee_status IN (%s, %s) AND fee_amount > 0 '
'ORDER BY claim_id DESC LIMIT 1',
('pending', 'failed'))
+16 -2
View File
@@ -19,9 +19,10 @@ from decimal import Decimal, ROUND_FLOOR, InvalidOperation
import requests
import db
import telegram
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API,
DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP, TOKEN_CONTRACT,
VEX_SYMBOL)
DISTRIBUTE_ENABLED, DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP,
TOKEN_CONTRACT, VEX_SYMBOL)
VEX_PREC = Decimal('0.0001')
@@ -124,6 +125,10 @@ def run_distribution(dry_run=False):
kirim tiap transfer (retry dalam hari, guard anti-duplikat) → tutup run.
Dry-run berhenti sebelum menulis DB / tanda tangan.
"""
if not dry_run and not DISTRIBUTE_ENABLED:
print('Distribusi dinonaktifkan (DISTRIBUTE_ENABLED=false) — no-op.',
flush=True)
return 0
voters = db.query('SELECT owner, staked FROM voters')
if not voters:
print('Tidak ada pemilih segar tersimpan — no-op.', flush=True)
@@ -154,6 +159,9 @@ def run_distribution(dry_run=False):
'VEX_BP_PRIVATE_KEY kosong — set secret dulu sebelum run nyata '
'(⊥ distribusi tanpa key)')
total_share = sum(share for _, share in shares)
telegram.notify_start(run_date, len(shares), total_share, balance)
created_at = datetime.now().isoformat(timespec='seconds')
total_stake = sum(float(s) for _, s in voters)
run_id = db.record_run(run_date, float(balance), len(voters), total_stake,
@@ -206,6 +214,8 @@ def run_distribution(dry_run=False):
status = 'ok' if not failed else 'partial'
db.update_run_status(run_id, status, round(sent_amount, 4))
telegram.notify_finish(status, len(pending) - len(failed), len(pending),
total_share, failed, run_date)
print(f'SELESAI: {len(pending) - len(failed)}/{len(pending)} terkirim '
f'(status run: {status})', flush=True)
return 0
@@ -213,7 +223,11 @@ def run_distribution(dry_run=False):
def main():
dry_run = '--dry-run' in sys.argv
try:
return run_distribution(dry_run=dry_run)
except Exception as exc:
telegram.notify_failure(exc)
raise
if __name__ == '__main__':
+41 -7
View File
@@ -1,8 +1,9 @@
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
#
# 4 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ), dan
# distribute (pembayaran harian distribute.py pada DISTRIBUTE_HOUR).
# 5 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ),
# distribute (pembayaran harian distribute.py pada DISTRIBUTE_HOUR), dan
# claim (klaim reward BP harian claim_loop.py + fee 10% ke VEX_BP_FEE_WALLET).
#
# Catatan swarm:
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
@@ -62,6 +63,9 @@ services:
start_period: 20s
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
@@ -82,6 +86,9 @@ services:
- appnet
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
@@ -98,6 +105,7 @@ services:
TZ: ${TZ:-Asia/Jakarta}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
DISTRIBUTE_ENABLED: ${DISTRIBUTE_ENABLED:-false}
DATABISNIS_API: ${DATABISNIS_API:-https://api.databisnis.id}
volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
@@ -105,13 +113,39 @@ services:
- appnet
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
claim:
image: git.proit.id/proitlab/databisnisid-claim:latest
command: ["python", "claim_loop.py"]
environment:
VEX_DB_BACKEND: mysql
VEX_DB_HOST: mariadb
VEX_DB_PORT: "3306"
VEX_DB_USER: ${MARIADB_USER:-databisnis}
VEX_DB_PASS: ${MARIADB_PASSWORD:-databisnis}
VEX_DB_NAME: ${MARIADB_DATABASE:-databisnisid}
TZ: ${TZ:-Asia/Jakarta}
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}
volumes:
- /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro
networks:
- appnet
deploy:
replicas: 1
placement:
constraints:
- node.hostname != server2U
restart_policy:
condition: any
networks:
appnet:
driver: overlay
# volume khusus stack — jangan pakai mariadb_data (milik docker-compose.dev.yml)
volumes:
db_data:
+92
View File
@@ -0,0 +1,92 @@
"""Kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort).
Dua kanal (V30/V36): KOMUNITAS (`TELEGRAM_COMMUNITY_CHAT_IDS`) hanya brief
mulai/selesai distribusi; INTERNAL (`TELEGRAM_INTERNAL_CHAT_IDS`) detail
distribusi + semua kegagalan + klaim reward (⊥ komunitas). Token tunggal
`TELEGRAM_BOT_TOKEN`. Tanpa token/daftar → kanal itu no-op senyap (⊥ ganggu
run). Gagal kirim hanya dicatat — ⊥ pernah crash distribusi/klaim.
"""
import requests
from config import (TELEGRAM_BOT_TOKEN, TELEGRAM_COMMUNITY_CHAT_IDS,
TELEGRAM_INTERNAL_CHAT_IDS)
def send_text(text, chat_ids=None):
"""Kirim `text` ke daftar chat id. `chat_ids=None` → kanal komunitas.
Best-effort: tanpa token/daftar → no-op; gagal → log saja.
"""
if chat_ids is None:
chat_ids = TELEGRAM_COMMUNITY_CHAT_IDS
if not TELEGRAM_BOT_TOKEN or not chat_ids:
return
url = f'https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage'
for chat_id in chat_ids:
try:
resp = requests.post(url, json={'chat_id': chat_id, 'text': text},
timeout=10)
resp.raise_for_status()
except requests.RequestException as exc:
print(f'[Peringatan] Gagal kirim Telegram ke {chat_id}: {exc}',
flush=True)
def send_internal(text):
"""Kirim ke kanal internal (detail + kegagalan + klaim)."""
send_text(text, TELEGRAM_INTERNAL_CHAT_IDS)
def notify_start(run_date, n_voters, total_share, balance):
"""Run nyata dimulai: komunitas brief + internal detail."""
send_text(f'DISTRIBUSI MULAI\nTanggal: {run_date}')
send_internal(
f'DISTRIBUSI MULAI\n'
f'Tanggal: {run_date}\n'
f'Pemilih: {n_voters}\n'
f'Total share: {total_share} VEX\n'
f'Saldo: {balance} VEX'
)
def notify_finish(status, sent, total, total_share, failed, run_date):
"""Run selesai: komunitas brief + internal detail (ok | partial)."""
send_text(
f'DISTRIBUSI SELESAI\n'
f'Tanggal: {run_date}\n'
f'Status: {status}'
)
send_internal(
f'DISTRIBUSI SELESAI\n'
f'Tanggal: {run_date}\n'
f'Status: {status}\n'
f'Terkirim: {sent}/{total} pemilih\n'
f'Total share: {total_share} VEX\n'
f'Gagal: {len(failed)}'
)
def notify_failure(exc):
"""Run gagal total — kanal internal saja (ops detail)."""
send_internal(
f'DISTRIBUSI GAGAL\n'
f'Error: {exc}'
)
def notify_claim(reward, fee, wallet):
"""Klaim reward BP sukses — kanal internal saja, ⊥ komunitas (V36)."""
send_internal(
f'KLAIM REWARD SUKSES\n'
f'Reward: {reward} VEX\n'
f'Fee BP: {fee} VEX → {wallet}'
)
def notify_claim_failure(exc):
"""Kegagalan siklus klaim — kanal internal saja (V36)."""
send_internal(
f'KLAIM GAGAL\n'
f'Error: {exc}'
)
+245
View File
@@ -0,0 +1,245 @@
"""Uji verifikasi klaim reward BP (oracle §V32..V34).
`./venv/bin/python test_claim.py` harus exit 0. Memakai DB sementara + mock
jaringan/pyntelope — ⊥ klaim nyata. Verifikasi: jendela klaim (V32), math fee
(V33), siklus penuh klaim→fee, retry fee, resume, reward 0 → skipped, klaim
yang mendarat walau timeout (V34).
"""
import os
import sqlite3
import sys
import tempfile
from datetime import datetime, timedelta
from decimal import Decimal
import db
import claim
import telegram
import distribute as dist
def check(tag, cond):
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
class FakeSigned:
"""Objek SignedTransaction tiruan: id deterministik + send terkontrol."""
def __init__(self, txid, exc=None):
self._txid = txid
self._exc = exc
def id(self):
return self._txid
def send(self):
if self._exc is not None:
raise self._exc
return {'processed': True}
def fresh_db(tag):
"""DB sqlite sementara baru per bagian uji."""
tmp = tempfile.mkdtemp()
db.DB_PATH = os.path.join(tmp, f'claim-{tag}.db')
return db.DB_PATH
def last_claim_row():
row = db.queryone(
'SELECT claim_id, run_date, claim_txid, reward, fee_amount, '
' fee_status, fee_txid FROM claim_runs ORDER BY claim_id DESC '
'LIMIT 1')
return row
def fake_post(data):
return type('R', (), {'raise_for_status': lambda self: None,
'json': lambda self: data})()
def main():
# Matikan sleep nyata (settle/poll) + Telegram selama uji.
claim.time.sleep = lambda s: None
_tg_token = telegram.TELEGRAM_BOT_TOKEN
_tg_comm = telegram.TELEGRAM_COMMUNITY_CHAT_IDS
_tg_int = telegram.TELEGRAM_INTERNAL_CHAT_IDS
_tg_post = telegram.requests.post
telegram.TELEGRAM_BOT_TOKEN = ''
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = []
telegram.TELEGRAM_INTERNAL_CHAT_IDS = []
try:
# ————— V32: parse_claim_time — format chain —————
check('V32 parse format %f',
claim.parse_claim_time('2026-08-05T07:01:44.500')
== datetime(2026, 8, 5, 7, 1, 44, 500000))
check('V32 parse format detik',
claim.parse_claim_time('2026-08-05T07:01:44')
== datetime(2026, 8, 5, 7, 1, 44))
check('V32 parse kosong → None', claim.parse_claim_time(None) is None)
check('V32 parse sampah → None', claim.parse_claim_time('x') is None)
# ————— V32: next_window = last_claim_time + 24 jam —————
claim.fetch_producer_last_claim = lambda: '2026-08-05T07:01:44.500'
now = datetime(2026, 8, 6, 12, 0, 0)
check('V32 jendela = last + 24 jam',
claim.next_window(now) == datetime(2026, 8, 6, 7, 1, 44, 500000))
claim.fetch_producer_last_claim = lambda: None
check('V32 tanpa last → now (langsung coba)',
claim.next_window(now) == now)
# ————— V32/V33: siklus penuh — klaim → catat → fee terkirim —————
fresh_db('cycle')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('100.0000'), Decimal('110.0004')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned('txclaim')
dist.verify_txid = lambda txid: True
r1 = claim.step()
check('V32 step klaim sukses → claimed', r1 == 'claimed')
row = last_claim_row()
check('V32 klaim tercatat (reward 10.0004, fee 1.0000 floor)',
row is not None and row[3] == 10.0004 and row[4] == 1.0
and row[2] == 'txclaim' and row[5] == 'pending')
memos = []
def fake_fee_sign(to, amount, memo):
memos.append((to, amount, memo))
return FakeSigned('txfee')
dist.build_signed_transfer = fake_fee_sign
r2 = claim.step()
check('V34 fee terkirim → fee_sent', r2 == 'fee_sent')
check('V33 fee ke wallet bpdbsjasprod', memos[-1][0] == 'bpdbsjasprod')
check('V33 memo BP FEE + tanggal',
memos[-1][2] == f'BP FEE {datetime.now().strftime("%Y-%m-%d")}')
row = last_claim_row()
check('V34 fee ditandai sent + txid',
row[5] == 'sent' and row[6] == 'txfee')
# ————— V33/V34: fee gagal → failed → diulang sampai terkirim —————
fresh_db('retry')
db.record_claim('2026-08-05', 'txold', 10.0, 1.0, 'pending',
'2026-08-05T08:00:00')
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned(
'txfail', exc=RuntimeError('net down'))
dist.verify_txid = lambda txid: False
check('V34 fee gagal → fee_failed', claim.step() == 'fee_failed')
check('V34 status failed',
last_claim_row()[5] == 'failed')
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txok')
check('V34 fee diulang → fee_sent', claim.step() == 'fee_sent')
row = last_claim_row()
check('V34 fee akhirnya sent', row[5] == 'sent' and row[6] == 'txok')
# ————— V33: reward 0 → fee skipped; step lanjut ke jadwal —————
fresh_db('zero')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('100.0000'), Decimal('100.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned('txzero')
dist.verify_txid = lambda txid: True
check('V33 klaim reward 0 → claimed', claim.step() == 'claimed')
row = last_claim_row()
check('V33 fee 0 → skipped', row[4] == 0.0 and row[5] == 'skipped')
# pending_claim_fee ⊥ memuat fee 0 → step lanjut ke jadwal (sleep)
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=20)
).strftime('%Y-%m-%dT%H:%M:%S')
check('V34 ⊥ fee pending → jadwal (sleep)', claim.step() == 'sleep')
# ————— V34: klaim timeout tapi mendarat (verify True) → diukur —————
fresh_db('landed')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=25)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('50.0000'), Decimal('60.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned(
'txL', exc=RuntimeError('broadcast timeout'))
dist.verify_txid = lambda txid: True
check('V34 timeout tapi mendarat → claimed',
claim._try_claim_once() == 'claimed')
row = last_claim_row()
check('V34 reward terukur', row[3] == 10.0 and row[2] == 'txL')
# ————— V34: verify None + last_claim_time maju → dianggap mendarat —————
fresh_db('reconcile')
claim.fetch_producer_last_claim = lambda: (
datetime.now() - timedelta(hours=1)
).strftime('%Y-%m-%dT%H:%M:%S')
balances = iter([Decimal('10.0000'), Decimal('12.0000')])
dist.fetch_balance = lambda: next(balances)
claim.build_claim_action = lambda: FakeSigned(
'txN', exc=RuntimeError('timeout'))
dist.verify_txid = lambda txid: None
check('V34 verify None + last maju → claimed',
claim._try_claim_once() == 'claimed')
check('V34 reward tetap diukur',
last_claim_row()[3] == 2.0)
# ————— V32: klaim ditolak → retry; poll deadline lewat → timeout —————
fresh_db('timeout')
claim.build_claim_action = lambda: FakeSigned(
'txT', exc=RuntimeError('rejected'))
dist.verify_txid = lambda txid: False
dist.fetch_balance = lambda: Decimal('100.0000')
check('V32 klaim ditolak → retry',
claim._try_claim_once() == 'retry')
check('V32 deadline lewat → timeout',
claim.poll_claim(datetime.now() - timedelta(seconds=1))
== 'timeout')
# ————— resume: fee failed dari DB → step mengirimnya —————
fresh_db('resume')
db.record_claim('2026-08-05', 'txr', 10.0, 1.0, 'failed',
'2026-08-05T08:00:00')
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txr1')
dist.verify_txid = lambda txid: True
check('V34 resume fee dari DB → fee_sent', claim.step() == 'fee_sent')
check('V34 resume → sent',
last_claim_row()[5] == 'sent' and last_claim_row()[6] == 'txr1')
# ————— V36: klaim reward → kanal INTERNAL saja, ⊥ komunitas —————
tg = {'posts': []}
def tg_post(url, json=None, timeout=None):
tg['posts'].append((url, json['chat_id'], json['text']))
return fake_post({})
telegram.TELEGRAM_BOT_TOKEN = '123:TEST'
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = ['-1001']
telegram.TELEGRAM_INTERNAL_CHAT_IDS = ['-1002']
telegram.requests.post = tg_post
telegram.notify_claim('10.0000', '1.0000', 'bpdbsjasprod')
telegram.notify_claim_failure(RuntimeError('net down'))
check('V36 klaim → internal saja',
len(tg['posts']) == 2
and all(p[1] == '-1002' for p in tg['posts']))
check('V36 ⊥ komunitas utk klaim',
not any(p[1] == '-1001' for p in tg['posts']))
check('V36 isi notif klaim',
'KLAIM REWARD SUKSES' in tg['posts'][0][2]
and 'KLAIM GAGAL' in tg['posts'][1][2])
finally:
claim.time.sleep = time.sleep
telegram.TELEGRAM_BOT_TOKEN = _tg_token
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = _tg_comm
telegram.TELEGRAM_INTERNAL_CHAT_IDS = _tg_int
telegram.requests.post = _tg_post
print('\nSEMUA UJI PASS')
if __name__ == '__main__':
import time
sys.exit(main())
+125
View File
@@ -17,6 +17,7 @@ from decimal import Decimal
import db
import distribute as dist
import telegram
def check(tag, cond):
@@ -68,6 +69,19 @@ def main():
db.DB_PATH = os.path.join(tmp, 'test.db')
_orig_query = db.query
# Oracle: Telegram ⊥ boleh mengirim nyata — nonaktifkan dulu, dipakai lagi §V30
_tg_token = telegram.TELEGRAM_BOT_TOKEN
_tg_comm = telegram.TELEGRAM_COMMUNITY_CHAT_IDS
_tg_int = telegram.TELEGRAM_INTERNAL_CHAT_IDS
_tg_post = telegram.requests.post
telegram.TELEGRAM_BOT_TOKEN = ''
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = []
telegram.TELEGRAM_INTERNAL_CHAT_IDS = []
# V35: kill-switch default OFF (payout ⊥ jalan tanpa diset true)
check('V35 default config off', dist.DISTRIBUTE_ENABLED is False)
dist.DISTRIBUTE_ENABLED = True # run-nyata di bawah pakai enabled
# ————— V20: fetch_balance — parse chain get_currency_balance —————
dist.requests.post = lambda url, json=None, timeout=30: fake_post(
['52186.0417 VEX'])
@@ -215,6 +229,117 @@ def main():
check('V22 run status partial',
calls['run_updates'][0][1] == 'partial')
# ————— V35: kill-switch DISTRIBUTE_ENABLED=false → no-op —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
'2026-08-05T00:00:00')])
db.query = _orig_query
dist.fetch_balance = lambda: Decimal('100.0000')
_orig_record_run = db.record_run
_orig_build = dist.build_signed_transfer
dist.DISTRIBUTE_ENABLED = False
calls['runs'] = []
calls['signs'] = []
db.record_run = lambda *a, **k: calls['runs'].append(a) or 99
dist.build_signed_transfer = (lambda to, amount, memo:
calls['signs'].append(memo)
or FakeSigned('txoff'))
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
out = buf.getvalue()
check('V35 disabled → exit 0', rc == 0)
check('V35 disabled → pesan dinonaktifkan', 'dinonaktifkan' in out)
check('V35 disabled → ⊥ tulis DB', calls['runs'] == [])
check('V35 disabled → ⊥ tanda tangan', calls['signs'] == [])
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=True)
check('V35 disabled + dry-run → rencana tetap',
rc == 0 and 'RENCANA' in buf.getvalue())
dist.DISTRIBUTE_ENABLED = True
db.record_run = _orig_record_run
dist.build_signed_transfer = _orig_build
# ————— V30: notifikasi Telegram — dua kanal, komunitas brief/internal detail —————
tg = {'posts': []}
def tg_post(url, json=None, timeout=None):
tg['posts'].append((url, json['chat_id'], json['text']))
return fake_post({})
telegram.TELEGRAM_BOT_TOKEN = '123:TEST'
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = ['-1001']
telegram.TELEGRAM_INTERNAL_CHAT_IDS = ['-1002']
telegram.requests.post = tg_post
# send_text tanpa token → no-op senyap (⊥ post)
telegram.TELEGRAM_BOT_TOKEN = ''
telegram.send_text('tidak boleh terkirim')
telegram.send_internal('tidak boleh terkirim')
check('V30 ⊥ token → no-op senyap', tg['posts'] == [])
telegram.TELEGRAM_BOT_TOKEN = '123:TEST'
# dry-run → ⊥ notif Telegram
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
'2026-08-05T00:00:00'),
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
'2026-08-05T00:00:00')])
db.query = _orig_query
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txT' + to)
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
dist.verify_txid = lambda txid: True
calls['runs'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=True)
check('V30 dry-run ⊥ notif', tg['posts'] == [])
# run nyata → komunitas 2 brief + internal 2 detail
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
rc = dist.run_distribution(dry_run=False)
check('V30 run nyata exit 0', rc == 0)
comm = [p for p in tg['posts'] if p[1] == '-1001']
intern = [p for p in tg['posts'] if p[1] == '-1002']
check('V30 komunitas 2 notif brief', len(comm) == 2)
check('V30 komunitas brief MULAI', 'DISTRIBUSI MULAI' in comm[0][2]
and 'Pemilih' not in comm[0][2])
check('V30 komunitas brief SELESAI + status', 'DISTRIBUSI SELESAI'
in comm[1][2] and 'Status: ok' in comm[1][2]
and 'Terkirim' not in comm[1][2])
check('V30 internal 2 notif detail', len(intern) == 2)
check('V30 internal detail MULAI', 'DISTRIBUSI MULAI' in intern[0][2]
and 'Pemilih: 2' in intern[0][2])
check('V30 internal detail SELESAI', '2/2 pemilih' in intern[1][2]
and 'Gagal: 0' in intern[1][2])
# no-op (pemilih kosong) → ⊥ notif baru
db.query = lambda sql, params=(): []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V30 no-op ⊥ notif', len(tg['posts']) == 4)
db.query = _orig_query
# failure (⊥ key) → notif gagal ke INTERNAL saja (ops detail)
dist.BP_PRIVATE_KEY = ''
try:
dist.main()
raise AssertionError('V30 gagal: main harusnya re-raise')
except RuntimeError:
check('V30 failure → re-raise RuntimeError', True)
new_posts = tg['posts'][4:]
check('V30 gagal → internal saja',
len(new_posts) == 1 and new_posts[0][1] == '-1002'
and 'DISTRIBUSI GAGAL' in new_posts[0][2])
dist.BP_PRIVATE_KEY = '5Ktest'
telegram.TELEGRAM_BOT_TOKEN = _tg_token
telegram.TELEGRAM_COMMUNITY_CHAT_IDS = _tg_comm
telegram.TELEGRAM_INTERNAL_CHAT_IDS = _tg_int
telegram.requests.post = _tg_post
print('\nSEMUA UJI PASS')
+86
View File
@@ -0,0 +1,86 @@
"""Uji V31: tiap image stack mandiri — daftar COPY memuat semua impor intra-proyek.
`./venv/bin/python test_images.py` harus exit 0. Memindai `Dockerfile`,
`Dockerfile.scan`, `Dockerfile.dist`, `Dockerfile.claim`: untuk tiap modul .py
yang di-COPY, kumpulkan impor intra-proyek (top-level `import X` / `from X
import`) lalu pastikan semuanya ada di daftar COPY image itu. Menangkap kelas
bug B4: modul yang diimpor modul yang di-COPY tapi ⊥ ikut disalin →
ModuleNotFoundError di container (image ⊥ mandiri).
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
PROJECT = {'config', 'db', 'get_voters', 'dashboard', 'gunicorn_conf',
'scan_loop', 'distribute', 'distribute_loop', 'telegram',
'claim', 'claim_loop'}
IMAGES = {
'web': 'Dockerfile',
'scan': 'Dockerfile.scan',
'dist': 'Dockerfile.dist',
'claim': 'Dockerfile.claim',
}
def copied_modules(dfile):
"""Modul .py yang di-COPY oleh `COPY <files...> ./`."""
mods = set()
with open(os.path.join(ROOT, dfile)) as fh:
for line in fh:
if not line.startswith('COPY '):
continue
tokens = line.split()
if tokens[-1] != './':
continue
for tok in tokens[1:-1]:
if tok.endswith('.py'):
mods.add(tok[:-3])
return mods
def project_imports(mod):
"""Impor intra-proyek dari sumber modul `mod`."""
imports = set()
path = os.path.join(ROOT, mod + '.py')
if not os.path.exists(path):
return imports
with open(path) as fh:
src = fh.read()
for m in re.finditer(r'^\s*import\s+([\w.]+)', src, re.M):
name = m.group(1).split('.')[0]
if name in PROJECT:
imports.add(name)
for m in re.finditer(r'^\s*from\s+([\w.]+)\s+import', src, re.M):
name = m.group(1).split('.')[0]
if name in PROJECT:
imports.add(name)
return imports
def check(tag, cond):
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
def main():
for image, dfile in IMAGES.items():
copied = copied_modules(dfile)
check(f'V31 {image}: ada modul .py di COPY',
bool(copied) and 'config' in copied)
for mod in sorted(copied):
missing = project_imports(mod) - copied
if missing:
raise AssertionError(
f'GAGAL: V31 {image}: {mod} impor {sorted(missing)} '
f'yang ⊥ di-COPY')
print(f'ok: V31 {image}: {mod} impor ⊆ COPY')
print('\nSEMUA UJI PASS')
if __name__ == '__main__':
sys.exit(main())