Compare commits

...
8 Commits
15 changed files with 333 additions and 76 deletions

No files matched your search

+8
View File
@@ -51,6 +51,14 @@ SCAN_HOURS=0,8,16
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
SCAN_RUN_ON_START=1
# — Distribusi profit-share harian (service distribute) —
# Jam (lokal, TZ) run harian. `VEX_BP_PRIVATE_KEY` TIDAK lagi lewat interpolasi
# .env: di stack ia dibaca dari file `.env` yang di-bind dari NFS
# (`/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` → `/app/.env`,
# dibaca `config.py` via load_dotenv). Di luar stack, set key di `.env` lokal.
DISTRIBUTE_HOUR=10
DISTRIBUTE_MAX_ATTEMPTS=3
# Kredensial mariadb stack — wajib konsisten antar-service (mariadb/web/scan)
MARIADB_ROOT_PASSWORD=root
MARIADB_DATABASE=databisnisid
+9 -8
View File
@@ -1,6 +1,6 @@
# 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.
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`.
## Run
@@ -10,10 +10,10 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- 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` — 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 image), 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`). `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` 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` — 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`.
- 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 test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py`.
- 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.
## Key facts
@@ -26,10 +26,10 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- The voters table has a sentinel first row (`owner="...........q"`, uint64 0) that must be skipped.
- Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6).
- Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`.
- The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
- 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.
- Dashboard layout (spec §V28): desktop/tablet balances the STAKE column near page center; `/history` uses six-column run/payment grids; mobile turns history rows into labeled cards, including MEMO and TXID. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting.
- 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.
## Layout
@@ -39,12 +39,13 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- `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`.
- `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).
- `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. `/history` = riwayat distribusi read-only; `run-list`/`pay-list` use six-column desktop/tablet grids and labeled mobile cards.
- `dashboard.py` + `templates/index.html` + `templates/history.html` + `static/style.css` + `static/app.js` — Flask web dashboard; reads the store via `db.py`, styled per `DESIGN.md`; `app.js` = debounced live owner search (fetch `/api/search`), degrades to the server-side `?q=` GET form if JS is off. 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.
- `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.
- `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`). Di stack produksi keduanya di-push ke registry `git.proit.id/proitlab/databisnisid-{web,scan}` (⊥ `build:` di compose). `.dockerignore` — venv/.env/artifak tak masuk build context.
- `docker-compose.yml` — STACK SWARM PRODUKSI (mariadb internal + web `:5000` + scan); 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`. `docker-compose.dev.yml` — mariadb uji lokal (`127.0.0.1:3306`, volume `mariadb_data`).
- `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`).
- `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 distribusi 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 distribute.py distribute_loop.py ./
CMD ["python", "distribute_loop.py"]
+14 -10
View File
@@ -21,11 +21,11 @@ Profit-share: tiap hari 10:00 lokal → kirim saldo liquid VEX akun BP ke pemili
- voter basi (revote > `VEX_STALE_DAYS`) atau `last_vote` tak diketahui → dibuang di `normalize` (⊥ disimpan); hanya pemilih segar yang masuk DB
- output/comments: Indonesian
- run harian → ganti snapshot (DROP+INSERT tiap skan; ⊥ history/append)
- produksi stack: Docker Swarm multi-node (node DB `server5.saltis.id`) — image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` (⊥ `build:` di stack), deploy `docker stack deploy -c docker-compose.yml databisnisid`; mariadb bind mount `/data/db/mariadb/databisnisid/data` pd node `server5.saltis.id` (placement constraint `node.hostname == server5.saltis.id`), web/scan di node mana pun via overlay `appnet`; scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, waktu lokal `TZ` default Asia/Jakarta) + skan-awal `SCAN_RUN_ON_START`
- produksi stack: Docker Swarm multi-node (node DB `server5.saltis.id`) — image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` & `-dist` (⊥ `build:` di stack), deploy `docker stack deploy -c docker-compose.yml databisnisid`; mariadb bind mount `/data/db/mariadb/databisnisid/data` pd node `server5.saltis.id` (placement constraint `node.hostname == server5.saltis.id`), web/scan/distribute di node mana pun via overlay `appnet`; scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, waktu lokal `TZ` default Asia/Jakarta) + skan-awal `SCAN_RUN_ON_START`; distribute berjalan pd jam `DISTRIBUTE_HOUR` (default 10) via `distribute_loop.py` (image `-dist`), key aktif BP dibaca dari bind NFS `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` (⊥ interpolasi)
- token VEX = kontrak `vex.token` (⊥ `eosio.token` — tak ada di chain; konstanta chain, ⊥ env-able), precision 4 desimal, issuer `vexcore`
- chain_id = `f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f` (nodeos v1.2.2) — utk signing
- saldo distribusi: chain `get_currency_balance` pd `vex.token` utk `TARGET_BP` (⊥ Hyperion; = SALDO LIQUID dashboard)
- signing: `pyntelope` (Python; eospy→eospyo→pyntelope); key aktif BP via swarm secret/env `VEX_BP_PRIVATE_KEY` — ⊥ repo, ⊥ log
- signing: `pyntelope` (Python; eospy→eospyo→pyntelope); key aktif BP `VEX_BP_PRIVATE_KEY` — ⊥ repo, ⊥ log; di stack dibaca dari `/app/.env` hasil bind NFS `config.env` (load_dotenv), di dev via env/`.env` lokal
- distribusi: harian `DISTRIBUTE_HOUR` (default 10) lokal `TZ` → SELURUH saldo liquid pro-rata `staked` ke pemilih segar tersimpan (snapshot terkini = scan 08:00)
- share = floor(balance × stake ÷ total_stake), 4 desimal; sisa (dust) TETAP di akun (⊥ drain penuh)
- memo per run, sama utk semua transfer: `DATABISNISID PROFIT SHARE YYYY-MM-DD`
@@ -52,10 +52,10 @@ api: POST `https://v2.vexascan.com:2096/v1/chain/push_transaction` (signed via p
api: POST `https://v2.vexascan.com:2096/v1/chain/get_info` → chain_id (utk signing)
api: GET `https://api.databisnis.id/v2/history/get_transaction?id=<txid>` (Hyperion) → {executed, trx_id, lib} — guard resend
db: table `distribute_runs` (run_id PK, run_date, balance_start, total_voters, total_staked, total_sent, status, created_at) via `db.py`; append
db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, memo, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append
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; 6-kolom desktop/tablet, kartu berlabel mobile
env: `VEX_BP_PRIVATE_KEY` (secret swarm / env dev), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS` — via `config.py` (`.env`)
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`)
## §V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
@@ -71,20 +71,21 @@ V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥
V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensitive); urut+paging terjaga; `rank` global (⊥ rank-dalam-filter); `?q=` & `/api/search` read-only
V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config`
V13: `last_vote` = `2000-01-01 + log2(last_vote_weight / (staked×10000)) × 365.25 hari` (None bila bobot/stake nol); estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` atau `< now − VEX_STALE_DAYS` dibuang di `normalize` (⊥ disimpan) → DB hanya berisi pemilih segar
V15: web list = kolom RANK/AKUN/STAKE (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA, ⊥ penanda/stat/filter basi; stats = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI); `/api/search` hasil tetap `{owner,staked,weight,rank,last_vote}` (weight disimpan & di-API, ⊥ dirender)
V15: web list = kolom RANK/AKUN/STAKE (VEX)/TOTAL REWARD (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA, ⊥ penanda/stat/filter basi; stats = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI); `/api/search` hasil `{owner,staked,weight,rank,last_vote,total_reward}` (weight disimpan & di-API, ⊥ dirender)
V16: saldo liquid akun `TARGET_BP` dari `GET {DATABISNIS_API}/v2/state/get_account` (parse `account.core_liquid_balance`) → sel stat SALDO LIQUID; cache in-memory per worker TTL `DASH_LIQUID_TTL`; gagal fetch → nilai lama (atau `—` bila belum pernah sukses) + `at` ikut diset (cooldown, ⊥ pukulan berulang); read-only, ⊥ tulis DB
V17: penyimpanan lewat `db.py` (backend `VEX_DB_BACKEND` = `sqlite` default | `mysql`); query berbagi sintaks pakai placeholder `%s` (diterjemahkan `?` utk sqlite); LIKE escape pakai `ESCAPE '!'` — backslash memutus literal string MySQL (B2); `db.query` → list (fetchall sqlite=list / PyMySQL=tuple diseragamkan, B3); replace snapshot mysql = CREATE IF NOT EXISTS + DELETE + INSERT satu transaksi (MVCC → pembaca dapat snapshot konsisten saat ganti harian, tak kena torn read); oracle utama (test_get_voters/test_dashboard) tetap sqlite hermetik; `test_mariadb.py` opt-in (skip exit 0 bila `VEX_DB_BACKEND != mysql`)
V18: stack produksi = Docker Swarm (`docker-compose.yml`, `docker stack deploy -c docker-compose.yml databisnisid`): mariadb bind mount `/data/db/mariadb/databisnisid/data` pd node `server5.saltis.id` + placement constraint `node.hostname == server5.saltis.id` (data node-lokal → mariadb harus selalu di node itu; di swarm yg ⊥ punya node itu task-nya Pending, bukan bug); web (gunicorn, publik `:5000`) + scan (`scan_loop.py`) di node mana pun, terhubung via overlay `appnet`; image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` (⊥ `build:`); scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, lokal `TZ` default Asia/Jakarta via `datetime.now()`) + skan-awal `SCAN_RUN_ON_START` (setelah DB siap) → tabel voters langsung ada (dashboard ⊥ 500); loop tak pernah keluar; `stack deploy` ⊥ `env_file` (env inline via `${VAR}` interpolasi `.env`); image web set `DASH_HOST=0.0.0.0` agar ingress menjangkau; healthcheck web = cek socket TCP (⊥ ketergantungan isi DB)
V18: stack produksi = Docker Swarm (`docker-compose.yml`, `docker stack deploy -c docker-compose.yml databisnisid`): mariadb bind mount `/data/db/mariadb/databisnisid/data` pd node `server5.saltis.id` + placement constraint `node.hostname == server5.saltis.id` (data node-lokal → mariadb harus selalu di node itu; di swarm yg ⊥ punya node itu task-nya Pending, bukan bug); web (gunicorn, publik `:5000`) + scan (`scan_loop.py`) + distribute (`distribute_loop.py`, image `databisnisid-dist`) di node mana pun, terhubung via overlay `appnet`; image dari registry `git.proit.id/proitlab/databisnisid-web` & `-scan` & `-dist` (⊥ `build:`); scan berjalan pd jam `SCAN_HOURS` (default 0,8,16, lokal `TZ` default Asia/Jakarta via `datetime.now()`) + skan-awal `SCAN_RUN_ON_START` (setelah DB siap) → tabel voters langsung ada (dashboard ⊥ 500); distribute loop harian `DISTRIBUTE_HOUR` (default 10, lokal) — ⊥ distribusi-awal saat start (snapshot bisa basi); key `VEX_BP_PRIVATE_KEY` dari bind `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro` dibaca `config.py` `load_dotenv` (⊥ interpolasi env compose); loop tak pernah keluar; `stack deploy` ⊥ `env_file` (env inline via `${VAR}` interpolasi `.env`); image web set `DASH_HOST=0.0.0.0` agar ingress menjangkau; healthcheck web = cek socket TCP (⊥ ketergantungan isi DB)
V19: target transfer = kontrak `vex.token` (konstanta chain hardcoded; ⊥ diubah ke `eosio.token`)
V20: saldo distribusi dari chain `get_currency_balance` pd `vex.token`; share = floor(balance×stake÷total_stake) 4 desimal; Σshare ≤ balance; sisa (dust) tetap di akun
V21: ∀ transfer → dicatat di `distribute_payments` sbg `pending` SEBELUM kirim; status → `sent`|`failed` dr hasil tx; ⊥ hapus baris (append)
V22: retry → hanya baris `failed`; baris `sent` ⊥ dikirim ulang; sebelum resend, verifikasi txid via `GET {DATABISNIS_API}/v2/history/get_transaction` → `executed` → tandai `sent` (⊥ duplikat)
V23: memo per run = `DATABISNISID PROFIT SHARE YYYY-MM-DD` — sama utk seluruh transfer run
V23: memo per run = `DATABISNISID PROFIT SHARE YYYY-MM-DD` — sama utk seluruh transfer run, hanya on-chain (⊖ disimpan DB)
V24: run harian `DISTRIBUTE_HOUR` (default 10) waktu lokal `TZ`; ⊥ beku/carryover — hitung ulang tiap hari dari snapshot tersimpan
V25: pemilih kosong | saldo < 1 unit shareable → no-op senyap, ⊥ tulis DB
V26: `VEX_BP_PRIVATE_KEY` ⊥ pernah di-log/print; `--dry-run` utk uji lokal (⊥ sign)
V27: dashboard read-only; `/history` baca `distribute_*` via `db.py`; ⊥ mutasi
V28: web list → kolom STAKE/JUMLAH text center; STAKE dekat pusat halaman via grid seimbang; history runs/payments 6 kolom desktop/tablet; mobile → kartu berlabel ∀ field termasuk MEMO/TXID; HTML `no-store` + stylesheet versioned
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)
## §T — Tasks
id|status|task|cites
@@ -112,11 +113,14 @@ T21|x|distribute.py: fetch_balance (chain get_currency_balance) + compute_shares
T22|x|distribute.py: run_distribution — pending → sign (pyntelope) → push_transaction → sent/failed; memo per run; retry loop (backoff, dalam hari)|V21,V22,V23,V24,I.api
T23|x|distribute.py: guard resend — verifikasi txid via Hyperion get_transaction sebelum kirim ulang|V22,V23,I.api
T24|x|distribute.py: `--dry-run` (rencana ⊥ sign) + no-op saat pemilih kosong/saldo kecil|V25,V26,I.cmd
T25|.|scheduler: service `distribute` di stack swarm (Dockerfile.distribute, docker-compose, secret `VEX_BP_PRIVATE_KEY`; loop tunggu `DISTRIBUTE_HOUR` lokal)|V24,I.env
T25|x|scheduler: service `distribute` di stack swarm (Dockerfile.dist + distribute_loop.py, image `databisnisid-dist`, loop tunggu `DISTRIBUTE_HOUR` lokal, ⊥ distribusi-awal; key `VEX_BP_PRIVATE_KEY` dari bind NFS `config.env:/app/.env:ro`)|V18,V24,I.env
T26|x|dashboard: route `/history` (runs + per-voter log) + template, gaya DESIGN.md, read-only|V27,I.web
T27|x|tests: test_distribute.py (mock chain+pyntelope, temp DB: math share, memo, no-op, status/retry, guard duplikat) + extend test_dashboard.py (`/history`)|V20,V21,V22,V25,V26,V27,I.db
T28|x|hygiene: .gitignore += `.oc/`; stage penghapusan README.md (stub kosong)|
T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; `/history` 6-kolom + kartu mobile berlabel; CSS cache-bust + HTML no-store|V28,I.web
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
## §B — Bug log
id|date|cause|fix
+58 -15
View File
@@ -19,6 +19,18 @@ from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
SEARCH_CAP = 500
MAX_Q_LEN = 64
# Tabel distribute_payments diperlukan utk kolom TOTAL REWARD (V29). Dipastikan
# ada sekali per worker (idempoten) sebelum query list/search dijalankan.
_dist_schema_ready = False
def _ensure_dist_schema():
global _dist_schema_ready
if not _dist_schema_ready:
db.ensure_distribute_schema()
_dist_schema_ready = True
app = Flask(__name__)
@@ -93,18 +105,35 @@ def _escape_like(q):
.replace('_', '!_'))
def _filter_sql(q):
"""V11: klausa WHERE + params untuk filter owner substring."""
def _filter_sql(q, tbl=''):
"""V11: klausa WHERE + params untuk filter owner substring.
`tbl` = prefix kolom owner (mis. `v.`) bila query memakai JOIN; kosong utk
query tunggal agar kolom tak ambigu dengan kolom `owner` dari subquery.
"""
clauses = []
params = []
if q:
clauses.append("owner LIKE %s ESCAPE '!'")
clauses.append(f"{tbl}owner LIKE %s ESCAPE '!'")
params.append(f'%{_escape_like(q)}%')
if not clauses:
return '', []
return 'WHERE ' + ' AND '.join(clauses), params
def _reward_join():
"""V29: total reward per voter = Σ amount distribute_payments status=sent.
Gabung (LEFT) subquery agregat agar baris tanpa transfer tetap tampil dgn
reward 0. Hanya status `sent` (⊥ failed/pending) → tidak ada dobel hitung
saat baris gagal ikut run berikutnya.
"""
return ('LEFT JOIN (SELECT owner, SUM(amount) AS total '
' FROM distribute_payments '
' WHERE status = %s GROUP BY owner) rw '
' ON rw.owner = v.owner'), ['sent']
def _db_rows(page, q):
"""V10/V11: satu halaman, urut staked DESC lalu owner ASC; rank global.
@@ -113,17 +142,19 @@ def _db_rows(page, q):
Correlated count selalu menghitung atas SELURUH tabel → rank tetap global.
"""
offset = (page - 1) * PAGE_SIZE
where, params = _filter_sql(q)
where, params = _filter_sql(q, 'v.')
rw_join, rw_params = _reward_join()
sql = (
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
' (SELECT COUNT(*) FROM voters v2 '
' WHERE v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
' v.last_vote '
'FROM voters v ' + where + ' '
' v.last_vote, '
' COALESCE(rw.total, 0) '
'FROM voters v ' + rw_join + ' ' + where + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
)
return db.query(sql, params + [PAGE_SIZE, offset])
return db.query(sql, rw_params + params + [PAGE_SIZE, offset])
def _count(q):
@@ -154,6 +185,7 @@ def index():
except ValueError:
abort(400)
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
_ensure_dist_schema()
total_voters, total_staked, top_staked, scanned = _stats()
liquid_vex = _get_liquid_vex()
filtered_count = _count(q)
@@ -179,19 +211,25 @@ def index():
@app.get('/api/search')
def api_search():
"""V11: JSON hasil pencarian owner (substring), urut staked DESC, rank global."""
"""V11: JSON hasil pencarian owner (substring), urut staked DESC, rank global.
Menyertakan `total_reward` (V29) agar hasil search sama kolomnya dgn list.
"""
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
where, params = _filter_sql(q)
_ensure_dist_schema()
where, params = _filter_sql(q, 'v.')
rw_join, rw_params = _reward_join()
sql = (
'SELECT v.owner, v.staked, v.weight, '
' (SELECT COUNT(*) FROM voters v2 '
' WHERE v2.staked > v.staked '
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
' v.last_vote '
'FROM voters v ' + where + ' '
' v.last_vote, '
' COALESCE(rw.total, 0) '
'FROM voters v ' + rw_join + ' ' + where + ' '
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s'
)
rows = db.query(sql, params + [SEARCH_CAP])
rows = db.query(sql, rw_params + params + [SEARCH_CAP])
return jsonify({
'query': q,
'count': len(rows),
@@ -199,7 +237,7 @@ def api_search():
'results': [
{
'owner': r[0], 'staked': r[1], 'weight': r[2],
'rank': r[3], 'last_vote': r[4],
'rank': r[3], 'last_vote': r[4], 'total_reward': r[5],
}
for r in rows
],
@@ -208,10 +246,15 @@ def api_search():
@app.get('/history')
def history():
"""V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih."""
"""V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih.
Payments hanya run terbaru (V28); tanggal run terbaru ditampilkan di judul.
"""
runs = db.list_runs()
payments = db.list_payments()
latest = runs[0][0] if runs else None
payments = db.list_payments(run_id=latest)
return render_template('history.html', runs=runs, payments=payments,
latest_date=runs[0][1] if runs else None,
bp=TARGET_BP)
+35 -12
View File
@@ -116,7 +116,6 @@ _PAY_COLS = (
'run_id INTEGER NOT NULL, '
'owner VARCHAR(13) NOT NULL, '
'amount DOUBLE NOT NULL, '
'memo TEXT NOT NULL, '
'txid TEXT, '
'status TEXT NOT NULL, '
'error TEXT, '
@@ -141,11 +140,32 @@ def ensure_distribute_schema():
else:
cur.execute(_RUN_DDL_MYSQL)
cur.execute(_PAY_DDL_MYSQL)
_drop_pay_memo(cur)
conn.commit()
finally:
conn.close()
def _drop_pay_memo(cur):
"""Migrasi: buang kolom memo lama dari distribute_payments (idempoten).
Kolom `memo` pernah disimpan (V23) lalu dihapus — DB produksi yang sudah
punya kolom perlu di-alter; DB baru langsung tanpa kolom.
"""
if DB_BACKEND == 'sqlite':
cur.execute(
"SELECT name FROM pragma_table_info('distribute_payments')")
cols = {row[0] for row in cur.fetchall()}
else:
cur.execute(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
" AND TABLE_NAME = 'distribute_payments'")
cols = {row[0] for row in cur.fetchall()}
if 'memo' in cols:
cur.execute(_translate('ALTER TABLE distribute_payments DROP COLUMN memo'))
def record_run(run_date, balance_start, total_voters, total_staked,
total_sent, status, created_at):
"""Insert baris `distribute_runs`, kembalikan run_id."""
@@ -165,7 +185,7 @@ def record_run(run_date, balance_start, total_voters, total_staked,
conn.close()
def record_payment(run_id, owner, amount, memo, status, created_at,
def record_payment(run_id, owner, amount, status, created_at,
txid=None, error=None):
"""Insert baris `distribute_payments` → payment_id."""
ensure_distribute_schema()
@@ -174,9 +194,9 @@ def record_payment(run_id, owner, amount, memo, status, created_at,
cur = conn.cursor()
cur.execute(_translate(
'INSERT INTO distribute_payments '
'(run_id, owner, amount, memo, txid, status, error, created_at, '
' updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)'),
(run_id, owner, amount, memo, txid, status, error, created_at,
'(run_id, owner, amount, txid, status, error, created_at, '
' updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)'),
(run_id, owner, amount, txid, status, error, created_at,
created_at))
conn.commit()
return cur.lastrowid
@@ -224,11 +244,14 @@ def list_runs():
'FROM distribute_runs ORDER BY run_id DESC')
def list_payments():
"""Transfer per-pemilih, terbaru dulu (gabung run untuk tanggal)."""
def list_payments(run_id=None):
"""Transfer per-pemilih, terbaru dulu; opsional filter satu run."""
ensure_distribute_schema()
return query(
'SELECT p.payment_id, p.run_id, p.owner, p.amount, p.memo, p.txid, '
' p.status, p.error, p.created_at, p.updated_at, r.run_date '
'FROM distribute_payments p JOIN distribute_runs r '
' ON p.run_id = r.run_id ORDER BY p.payment_id DESC')
sql = ('SELECT payment_id, run_id, owner, amount, txid, status, error, '
' created_at, updated_at FROM distribute_payments')
params = ()
if run_id is not None:
sql += ' WHERE run_id = %s'
params = (run_id,)
sql += ' ORDER BY payment_id DESC'
return query(sql, params)
+1 -1
View File
@@ -160,7 +160,7 @@ def run_distribution(dry_run=False):
0.0, 'ok', created_at)
pending = []
for owner, share in shares:
pid = db.record_payment(run_id, owner, float(share), memo, 'pending',
pid = db.record_payment(run_id, owner, float(share), 'pending',
created_at)
pending.append((pid, owner, share))
+77
View File
@@ -0,0 +1,77 @@
"""Jadwal distribusi harian: jalankan distribute.py pada jam DISTRIBUTE_HOUR.
Menunggu hingga batas `DISTRIBUTE_HOUR` berikutnya (waktu lokal via `TZ`)
lalu memanggil `distribute.main()`. Loop tak pernah keluar — service dijaga
hidup oleh restart_policy swarm. Gagal distribusi dicatat lalu lanjut ke hari
berikutnya (⊥ membunuh loop). Tanpa `VEX_BP_PRIVATE_KEY` run nyata raise
RuntimeError yang jelas — ditangkap, dicetak, dicoba lagi besok.
Berbeda dengan scan: ⊥ ada "distribusi-awal" saat start (SCAN_RUN_ON_START) —
membayar di container start berisiko memakai snapshot basi; distribusi hanya
berjalan pada jam terjadwal.
Scheduler helper (`next_boundary`, `_wait_db`) sengaja diduplikasi dari
`scan_loop.py` (bukan di-import) karena kedua image Docker terpisah:
`Dockerfile.dist` ⊥ membawa `scan_loop.py`/`get_voters.py`.
"""
import os
import time
from datetime import datetime, timedelta
import db
import distribute
def next_boundary(now, hours):
"""datetime berikutnya pada jam-jam `hours` yang lebih besar dari `now`.
Sama dengan `scan_loop.next_boundary` (mirror agar image dist mandiri).
"""
for h in hours:
cand = now.replace(hour=h, minute=0, second=0, microsecond=0)
if cand > now:
return cand
# semua jam hari ini sudah lewat → besok jam pertama
nxt = now + timedelta(days=1)
return nxt.replace(hour=hours[0], minute=0, second=0, microsecond=0)
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 _run_distribute(label):
print(f'[{label}] memulai distribusi...', flush=True)
try:
distribute.main()
except Exception as exc:
print(f'[Error] Distribusi gagal: {exc}', flush=True)
def main():
hour = int(os.getenv('DISTRIBUTE_HOUR', '10'))
if not 0 <= hour <= 23:
raise ValueError(f'DISTRIBUTE_HOUR tak valid: {hour!r}')
print(f'Jadwal distribusi: jam {hour} (waktu lokal)')
_wait_db()
while True:
now = datetime.now()
target = next_boundary(now, [hour])
wait = (target - now).total_seconds()
print(f'{now.isoformat(timespec="seconds")} tidur {wait / 3600:.2f} jam '
f'sampai {target.isoformat(timespec="seconds")}', flush=True)
time.sleep(max(wait, 0))
_run_distribute('distribusi terjadwal')
if __name__ == '__main__':
main()
+26 -2
View File
@@ -1,7 +1,8 @@
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
#
# 3 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ).
# 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).
#
# Catatan swarm:
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
@@ -84,6 +85,29 @@ services:
restart_policy:
condition: any
distribute:
image: git.proit.id/proitlab/databisnisid-dist:latest
command: ["python", "distribute_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}
DISTRIBUTE_HOUR: ${DISTRIBUTE_HOUR:-10}
DISTRIBUTE_MAX_ATTEMPTS: ${DISTRIBUTE_MAX_ATTEMPTS:-3}
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
restart_policy:
condition: any
networks:
appnet:
driver: overlay
+1
View File
@@ -60,6 +60,7 @@
'<span class="rank">' + r.rank + '</span>' +
'<span class="owner">' + escapeHtml(r.owner) + '</span>' +
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</span>' +
'<span class="reward" data-label="TOTAL REWARD (VEX)">' + vex(r.total_reward) + '</span>' +
'<span class="vote-date" data-label="VOTE TERAKHIR">' + shortdate(r.last_vote) + '</span>' +
'</div>';
}).join('');
+33 -8
View File
@@ -181,7 +181,7 @@ button.pill {
.list-header,
.list-row {
display: grid;
grid-template-columns: 72px 1fr 200px 1fr;
grid-template-columns: 72px repeat(3, 1fr);
gap: var(--sp-lg);
align-items: baseline;
padding: var(--sp-lg) var(--sp-sm);
@@ -193,7 +193,11 @@ button.pill {
}
.pay-list .list-header,
.pay-list .list-row {
grid-template-columns: 1fr 140px 170px 100px 1.2fr 260px;
grid-template-columns: 200px 200px 120px 1fr;
}
.voter-list .list-header,
.voter-list .list-row {
grid-template-columns: 72px repeat(4, 1fr);
}
.list-header {
font-family: var(--font-mono);
@@ -218,7 +222,8 @@ button.pill {
color: var(--on-dark);
overflow-wrap: anywhere;
}
.stake {
.stake,
.reward {
font-family: var(--font-display);
font-size: 20px;
letter-spacing: 1px;
@@ -226,13 +231,24 @@ button.pill {
color: var(--on-dark);
font-variant-numeric: tabular-nums;
}
.list-header span:nth-child(3) { text-align: center; }
.list-row .vote-date { text-align: center; }
.list-header span:nth-child(3),
.list-header span:nth-child(4) { text-align: center; }
.voter-list .list-header span:nth-child(n+3) { text-align: center; }
.pay-list .list-header span:nth-child(2),
.pay-list .list-header span:nth-child(3) { text-align: center; }
.pay-list .list-header span:nth-child(4) { text-align: left; }
.txid {
font-size: 12px;
letter-spacing: 1px;
color: var(--muted);
overflow-wrap: anywhere;
}
.txid a {
color: var(--muted);
text-decoration: underline;
}
.txid a:hover { color: var(--on-dark); }
.empty {
font-family: var(--font-mono);
font-size: 12px;
@@ -293,6 +309,10 @@ button.pill {
@media (max-width: 1024px) {
.list-header,
.list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
.voter-list .list-header,
.voter-list .list-row { grid-template-columns: 56px 1fr 160px 1fr 1fr; }
.pay-list .list-header,
.pay-list .list-row { grid-template-columns: 180px 180px 110px 1fr; }
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
.stats { grid-template-columns: repeat(2, 1fr); }
.spec-cell:nth-child(3) { border-left: none; }
@@ -317,13 +337,18 @@ button.pill {
.list-row .owner { grid-column: 2; font-size: 18px; }
.list-row .stake,
.list-row .vote-date,
.list-row .txid { grid-column: 2; }
.list-row .stake { text-align: left; }
.list-row .txid,
.list-row .reward { grid-column: 2; }
.list-row .stake,
.list-row .reward { text-align: left; }
.list-row .vote-date { text-align: left; }
.list-row .txid { font-size: 12px; }
.list-row .stake { overflow-wrap: anywhere; }
.list-row .stake,
.list-row .reward { overflow-wrap: anywhere; }
.list-row .stake::before,
.list-row .vote-date::before,
.list-row .txid::before {
.list-row .txid::before,
.list-row .reward::before {
content: attr(data-label);
display: block;
font-family: var(--font-mono);
+4 -8
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=3) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=8) }}">
</head>
<body>
<header class="top-nav">
@@ -59,26 +59,22 @@
</div>
</section>
<p class="caption">TRANSFER PER PEMILIH</p>
<p class="caption">TRANSFER PER PEMILIH{% if latest_date %} · {{ latest_date }}{% endif %}</p>
<section class="spec-list pay-list">
<div class="list-header">
<span>AKUN</span>
<span>TANGGAL</span>
<span>JUMLAH</span>
<span>STATUS</span>
<span>MEMO</span>
<span>TXID</span>
</div>
<div class="list-rows">
{% for pay in payments %}
<div class="list-row">
<span class="owner">{{ pay[2] }}</span>
<span class="vote-date" data-label="TANGGAL">{{ pay[10] }}</span>
<span class="stake" data-label="JUMLAH">{{ pay[3]|vex }}</span>
<span class="vote-date" data-label="STATUS">{{ pay[6]|upper }}</span>
<span class="txid memo" data-label="MEMO">{{ pay[4] }}</span>
<span class="owner txid" data-label="TXID">{{ pay[5] or '—' }}</span>
<span class="vote-date" data-label="STATUS">{{ pay[5]|upper }}</span>
<span class="owner txid" data-label="TXID">{% if pay[4] %}<a href="https://vexascan.com/transaction/{{ pay[4] }}" target="_blank" rel="noopener">{{ pay[4] }}</a>{% else %}—{% endif %}</span>
</div>
{% else %}
<div class="empty">BELUM ADA TRANSFER</div>
+4 -2
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Saira+Condensed:wght@400&family=EB+Garamond:ital,wght@0,400;1,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=3) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=8) }}">
</head>
<body>
<header class="top-nav">
@@ -55,11 +55,12 @@
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
</p>
<section class="spec-list" id="spec-list">
<section class="spec-list voter-list" id="spec-list">
<div class="list-header">
<span>RANK</span>
<span>AKUN</span>
<span>STAKE (VEX)</span>
<span>TOTAL REWARD (VEX)</span>
<span>VOTE TERAKHIR</span>
</div>
<div class="list-rows" id="rows">
@@ -68,6 +69,7 @@
<span class="rank">{{ row[4] }}</span>
<span class="owner">{{ row[0] }}</span>
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</span>
<span class="reward" data-label="TOTAL REWARD (VEX)">{{ row[6]|vex }}</span>
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[5]|shortdate }}</span>
</div>
{% else %}
+36 -3
View File
@@ -55,12 +55,17 @@ def main():
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
# ————— V27: seed riwayat distribusi utk uji /history —————
# run lama di-seed duluan agar run_id-nya lebih kecil (⊥ run terbaru).
today = datetime.now().strftime('%Y-%m-%d')
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
dbm.ensure_distribute_schema()
old_rid = dbm.record_run(yesterday, 50000.0, 1, 1000.0, 80.0, 'ok',
'2026-08-04T10:00:00')
dbm.record_payment(old_rid, 'oldacct', 40.0, 'sent',
'2026-08-04T10:00:01', txid='txold999')
rid = dbm.record_run(today, 52186.0417, 1, 1200.0, 100.0, 'ok',
'2026-08-05T10:00:00')
dbm.record_payment(rid, 'acct001', 50.0,
f'DATABISNISID PROFIT SHARE {today}', 'sent',
dbm.record_payment(rid, 'acct001', 50.0, 'sent',
'2026-08-05T10:00:01', txid='txabc123')
client = dashboard.app.test_client()
@@ -88,6 +93,19 @@ def main():
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
for i in range(len(staked_vals) - 1)))
# ————— V29: kolom TOTAL REWARD (Σ transfer sent per voter, 0 bila tanpa) —————
resp = client.get('/?page=1')
html = resp.get_data(as_text=True)
check('V29 kolom TOTAL REWARD (VEX)', 'TOTAL REWARD (VEX)' in html)
check('V29 reward 0 utk tanpa transfer',
'<span class="reward" data-label="TOTAL REWARD (VEX)">0,0000</span>'
in html)
resp = client.get('/?page=3')
html = resp.get_data(as_text=True)
check('V29 reward acct001 = total sent (50,0000)',
'<span class="reward" data-label="TOTAL REWARD (VEX)">50,0000</span>'
in html)
# halaman melebihi total → dibatasi (tidak error)
resp = client.get('/?page=99')
check('V10 halaman ekstra → 200', resp.status_code == 200)
@@ -104,10 +122,16 @@ def main():
html = resp.get_data(as_text=True)
check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html)
check('V27 run date tampil', today in html)
check('V27 tanggal di judul TRANSFER PER PEMILIH',
f'TRANSFER PER PEMILIH · {today}' in html)
check('V27 transfer per-pemilih tampil', 'acct001' in html)
check('V27 hanya run terbaru (transfer lama tak tampil)',
'oldacct' not in html)
check('V27 status SENT tampil', 'SENT' in html)
check('V27 memo tercatat', f'DATABISNISID PROFIT SHARE {today}' in html)
check('V27 memo tak dirender', f'DATABISNISID PROFIT SHARE {today}' not in html)
check('V27 txid tercatat', 'txabc123' in html)
check('V27 txid jadi link explorer',
'href="https://vexascan.com/transaction/txabc123"' in html)
# ————— V11: search server-side ?q= (substring, case-insensitive) —————
resp = client.get('/?q=acct12')
@@ -153,6 +177,15 @@ def main():
isinstance(data['results'][0]['weight'], str))
check('V11 /api/search last_vote hadir',
all(r['last_vote'] for r in data['results']))
check('V29 search total_reward hadir',
all('total_reward' in r for r in data['results']))
check('V29 search total_reward 0 (tanpa transfer)',
all(r['total_reward'] == 0 for r in data['results']))
resp = client.get('/api/search?q=acct001')
data = resp.get_json()
check('V29 search total_reward acct001 = 50.0',
len(data['results']) == 1 and data['results'][0]['total_reward'] == 50.0)
resp = client.get('/api/search?q=zzzz')
check('V11 /api/search kosong', resp.get_json()['count'] == 0)
+12 -7
View File
@@ -150,9 +150,9 @@ def main():
calls['runs'].append((run_date, status))
return 7
def fake_record_payment(run_id, owner, amount, memo, status, created,
def fake_record_payment(run_id, owner, amount, status, created,
txid=None, error=None):
calls['pays'].append((owner, amount, memo, status))
calls['pays'].append((owner, amount, status))
return len(calls['pays'])
def fake_update(pid, status, txid=None, error=None, updated_at=None):
@@ -165,7 +165,13 @@ def main():
db.record_payment = fake_record_payment
db.update_payment_status = fake_update
db.update_run_status = fake_run_update
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('tx' + to)
signed = []
def fake_sign(to, amount, memo):
signed.append(memo)
return FakeSigned('tx' + to)
dist.build_signed_transfer = fake_sign
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
@@ -173,11 +179,10 @@ def main():
check('V21 run tercatat (run_date)', calls['runs'][0][0]
== datetime.now().strftime('%Y-%m-%d'))
check('V21 2 payment pending tercatat',
len([p for p in calls['pays'] if p[3] == 'pending']) == 2)
len([p for p in calls['pays'] if p[2] == 'pending']) == 2)
today = datetime.now().strftime('%Y-%m-%d')
check('V23 memo per run',
all(p[2] == f'DATABISNISID PROFIT SHARE {today}'
for p in calls['pays']))
check('V23 memo on-chain per run',
all(m == f'DATABISNISID PROFIT SHARE {today}' for m in signed))
check('V21 semua sent + txid',
all(u[1] == 'sent' and u[2].startswith('tx') for u in calls['updates']))
check('V24 status run ok + total_sent',