Compare commits
8
Commits
e4fa6bf1dc
...
397e20ec3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
397e20ec3a | ||
|
|
83e294dec8 | ||
|
|
4e02701887 | ||
|
|
f77681793f | ||
|
|
d30dd8b292 | ||
|
|
3ad5eb32d2 | ||
|
|
344de409cd | ||
|
|
a3251eebc4 |
No files matched your search
@@ -51,6 +51,14 @@ SCAN_HOURS=0,8,16
|
|||||||
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
|
# Skan sekali saat container start (tabel voters langsung ada; dashboard ⊥ 500)
|
||||||
SCAN_RUN_ON_START=1
|
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)
|
# Kredensial mariadb stack — wajib konsisten antar-service (mariadb/web/scan)
|
||||||
MARIADB_ROOT_PASSWORD=root
|
MARIADB_ROOT_PASSWORD=root
|
||||||
MARIADB_DATABASE=databisnisid
|
MARIADB_DATABASE=databisnisid
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md
|
# 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
|
## 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.
|
- 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:
|
- 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`
|
`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.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- 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).
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## 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`.
|
- `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.
|
- `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. `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.
|
- `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`; 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.
|
- `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.
|
- `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).
|
||||||
- `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`).
|
- `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).
|
- `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).
|
- `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 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"]
|
||||||
@@ -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
|
- voter basi (revote > `VEX_STALE_DAYS`) atau `last_vote` tak diketahui → dibuang di `normalize` (⊥ disimpan); hanya pemilih segar yang masuk DB
|
||||||
- output/comments: Indonesian
|
- output/comments: Indonesian
|
||||||
- run harian → ganti snapshot (DROP+INSERT tiap skan; ⊥ history/append)
|
- 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`
|
- 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
|
- 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)
|
- 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)
|
- 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)
|
- 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`
|
- 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: 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
|
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_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
|
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
|
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` (secret swarm / env dev), `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` — via `config.py` (`.env`)
|
||||||
|
|
||||||
## §V — Invariants
|
## §V — Invariants
|
||||||
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
|
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
|
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`
|
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
|
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
|
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`)
|
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`)
|
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
|
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)
|
V21: ∀ transfer → dicatat di `distribute_payments` sbg `pending` SEBELUM kirim; status → `sent`|`failed` dr hasil tx; ⊥ hapus baris (append)
|
||||||
V22: retry → hanya baris `failed`; baris `sent` ⊥ dikirim ulang; sebelum resend, verifikasi txid via `GET {DATABISNIS_API}/v2/history/get_transaction` → `executed` → tandai `sent` (⊥ duplikat)
|
V22: retry → hanya baris `failed`; baris `sent` ⊥ dikirim ulang; sebelum resend, verifikasi txid via `GET {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
|
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
|
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)
|
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
|
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
|
## §T — Tasks
|
||||||
id|status|task|cites
|
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
|
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
|
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
|
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
|
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
|
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)|
|
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
|
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
|
## §B — Bug log
|
||||||
id|date|cause|fix
|
id|date|cause|fix
|
||||||
|
|||||||
+58
-15
@@ -19,6 +19,18 @@ from config import (DASH_HOST, DASH_LIQUID_TTL, DASH_PORT, DATABISNIS_API,
|
|||||||
SEARCH_CAP = 500
|
SEARCH_CAP = 500
|
||||||
MAX_Q_LEN = 64
|
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__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,18 +105,35 @@ def _escape_like(q):
|
|||||||
.replace('_', '!_'))
|
.replace('_', '!_'))
|
||||||
|
|
||||||
|
|
||||||
def _filter_sql(q):
|
def _filter_sql(q, tbl=''):
|
||||||
"""V11: klausa WHERE + params untuk filter owner substring."""
|
"""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 = []
|
clauses = []
|
||||||
params = []
|
params = []
|
||||||
if q:
|
if q:
|
||||||
clauses.append("owner LIKE %s ESCAPE '!'")
|
clauses.append(f"{tbl}owner LIKE %s ESCAPE '!'")
|
||||||
params.append(f'%{_escape_like(q)}%')
|
params.append(f'%{_escape_like(q)}%')
|
||||||
if not clauses:
|
if not clauses:
|
||||||
return '', []
|
return '', []
|
||||||
return 'WHERE ' + ' AND '.join(clauses), params
|
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):
|
def _db_rows(page, q):
|
||||||
"""V10/V11: satu halaman, urut staked DESC lalu owner ASC; rank global.
|
"""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.
|
Correlated count selalu menghitung atas SELURUH tabel → rank tetap global.
|
||||||
"""
|
"""
|
||||||
offset = (page - 1) * PAGE_SIZE
|
offset = (page - 1) * PAGE_SIZE
|
||||||
where, params = _filter_sql(q)
|
where, params = _filter_sql(q, 'v.')
|
||||||
|
rw_join, rw_params = _reward_join()
|
||||||
sql = (
|
sql = (
|
||||||
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
|
'SELECT v.owner, v.staked, v.weight, v.scanned_at, '
|
||||||
' (SELECT COUNT(*) FROM voters v2 '
|
' (SELECT COUNT(*) FROM voters v2 '
|
||||||
' WHERE v2.staked > v.staked '
|
' WHERE v2.staked > v.staked '
|
||||||
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
|
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
|
||||||
' v.last_vote '
|
' v.last_vote, '
|
||||||
'FROM voters v ' + where + ' '
|
' COALESCE(rw.total, 0) '
|
||||||
|
'FROM voters v ' + rw_join + ' ' + where + ' '
|
||||||
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s OFFSET %s'
|
'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):
|
def _count(q):
|
||||||
@@ -154,6 +185,7 @@ def index():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
abort(400)
|
abort(400)
|
||||||
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
q = request.args.get('q', '').strip()[:MAX_Q_LEN]
|
||||||
|
_ensure_dist_schema()
|
||||||
total_voters, total_staked, top_staked, scanned = _stats()
|
total_voters, total_staked, top_staked, scanned = _stats()
|
||||||
liquid_vex = _get_liquid_vex()
|
liquid_vex = _get_liquid_vex()
|
||||||
filtered_count = _count(q)
|
filtered_count = _count(q)
|
||||||
@@ -179,19 +211,25 @@ def index():
|
|||||||
|
|
||||||
@app.get('/api/search')
|
@app.get('/api/search')
|
||||||
def 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]
|
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 = (
|
sql = (
|
||||||
'SELECT v.owner, v.staked, v.weight, '
|
'SELECT v.owner, v.staked, v.weight, '
|
||||||
' (SELECT COUNT(*) FROM voters v2 '
|
' (SELECT COUNT(*) FROM voters v2 '
|
||||||
' WHERE v2.staked > v.staked '
|
' WHERE v2.staked > v.staked '
|
||||||
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
|
' OR (v2.staked = v.staked AND v2.owner <= v.owner)) AS rn, '
|
||||||
' v.last_vote '
|
' v.last_vote, '
|
||||||
'FROM voters v ' + where + ' '
|
' COALESCE(rw.total, 0) '
|
||||||
|
'FROM voters v ' + rw_join + ' ' + where + ' '
|
||||||
'ORDER BY v.staked DESC, v.owner ASC LIMIT %s'
|
'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({
|
return jsonify({
|
||||||
'query': q,
|
'query': q,
|
||||||
'count': len(rows),
|
'count': len(rows),
|
||||||
@@ -199,7 +237,7 @@ def api_search():
|
|||||||
'results': [
|
'results': [
|
||||||
{
|
{
|
||||||
'owner': r[0], 'staked': r[1], 'weight': r[2],
|
'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
|
for r in rows
|
||||||
],
|
],
|
||||||
@@ -208,10 +246,15 @@ def api_search():
|
|||||||
|
|
||||||
@app.get('/history')
|
@app.get('/history')
|
||||||
def 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()
|
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,
|
return render_template('history.html', runs=runs, payments=payments,
|
||||||
|
latest_date=runs[0][1] if runs else None,
|
||||||
bp=TARGET_BP)
|
bp=TARGET_BP)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ _PAY_COLS = (
|
|||||||
'run_id INTEGER NOT NULL, '
|
'run_id INTEGER NOT NULL, '
|
||||||
'owner VARCHAR(13) NOT NULL, '
|
'owner VARCHAR(13) NOT NULL, '
|
||||||
'amount DOUBLE NOT NULL, '
|
'amount DOUBLE NOT NULL, '
|
||||||
'memo TEXT NOT NULL, '
|
|
||||||
'txid TEXT, '
|
'txid TEXT, '
|
||||||
'status TEXT NOT NULL, '
|
'status TEXT NOT NULL, '
|
||||||
'error TEXT, '
|
'error TEXT, '
|
||||||
@@ -141,11 +140,32 @@ def ensure_distribute_schema():
|
|||||||
else:
|
else:
|
||||||
cur.execute(_RUN_DDL_MYSQL)
|
cur.execute(_RUN_DDL_MYSQL)
|
||||||
cur.execute(_PAY_DDL_MYSQL)
|
cur.execute(_PAY_DDL_MYSQL)
|
||||||
|
_drop_pay_memo(cur)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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,
|
def record_run(run_date, balance_start, total_voters, total_staked,
|
||||||
total_sent, status, created_at):
|
total_sent, status, created_at):
|
||||||
"""Insert baris `distribute_runs`, kembalikan run_id."""
|
"""Insert baris `distribute_runs`, kembalikan run_id."""
|
||||||
@@ -165,7 +185,7 @@ def record_run(run_date, balance_start, total_voters, total_staked,
|
|||||||
conn.close()
|
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):
|
txid=None, error=None):
|
||||||
"""Insert baris `distribute_payments` → payment_id."""
|
"""Insert baris `distribute_payments` → payment_id."""
|
||||||
ensure_distribute_schema()
|
ensure_distribute_schema()
|
||||||
@@ -174,9 +194,9 @@ def record_payment(run_id, owner, amount, memo, status, created_at,
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(_translate(
|
cur.execute(_translate(
|
||||||
'INSERT INTO distribute_payments '
|
'INSERT INTO distribute_payments '
|
||||||
'(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, %s)'),
|
' updated_at) VALUES (%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,
|
||||||
created_at))
|
created_at))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
@@ -224,11 +244,14 @@ def list_runs():
|
|||||||
'FROM distribute_runs ORDER BY run_id DESC')
|
'FROM distribute_runs ORDER BY run_id DESC')
|
||||||
|
|
||||||
|
|
||||||
def list_payments():
|
def list_payments(run_id=None):
|
||||||
"""Transfer per-pemilih, terbaru dulu (gabung run untuk tanggal)."""
|
"""Transfer per-pemilih, terbaru dulu; opsional filter satu run."""
|
||||||
ensure_distribute_schema()
|
ensure_distribute_schema()
|
||||||
return query(
|
sql = ('SELECT payment_id, run_id, owner, amount, txid, status, error, '
|
||||||
'SELECT p.payment_id, p.run_id, p.owner, p.amount, p.memo, p.txid, '
|
' created_at, updated_at FROM distribute_payments')
|
||||||
' p.status, p.error, p.created_at, p.updated_at, r.run_date '
|
params = ()
|
||||||
'FROM distribute_payments p JOIN distribute_runs r '
|
if run_id is not None:
|
||||||
' ON p.run_id = r.run_id ORDER BY p.payment_id DESC')
|
sql += ' WHERE run_id = %s'
|
||||||
|
params = (run_id,)
|
||||||
|
sql += ' ORDER BY payment_id DESC'
|
||||||
|
return query(sql, params)
|
||||||
+1
-1
@@ -160,7 +160,7 @@ def run_distribution(dry_run=False):
|
|||||||
0.0, 'ok', created_at)
|
0.0, 'ok', created_at)
|
||||||
pending = []
|
pending = []
|
||||||
for owner, share in shares:
|
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)
|
created_at)
|
||||||
pending.append((pid, owner, share))
|
pending.append((pid, owner, share))
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,7 +1,8 @@
|
|||||||
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
|
# Stack Docker Swarm — deploy via `docker stack deploy -c docker-compose.yml databisnisid`
|
||||||
#
|
#
|
||||||
# 3 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
|
# 4 service: mariadb (storage, internal), web (dashboard gunicorn, publik :5000),
|
||||||
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ).
|
# scan (skan get_voters.py pada SCAN_HOURS, waktu lokal TZ), dan
|
||||||
|
# distribute (pembayaran harian distribute.py pada DISTRIBUTE_HOUR).
|
||||||
#
|
#
|
||||||
# Catatan swarm:
|
# Catatan swarm:
|
||||||
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
|
# - `build:` hanya untuk `docker compose build`; `docker stack deploy` memakai `image:`
|
||||||
@@ -84,6 +85,29 @@ services:
|
|||||||
restart_policy:
|
restart_policy:
|
||||||
condition: any
|
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:
|
networks:
|
||||||
appnet:
|
appnet:
|
||||||
driver: overlay
|
driver: overlay
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
'<span class="rank">' + r.rank + '</span>' +
|
'<span class="rank">' + r.rank + '</span>' +
|
||||||
'<span class="owner">' + escapeHtml(r.owner) + '</span>' +
|
'<span class="owner">' + escapeHtml(r.owner) + '</span>' +
|
||||||
'<span class="stake" data-label="STAKE (VEX)">' + vex(r.staked) + '</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>' +
|
'<span class="vote-date" data-label="VOTE TERAKHIR">' + shortdate(r.last_vote) + '</span>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
+33
-8
@@ -181,7 +181,7 @@ button.pill {
|
|||||||
.list-header,
|
.list-header,
|
||||||
.list-row {
|
.list-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 72px 1fr 200px 1fr;
|
grid-template-columns: 72px repeat(3, 1fr);
|
||||||
gap: var(--sp-lg);
|
gap: var(--sp-lg);
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
padding: var(--sp-lg) var(--sp-sm);
|
padding: var(--sp-lg) var(--sp-sm);
|
||||||
@@ -193,7 +193,11 @@ button.pill {
|
|||||||
}
|
}
|
||||||
.pay-list .list-header,
|
.pay-list .list-header,
|
||||||
.pay-list .list-row {
|
.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 {
|
.list-header {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -218,7 +222,8 @@ button.pill {
|
|||||||
color: var(--on-dark);
|
color: var(--on-dark);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.stake {
|
.stake,
|
||||||
|
.reward {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
@@ -226,13 +231,24 @@ button.pill {
|
|||||||
color: var(--on-dark);
|
color: var(--on-dark);
|
||||||
font-variant-numeric: tabular-nums;
|
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 {
|
.txid {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
.txid a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.txid a:hover { color: var(--on-dark); }
|
||||||
.empty {
|
.empty {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -293,6 +309,10 @@ button.pill {
|
|||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.list-header,
|
.list-header,
|
||||||
.list-row { grid-template-columns: 56px 1fr 160px 1fr; gap: var(--sp-md); }
|
.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); }
|
.spec-list, .stats, .search, .search-result { margin-left: var(--sp-md); margin-right: var(--sp-md); }
|
||||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
.spec-cell:nth-child(3) { border-left: none; }
|
.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 .owner { grid-column: 2; font-size: 18px; }
|
||||||
.list-row .stake,
|
.list-row .stake,
|
||||||
.list-row .vote-date,
|
.list-row .vote-date,
|
||||||
.list-row .txid { grid-column: 2; }
|
.list-row .txid,
|
||||||
.list-row .stake { text-align: left; }
|
.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 .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 .stake::before,
|
||||||
.list-row .vote-date::before,
|
.list-row .vote-date::before,
|
||||||
.list-row .txid::before {
|
.list-row .txid::before,
|
||||||
|
.list-row .reward::before {
|
||||||
content: attr(data-label);
|
content: attr(data-label);
|
||||||
display: block;
|
display: block;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-nav">
|
<header class="top-nav">
|
||||||
@@ -59,26 +59,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="spec-list pay-list">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<span>AKUN</span>
|
<span>AKUN</span>
|
||||||
<span>TANGGAL</span>
|
|
||||||
<span>JUMLAH</span>
|
<span>JUMLAH</span>
|
||||||
<span>STATUS</span>
|
<span>STATUS</span>
|
||||||
<span>MEMO</span>
|
|
||||||
<span>TXID</span>
|
<span>TXID</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-rows">
|
<div class="list-rows">
|
||||||
{% for pay in payments %}
|
{% for pay in payments %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
<span class="owner">{{ pay[2] }}</span>
|
<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="stake" data-label="JUMLAH">{{ pay[3]|vex }}</span>
|
||||||
<span class="vote-date" data-label="STATUS">{{ pay[6]|upper }}</span>
|
<span class="vote-date" data-label="STATUS">{{ pay[5]|upper }}</span>
|
||||||
<span class="txid memo" data-label="MEMO">{{ pay[4] }}</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>
|
||||||
<span class="owner txid" data-label="TXID">{{ pay[5] or '—' }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty">BELUM ADA TRANSFER</div>
|
<div class="empty">BELUM ADA TRANSFER</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="top-nav">
|
<header class="top-nav">
|
||||||
@@ -55,11 +55,12 @@
|
|||||||
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
|
{% if q %}HASIL · {{ filtered_count }} AKUN UNTUK "{{ q }}"{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="spec-list" id="spec-list">
|
<section class="spec-list voter-list" id="spec-list">
|
||||||
<div class="list-header">
|
<div class="list-header">
|
||||||
<span>RANK</span>
|
<span>RANK</span>
|
||||||
<span>AKUN</span>
|
<span>AKUN</span>
|
||||||
<span>STAKE (VEX)</span>
|
<span>STAKE (VEX)</span>
|
||||||
|
<span>TOTAL REWARD (VEX)</span>
|
||||||
<span>VOTE TERAKHIR</span>
|
<span>VOTE TERAKHIR</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-rows" id="rows">
|
<div class="list-rows" id="rows">
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
<span class="rank">{{ row[4] }}</span>
|
<span class="rank">{{ row[4] }}</span>
|
||||||
<span class="owner">{{ row[0] }}</span>
|
<span class="owner">{{ row[0] }}</span>
|
||||||
<span class="stake" data-label="STAKE (VEX)">{{ row[1]|vex }}</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>
|
<span class="vote-date" data-label="VOTE TERAKHIR">{{ row[5]|shortdate }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
+36
-3
@@ -55,12 +55,17 @@ def main():
|
|||||||
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
|
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
|
||||||
|
|
||||||
# ————— V27: seed riwayat distribusi utk uji /history —————
|
# ————— 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')
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
||||||
dbm.ensure_distribute_schema()
|
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',
|
rid = dbm.record_run(today, 52186.0417, 1, 1200.0, 100.0, 'ok',
|
||||||
'2026-08-05T10:00:00')
|
'2026-08-05T10:00:00')
|
||||||
dbm.record_payment(rid, 'acct001', 50.0,
|
dbm.record_payment(rid, 'acct001', 50.0, 'sent',
|
||||||
f'DATABISNISID PROFIT SHARE {today}', 'sent',
|
|
||||||
'2026-08-05T10:00:01', txid='txabc123')
|
'2026-08-05T10:00:01', txid='txabc123')
|
||||||
|
|
||||||
client = dashboard.app.test_client()
|
client = dashboard.app.test_client()
|
||||||
@@ -88,6 +93,19 @@ def main():
|
|||||||
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
|
check('V10 urut non-naik', all(staked_vals[i] >= staked_vals[i + 1]
|
||||||
for i in range(len(staked_vals) - 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)
|
# halaman melebihi total → dibatasi (tidak error)
|
||||||
resp = client.get('/?page=99')
|
resp = client.get('/?page=99')
|
||||||
check('V10 halaman ekstra → 200', resp.status_code == 200)
|
check('V10 halaman ekstra → 200', resp.status_code == 200)
|
||||||
@@ -104,10 +122,16 @@ def main():
|
|||||||
html = resp.get_data(as_text=True)
|
html = resp.get_data(as_text=True)
|
||||||
check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html)
|
check('V27 judul RIWAYAT DISTRIBUSI', 'RIWAYAT DISTRIBUSI' in html)
|
||||||
check('V27 run date tampil', today 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 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 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 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) —————
|
# ————— V11: search server-side ?q= (substring, case-insensitive) —————
|
||||||
resp = client.get('/?q=acct12')
|
resp = client.get('/?q=acct12')
|
||||||
@@ -153,6 +177,15 @@ def main():
|
|||||||
isinstance(data['results'][0]['weight'], str))
|
isinstance(data['results'][0]['weight'], str))
|
||||||
check('V11 /api/search last_vote hadir',
|
check('V11 /api/search last_vote hadir',
|
||||||
all(r['last_vote'] for r in data['results']))
|
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')
|
resp = client.get('/api/search?q=zzzz')
|
||||||
check('V11 /api/search kosong', resp.get_json()['count'] == 0)
|
check('V11 /api/search kosong', resp.get_json()['count'] == 0)
|
||||||
|
|||||||
+12
-7
@@ -150,9 +150,9 @@ def main():
|
|||||||
calls['runs'].append((run_date, status))
|
calls['runs'].append((run_date, status))
|
||||||
return 7
|
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):
|
txid=None, error=None):
|
||||||
calls['pays'].append((owner, amount, memo, status))
|
calls['pays'].append((owner, amount, status))
|
||||||
return len(calls['pays'])
|
return len(calls['pays'])
|
||||||
|
|
||||||
def fake_update(pid, status, txid=None, error=None, updated_at=None):
|
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.record_payment = fake_record_payment
|
||||||
db.update_payment_status = fake_update
|
db.update_payment_status = fake_update
|
||||||
db.update_run_status = fake_run_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()
|
buf = io.StringIO()
|
||||||
with redirect_stdout(buf):
|
with redirect_stdout(buf):
|
||||||
rc = dist.run_distribution(dry_run=False)
|
rc = dist.run_distribution(dry_run=False)
|
||||||
@@ -173,11 +179,10 @@ def main():
|
|||||||
check('V21 run tercatat (run_date)', calls['runs'][0][0]
|
check('V21 run tercatat (run_date)', calls['runs'][0][0]
|
||||||
== datetime.now().strftime('%Y-%m-%d'))
|
== datetime.now().strftime('%Y-%m-%d'))
|
||||||
check('V21 2 payment pending tercatat',
|
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')
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
check('V23 memo per run',
|
check('V23 memo on-chain per run',
|
||||||
all(p[2] == f'DATABISNISID PROFIT SHARE {today}'
|
all(m == f'DATABISNISID PROFIT SHARE {today}' for m in signed))
|
||||||
for p in calls['pays']))
|
|
||||||
check('V21 semua sent + txid',
|
check('V21 semua sent + txid',
|
||||||
all(u[1] == 'sent' and u[2].startswith('tx') for u in calls['updates']))
|
all(u[1] == 'sent' and u[2].startswith('tx') for u in calls['updates']))
|
||||||
check('V24 status run ok + total_sent',
|
check('V24 status run ok + total_sent',
|
||||||
|
|||||||
Reference in new issue
Block a user