Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e6e93796 | ||
|
|
d3a0e768a0 | ||
|
|
0b1749a062 | ||
|
|
025f46d399 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
|
||||
@@ -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'))
|
||||
+17
-3
@@ -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
|
||||
return run_distribution(dry_run=dry_run)
|
||||
try:
|
||||
return run_distribution(dry_run=dry_run)
|
||||
except Exception as exc:
|
||||
telegram.notify_failure(exc)
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
+41
-7
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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')
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user