T19-T28: distribusi profit harian (distribute.py, /history, tests)

This commit is contained in:
proitlab committed 2026-08-05 16:07:36 +07:00
1 parent 656f26085f
commit 8ab8001b34
14 files changed
+789 -11

No files matched your search

+3
View File
@@ -4,6 +4,9 @@ scan.log
__pycache__/
*.pyc
# opencode session state
.oc/
# local env config (secrets/overrides — jangan di-commit)
.env
+12 -8
View File
@@ -1,19 +1,20 @@
# 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.
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.
## Run
- Node reference script: `node get_voters.js` (no deps, Node 18+).
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
- Python tool (production): `./venv/bin/python get_voters.py`. venv is Python 3.12, deps `requests` + `flask` + `python-dotenv` + `gunicorn` + `pymysql` (mysql backend only) + `pyntelope` (distribute signing) (`requirements.txt`). Install with `./venv/bin/pip install -r requirements.txt`.
- Daily payout (production): `./venv/bin/python distribute.py` — reads the current voters snapshot, fetches the BP account's liquid VEX, splits it pro-rata by stake (floor 4-dec, dust stays in the account), and pushes one signed `vex.token::transfer` per voter with memo `DATABISNISID PROFIT SHARE YYYY-MM-DD`. Preview the plan without signing/writing: `./venv/bin/python distribute.py --dry-run`. Requires `VEX_BP_PRIVATE_KEY` (BP active key) in env/`.env`; without it `--dry-run` still works, a real run raises. Failed rows are recorded `failed` and rejoined by the next day's run — no manual cleanup needed.
- 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`.
- 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`. 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 test_get_voters.py test_dashboard.py test_mariadb.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py` and `./venv/bin/python test_dashboard.py` must both exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`. 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`.
- 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,17 +27,20 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- 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.
- 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.
- 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
- `SPEC.md` — spec (goal/constraints/interfaces/invariants/tasks/bug log), in caveman encoding. Build/backprop flow through it.
- `get_voters.py` — production fetcher: scan → filter (stake + freshness, basi dibuang) → `db.replace_snapshot` (each run replaces the table = daily snapshot, with `scanned_at` + derived `last_vote`; never appends history).
- `db.py` — storage abstraction (sqlite default | mysql via PyMySQL); `connect/query/queryone/replace_snapshot`; `%s` → `?` for sqlite; `query` returns list.
- `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.
- `dashboard.py` + `templates/index.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.
- `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.
- `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`; 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.
- `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`).
-2
View File
@@ -1,2 +0,0 @@
# databisnisid
+43
View File
@@ -3,6 +3,7 @@
## §G — Goal
Scan Vexanium voters table → persist `databisnisid` voters + weight in SQLite.
Re-runnable scheduled. Python 3.12, venv `./venv`.
Profit-share: tiap hari 10:00 lokal → kirim saldo liquid VEX akun BP ke pemilih segar (pro-rata stake), catat tiap transfer (riwayat per-pemilih) + view riwayat dashboard.
## §C — Constraints
- venv `./venv` exists (py3.12, only pip) — install deps there, ⊥ global
@@ -21,6 +22,19 @@ Re-runnable scheduled. Python 3.12, venv `./venv`.
- 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`
- 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
- 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`
- riwayat: tiap transfer dicatat per-pemilih (`distribute_payments`: pending→sent/failed) + ringkasan per run (`distribute_runs`); append ⊥ hapus
- retry dalam hari: ulang hanya baris `failed`; ⊥ kirim ulang `sent`; sebelum resend → verifikasi txid on-chain (Hyperion `get_transaction` → `executed`)
- ⊥ freezing/carryover: tiap hari hitung ulang segar dari snapshot tersimpan; kegagalan sisa → ikut run besok
- pemilih kosong | saldo < 1 unit shareable → no-op senyap (⊥ catat)
- dashboard read-only + view riwayat `/history` (runs + per-voter log)
- CPU/NET akun BP besar (limit 41.5M µs, NET 3.26M words) → ratusan transfer/hari sepele; Vexanium tanpa fee
## §I — Interfaces
api: POST `https://v2.vexascan.com:2096/v1/chain/get_table_rows`
@@ -32,6 +46,16 @@ web: GET `/` (Flask, disajikan gunicorn di produksi) → HTML spec-list, paged 5
web: GET `/` + `?q=<substring>` → filter owner (server-side, no-JS fallback); pager bawa `q`
web: GET `/api/search?q=` → JSON `{query,count,cap,results:[{owner,staked,weight,rank,last_vote}]}`, rank global, cap 500
env: `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `SCAN_HOURS`, `SCAN_RUN_ON_START`, `TZ` — via `config.py` (`.env`)
api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_balance` → body {code:`vex.token`, account, symbol:`VEX`} → `["X.XXXX VEX"]`
api: POST `https://v2.vexascan.com:2096/v1/chain/get_currency_stats` → {supply, max_supply, issuer} (cek precision)
api: POST `https://v2.vexascan.com:2096/v1/chain/push_transaction` (signed via pyntelope) → txid
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
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
env: `VEX_BP_PRIVATE_KEY` (secret swarm / env dev), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS` — via `config.py` (`.env`)
## §V — Invariants
V1: ∀ fetch → retry ≥3 on timeout/http err (like JS fetchWithRetry)
@@ -51,6 +75,15 @@ V15: web list = kolom RANK/AKUN/STAKE (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA,
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)
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
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
## §T — Tasks
id|status|task|cites
@@ -72,6 +105,16 @@ T15|x|hapus bobot suara + UI basi di web (kolom/stats/penanda/filter `?stale=1`)
T16|x|saldo liquid akun di dashboard: API databisnis + cache TTL + cooldown; sel SALDO LIQUID|V16,I.web,I.env
T17|x|backend shared db.py (sqlite|mysql, `%s`→`?`, ESCAPE '!'), config VEX_DB_*, refactor fetcher+dashboard, test_mariadb.py opt-in, runbook docker-compose|V17,I.db,I.env
T18|x|stack swarm: Dockerfile web + Dockerfile.scan, scan_loop.py (SCAN_HOURS 0,8,16 lokal, skan-awal), docker-compose.yml stack (mariadb+web+scan), docker-compose.dev.yml utk uji lokal|V18,I.web,I.env
T19|x|db.py: tabel `distribute_runs` + `distribute_payments` (sqlite/mysql, `%s`, append) + helper (record_run, record_payment, update_payment_status, list_payments)|V21,V22,I.db
T20|x|config.py: env `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS` + konstanta chain (`vex.token`, chain_id); requirements.txt += `pyntelope`|V19,I.env
T21|x|distribute.py: fetch_balance (chain get_currency_balance) + compute_shares (floor 4-des, sisa di akun)|V20,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
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
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)|
## §B — Bug log
id|date|cause|fix
+13
View File
@@ -50,3 +50,16 @@ DATABISNIS_API = os.getenv('DATABISNIS_API', 'https://api.databisnis.id')
# Dashboard: TTL cache (detik) untuk saldo liquid — gagal fetch juga di-cooldown
DASH_LIQUID_TTL = int(os.getenv('DASH_LIQUID_TTL', '60'))
# ————————————————— Distribusi profit-share (V19..V27) —————————————————
# Jam (lokal, TZ) run harian distribusi; key aktif BP via swarm secret/env.
# ⊥ env-able: konstanta chain Vexanium (token + chain_id untuk signing).
BP_PRIVATE_KEY = os.getenv('VEX_BP_PRIVATE_KEY', '')
DISTRIBUTE_HOUR = int(os.getenv('DISTRIBUTE_HOUR', '10'))
DISTRIBUTE_MAX_ATTEMPTS = int(os.getenv('DISTRIBUTE_MAX_ATTEMPTS', '3'))
# Kontrak token VEX = vex.token (⊥ eosio.token — tak ada di chain)
TOKEN_CONTRACT = 'vex.token'
VEX_SYMBOL = 'VEX'
VEX_PRECISION = 4
VEX_CHAIN_ID = 'f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f'
+9
View File
@@ -196,5 +196,14 @@ def api_search():
})
@app.get('/history')
def history():
"""V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih."""
runs = db.list_runs()
payments = db.list_payments()
return render_template('history.html', runs=runs, payments=payments,
bp=TARGET_BP)
if __name__ == '__main__':
app.run(host=DASH_HOST, port=DASH_PORT, debug=False)
+137
View File
@@ -95,3 +95,140 @@ def replace_snapshot(rows, scanned_at):
conn.commit()
finally:
conn.close()
# ——————————————————————————————————————————————————————————————————————
# Riwayat distribusi profit-share (V19..V27). Tabel append — tiap transfer
# & tiap run tercatat permanen (⊥ hapus). Skema sama untuk kedua backend,
# beda hanya klausa primary key autoincrement.
# ——————————————————————————————————————————————————————————————————————
_RUN_COLS = (
'run_date TEXT NOT NULL, '
'balance_start DOUBLE NOT NULL, '
'total_voters INTEGER NOT NULL, '
'total_staked DOUBLE NOT NULL, '
'total_sent DOUBLE NOT NULL, '
'status TEXT NOT NULL, '
'created_at TEXT NOT NULL'
)
_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, '
'created_at TEXT NOT NULL, '
'updated_at TEXT NOT NULL'
)
_RUN_DDL_SQLITE = f'CREATE TABLE IF NOT EXISTS distribute_runs (run_id INTEGER PRIMARY KEY AUTOINCREMENT, {_RUN_COLS})'
_PAY_DDL_SQLITE = f'CREATE TABLE IF NOT EXISTS distribute_payments (payment_id INTEGER PRIMARY KEY AUTOINCREMENT, {_PAY_COLS})'
_RUN_DDL_MYSQL = f'CREATE TABLE IF NOT EXISTS distribute_runs (run_id INT AUTO_INCREMENT PRIMARY KEY, {_RUN_COLS})'
_PAY_DDL_MYSQL = f'CREATE TABLE IF NOT EXISTS distribute_payments (payment_id INT AUTO_INCREMENT PRIMARY KEY, {_PAY_COLS})'
def ensure_distribute_schema():
"""Buat tabel riwayat distribusi bila belum ada (idempoten, kedua backend)."""
conn = connect()
try:
cur = conn.cursor()
if DB_BACKEND == 'sqlite':
cur.execute(_RUN_DDL_SQLITE)
cur.execute(_PAY_DDL_SQLITE)
else:
cur.execute(_RUN_DDL_MYSQL)
cur.execute(_PAY_DDL_MYSQL)
conn.commit()
finally:
conn.close()
def record_run(run_date, balance_start, total_voters, total_staked,
total_sent, status, created_at):
"""Insert baris `distribute_runs`, kembalikan run_id."""
ensure_distribute_schema()
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'INSERT INTO distribute_runs '
'(run_date, balance_start, total_voters, total_staked, total_sent, '
' status, created_at) VALUES (%s, %s, %s, %s, %s, %s, %s)'),
(run_date, balance_start, total_voters, total_staked, total_sent,
status, created_at))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def record_payment(run_id, owner, amount, memo, status, created_at,
txid=None, error=None):
"""Insert baris `distribute_payments` → payment_id."""
ensure_distribute_schema()
conn = connect()
try:
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,
created_at))
conn.commit()
return cur.lastrowid
finally:
conn.close()
def update_payment_status(payment_id, status, txid=None, error=None, updated_at=None):
"""Perbarui status satu transfer (V21): pending → sent|failed."""
from datetime import datetime
if updated_at is None:
updated_at = datetime.now().isoformat(timespec='seconds')
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'UPDATE distribute_payments SET status = %s, txid = %s, '
'error = %s, updated_at = %s WHERE payment_id = %s'),
(status, txid, error, updated_at, payment_id))
conn.commit()
finally:
conn.close()
def update_run_status(run_id, status, total_sent):
"""Tutup run: status ok|partial + total terkirim nyata."""
conn = connect()
try:
cur = conn.cursor()
cur.execute(_translate(
'UPDATE distribute_runs SET status = %s, total_sent = %s '
'WHERE run_id = %s'),
(status, total_sent, run_id))
conn.commit()
finally:
conn.close()
def list_runs():
"""Ringkasan run distribusi, terbaru dulu."""
ensure_distribute_schema()
return query(
'SELECT run_id, run_date, balance_start, total_voters, total_staked, '
' total_sent, status, created_at '
'FROM distribute_runs ORDER BY run_id DESC')
def list_payments():
"""Transfer per-pemilih, terbaru dulu (gabung run untuk tanggal)."""
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')
+220
View File
@@ -0,0 +1,220 @@
"""Distribusi profit-share harian: kirim saldo liquid VEX akun BP ke pemilih.
Setiap hari jam `DISTRIBUTE_HOUR` (lokal, TZ), `distribute.py` membaca pemilih
segar tersimpan (snapshot terkini dari scan 08:00), menghitung porsi
proporsional stake (V20), lalu mengirim transfer `vex.token::transfer`
bertanda tangan (pyntelope, key aktif BP dari secret/env
`VEX_BP_PRIVATE_KEY`). Setiap transfer dicatat di `distribute_payments`
sebagai `pending` SEBELUM kirim (V21). Retry hanya baris `failed`; sebelum
resend, txid diverifikasi di Hyperion agar transfer yang sempat mendarat tak
terkirim ganda (V22). `--dry-run` menampilkan rencana tanpa tanda tangan
(V26). Tidak ada freezing/carryover (V24): tiap run menghitung ulang segar.
"""
import sys
import time
from datetime import datetime
from decimal import Decimal, ROUND_FLOOR, InvalidOperation
import requests
import db
from config import (API_NODE, BP_PRIVATE_KEY, DATABISNIS_API,
DISTRIBUTE_MAX_ATTEMPTS, TARGET_BP, TOKEN_CONTRACT,
VEX_SYMBOL)
VEX_PREC = Decimal('0.0001')
def _post_json(url, payload, max_retries=3, timeout=30):
"""POST JSON dengan retry (node publik flaky) — pola `get_voters`."""
for attempt in range(max_retries):
try:
resp = requests.post(url, json=payload, timeout=timeout)
resp.raise_for_status()
return resp.json()
except requests.RequestException:
if attempt == max_retries - 1:
raise
print(f'[Peringatan] Gagal menghubungi node, mencoba lagi... '
f'({attempt + 1}/{max_retries})', flush=True)
time.sleep(2)
def fetch_balance():
"""V20: saldo liquid VEX akun `TARGET_BP` dari chain get_currency_balance.
Kembalikan Decimal (`0` bila kosong). Gagal total → None.
"""
data = _post_json(f'{API_NODE}/v1/chain/get_currency_balance',
{'code': TOKEN_CONTRACT, 'account': TARGET_BP,
'symbol': VEX_SYMBOL})
raw = (data or [''])[0] or ''
try:
return Decimal(raw.split()[0])
except (IndexError, ValueError, InvalidOperation):
return Decimal('0')
def compute_shares(balance, voters):
"""V20: porsi per pemilih = floor(balance × stake ÷ total_stake), 4 desimal.
`voters` = iterable (owner, staked). Σshare ≤ balance; sisa (dust) tetap
di akun (⊥ drain penuh). Porsi nol tidak dikembalikan.
"""
total = sum((Decimal(str(s)) for _, s in voters), Decimal('0'))
if total <= 0:
return []
shares = []
for owner, staked in voters:
share = (balance * Decimal(str(staked)) / total).quantize(
VEX_PREC, rounding=ROUND_FLOOR)
if share > 0:
shares.append((owner, share))
return shares
def verify_txid(txid):
"""V22: cek tx benar-benar mendarat via Hyperion `get_transaction`.
→ True bila `executed`, False bila tak ditemukan/diproses, None bila
Hyperion tak terjangkau (⊥ kirim ulang bila tak bisa dipastikan).
"""
try:
resp = requests.get(f'{DATABISNIS_API}/v2/history/get_transaction',
params={'id': txid}, timeout=10)
resp.raise_for_status()
return bool(resp.json().get('executed'))
except requests.RequestException:
return None
def build_signed_transfer(to, amount, memo):
"""Bangun + tanda tangani tx `vex.token::transfer` via pyntelope.
Kembalikan objek SignedTransaction (⊥ broadcast). `amount` = Decimal.
Mengambil ABI + TAPOS dari node saat `link()` (jaringan, sesuai pola
retry di tingkat panggil).
"""
from pyntelope import Action, Authorization, Data, Net, Transaction, types
net = Net(host=API_NODE)
trx = Transaction(actions=[
Action(
account=TOKEN_CONTRACT,
name='transfer',
authorization=[Authorization(actor=TARGET_BP, permission='active')],
data=[
Data(name='from', value=types.Name(TARGET_BP)),
Data(name='to', value=types.Name(to)),
Data(name='quantity',
value=types.Asset(f'{amount:.4f} {VEX_SYMBOL}')),
Data(name='memo', value=types.String(memo)),
],
)
])
linked = trx.link(net=net)
return linked.sign(key=BP_PRIVATE_KEY)
def run_distribution(dry_run=False):
"""V21..V26: satu run distribusi harian.
Urutan: baca pemilih tersimpan → saldo → porsi → catat run + payment →
kirim tiap transfer (retry dalam hari, guard anti-duplikat) → tutup run.
Dry-run berhenti sebelum menulis DB / tanda tangan.
"""
voters = db.query('SELECT owner, staked FROM voters')
if not voters:
print('Tidak ada pemilih segar tersimpan — no-op.', flush=True)
return 0
balance = fetch_balance()
if balance is None or balance < VEX_PREC:
print('Saldo liquid kosong / di bawah 0.0001 VEX — no-op.', flush=True)
return 0
shares = compute_shares(balance, voters)
if not shares:
print('Tak ada porsi positif — no-op.', flush=True)
return 0
run_date = datetime.now().strftime('%Y-%m-%d')
memo = f'DATABISNISID PROFIT SHARE {run_date}'
if dry_run:
print('=== RENCANA DISTRIBUSI (dry-run, ⊥ tanda tangan) ===')
print(f'Tanggal: {run_date} | Saldo: {balance} VEX | Memo: {memo}')
for owner, share in shares:
print(f'{owner}: {share} VEX')
print(f'TOTAL: {sum(share for _, share in shares)} VEX '
f'({len(shares)} pemilih)')
return 0
if not BP_PRIVATE_KEY:
raise RuntimeError(
'VEX_BP_PRIVATE_KEY kosong — set secret dulu sebelum run nyata '
'(⊥ distribusi tanpa key)')
created_at = datetime.now().isoformat(timespec='seconds')
total_stake = sum(float(s) for _, s in voters)
run_id = db.record_run(run_date, float(balance), len(voters), total_stake,
0.0, 'ok', created_at)
pending = []
for owner, share in shares:
pid = db.record_payment(run_id, owner, float(share), memo, 'pending',
created_at)
pending.append((pid, owner, share))
print(f'Mulai distribusi: {len(pending)} transfer, memo "{memo}"',
flush=True)
failed = []
sent_amount = 0.0
for pid, owner, share in pending:
txid = None
for attempt in range(1, DISTRIBUTE_MAX_ATTEMPTS + 1):
try:
signed = build_signed_transfer(owner, share, memo)
txid = signed.id() # deterministik, sebelum broadcast
signed.send()
db.update_payment_status(pid, 'sent', txid=txid)
sent_amount += float(share)
print(f'[OK] {owner}: {share} VEX ({txid[:16]}...)',
flush=True)
break
except Exception as exc:
# Timeout setelah broadcast → tx mungkin mendarat. Verifikasi
# sebelum resend agar ⊥ ganda (V22). Bila Hyperion tak bisa
# dipastikan → tahan (tandai failed, jangan resend).
if txid is not None:
landed = verify_txid(txid)
if landed is True:
db.update_payment_status(pid, 'sent', txid=txid)
sent_amount += float(share)
print(f'[OK] {owner}: {share} VEX '
f'(terverifikasi, {txid[:16]}...)', flush=True)
break
if landed is None:
print(f'[Peringatan] Verifikasi txid tak tersedia '
f'untuk {owner} — ditahan (⊥ resend)',
flush=True)
if attempt == DISTRIBUTE_MAX_ATTEMPTS:
db.update_payment_status(pid, 'failed', txid=txid,
error=str(exc))
failed.append((owner, exc))
print(f'[GAGAL] {owner}: {share} VEX — {exc}', flush=True)
break
time.sleep(2)
status = 'ok' if not failed else 'partial'
db.update_run_status(run_id, status, round(sent_amount, 4))
print(f'SELESAI: {len(pending) - len(failed)}/{len(pending)} terkirim '
f'(status run: {status})', flush=True)
return 0
def main():
dry_run = '--dry-run' in sys.argv
return run_distribution(dry_run=dry_run)
if __name__ == '__main__':
sys.exit(main())
+1
View File
@@ -3,3 +3,4 @@ flask>=3.0
python-dotenv>=1.0
gunicorn>=22.0
PyMySQL>=1.1
pyntelope>=0.1.2
+17
View File
@@ -61,6 +61,17 @@ body {
text-transform: uppercase;
color: var(--on-dark);
}
.nav-link {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--on-dark);
text-decoration: none;
border-bottom: 1px solid var(--hairline);
padding-bottom: 2px;
}
.nav-link:hover { color: var(--accent); }
.wordmark {
font-family: var(--font-display);
font-size: 14px;
@@ -207,6 +218,12 @@ button.pill {
color: var(--on-dark);
font-variant-numeric: tabular-nums;
}
.txid {
font-size: 12px;
letter-spacing: 1px;
color: var(--muted);
overflow-wrap: anywhere;
}
.empty {
font-family: var(--font-mono);
font-size: 12px;
+94
View File
@@ -0,0 +1,94 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RIWAYAT DISTRIBUSI — {{ bp }}</title>
<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') }}">
</head>
<body>
<header class="top-nav">
<span class="nav-label">RIWAYAT</span>
<span class="wordmark">DATABISNISID</span>
<span class="nav-label"><a class="nav-link" href="{{ url_for('index') }}">DAFTAR</a></span>
</header>
<main class="page">
<section class="hero">
<p class="caption">DISTRIBUSI PROFIT-SHARE · VEX.TOKEN · PRO-RATA STAKE</p>
<h1 class="display-xl">RIWAYAT DISTRIBUSI</h1>
<p class="caption">BACA-ONLY · DISAJIKAN DARI PENYIMPANAN HARIAN</p>
</section>
<section class="stats">
<div class="spec-cell">
<span class="cell-value">{{ runs|length }}</span>
<span class="cell-label">TOTAL RUN</span>
</div>
<div class="spec-cell">
<span class="cell-value">{{ payments|length }}</span>
<span class="cell-label">TOTAL TRANSFER</span>
</div>
</section>
<section class="spec-list">
<div class="list-header">
<span>RUN</span>
<span>TANGGAL</span>
<span>SALDO AWAL</span>
<span>PEMILIH</span>
<span>TERKIRIM</span>
<span>STATUS</span>
</div>
<div class="list-rows">
{% for run in runs %}
<div class="list-row">
<span class="rank">{{ run[0] }}</span>
<span class="owner">{{ run[1] }}</span>
<span class="stake" data-label="SALDO AWAL">{{ run[2]|vex }}</span>
<span class="vote-date" data-label="PEMILIH">{{ run[3] }}</span>
<span class="stake" data-label="TERKIRIM">{{ run[5]|vex }}</span>
<span class="vote-date" data-label="STATUS">{{ 'OK' if run[6] == 'ok' else 'SEBAGIAN' }}</span>
</div>
{% else %}
<div class="empty">BELUM ADA RUN — JALANKAN distribute.py</div>
{% endfor %}
</div>
</section>
<p class="caption">TRANSFER PER PEMILIH</p>
<section class="spec-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>
</div>
{% else %}
<div class="empty">BELUM ADA TRANSFER</div>
{% endfor %}
</div>
</section>
</main>
<footer class="footer">
<span>DATA TERVALIDASI DARI JARINGAN VEXANIUM · VEX.TOKEN · TRANSFER</span>
</footer>
</body>
</html>
+1 -1
View File
@@ -13,7 +13,7 @@
<header class="top-nav">
<span class="nav-label">DAFTAR</span>
<span class="wordmark">DATABISNISID</span>
<span class="nav-label">VEX</span>
<span class="nav-label"><a class="nav-link" href="{{ url_for('history') }}">RIWAYAT</a></span>
</header>
<main class="page">
+22
View File
@@ -53,6 +53,16 @@ def main():
dbm.DB_PATH = db # arahkan app ke DB uji (read-only tetap dipertahankan)
real_get_liquid = dashboard._get_liquid_vex
dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata
# ————— V27: seed riwayat distribusi utk uji /history —————
today = datetime.now().strftime('%Y-%m-%d')
dbm.ensure_distribute_schema()
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',
'2026-08-05T10:00:01', txid='txabc123')
client = dashboard.app.test_client()
# V10: judul halaman & kolom list (bobot suara tak ditampilkan, vote segar ada)
@@ -85,8 +95,20 @@ def main():
# V10: read-only — tidak ada tabel baru / perubahan pada DB asli
before = _table_hashes(db)
client.get('/')
client.get('/history')
check('V10 read-only (DB tak berubah)', _table_hashes(db) == before)
# ————— V27: /history — riwayat distribusi (read-only) —————
resp = client.get('/history')
check('V27 /history → 200', resp.status_code == 200)
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 transfer per-pemilih tampil', 'acct001' in html)
check('V27 status SENT tampil', 'SENT' in html)
check('V27 memo tercatat', f'DATABISNISID PROFIT SHARE {today}' in html)
check('V27 txid tercatat', 'txabc123' in html)
# ————— V11: search server-side ?q= (substring, case-insensitive) —————
resp = client.get('/?q=acct12')
check('V11 search ?q= → 200', resp.status_code == 200)
+217
View File
@@ -0,0 +1,217 @@
"""Uji verifikasi distribusi profit-share (oracle §V19..V27).
`./venv/bin/python test_distribute.py` harus exit 0. Memakai DB sementara +
mock jaringan/pyntelope — ⊥ transfer nyata. Verifikasi: matematika porsi
(V20), memo per run (V23), urutan pending→sent/failed (V21), guard
anti-duplikat (V22), no-op (V25), dry-run & ⊥ key (V26).
"""
import io
import os
import sqlite3
import sys
import tempfile
from contextlib import redirect_stdout
from datetime import datetime
from decimal import Decimal
import db
import distribute as dist
def check(tag, cond):
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
class FakeSigned:
"""Objek SignedTransaction tiruan: id deterministik + send terkontrol."""
def __init__(self, txid, fails=0):
self._txid = txid
self._fails = fails
def id(self):
return self._txid
def send(self):
if self._fails > 0:
self._fails -= 1
raise RuntimeError('broadcast timeout (mock)')
return {'processed': True}
def make_voters_db(path, rows):
conn = sqlite3.connect(path)
conn.execute('DROP TABLE IF EXISTS voters')
conn.execute('CREATE TABLE voters ('
'owner TEXT PRIMARY KEY, weight TEXT NOT NULL, '
'staked REAL NOT NULL, scanned_at TEXT NOT NULL, '
'last_vote TEXT)')
conn.executemany(
'INSERT INTO voters (owner, weight, staked, scanned_at, last_vote) '
'VALUES (?, ?, ?, ?, ?)',
rows,
)
conn.commit()
conn.close()
def fake_post(data):
return type('R', (), {'raise_for_status': lambda self: None,
'json': lambda self: data})()
def main():
tmp = tempfile.mkdtemp()
db.DB_PATH = os.path.join(tmp, 'test.db')
_orig_query = db.query
# ————— V20: fetch_balance — parse chain get_currency_balance —————
dist.requests.post = lambda url, json=None, timeout=30: fake_post(
['52186.0417 VEX'])
check('V20 fetch_balance parse', dist.fetch_balance() == Decimal('52186.0417'))
dist.requests.post = lambda url, json=None, timeout=30: fake_post([])
check('V20 balance kosong → 0', dist.fetch_balance() == Decimal('0'))
# ————— V20: compute_shares — floor 4 desimal, sisa tetap di akun —————
shares = dist.compute_shares(Decimal('100.0000'),
[('a', 300.0), ('b', 700.0)])
check('V20 porsi proporsional stake',
shares == [('a', Decimal('30.0000')), ('b', Decimal('70.0000'))])
rem = dist.compute_shares(Decimal('0.0003'), [('a', 1.0), ('b', 1.0)])
check('V20 Σshare ≤ balance (sisa dibuang)',
sum(s for _, s in rem) == Decimal('0.0002'))
check('V20 sisa tetap di akun', rem == [('a', Decimal('0.0001')),
('b', Decimal('0.0001'))])
z = dist.compute_shares(Decimal('0.0002'),
[('a', 1.0), ('b', 10000.0)])
check('V20 porsi nol dibuang (a)', len(z) == 1 and z[0][0] == 'b')
check('V20 total_stake nol → []', dist.compute_shares(Decimal('1'),
[('x', 0.0)]) == [])
# ————— V26: dry-run — rencana tanpa tanda tangan / tanpa tulis DB —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
'2026-08-05T00:00:00')])
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = ''
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=True)
out = buf.getvalue()
check('V26 dry-run exit 0', rc == 0)
check('V26 dry-run menampilkan rencana', 'RENCANA' in out and 'aaa1' in out)
conn = sqlite3.connect(db.DB_PATH)
tbls = [r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()]
conn.close()
check('V26 dry-run ⊥ menulis DB', 'distribute_runs' not in tbls)
# ————— V25: no-op — pemilih kosong / saldo kecil —————
dist.fetch_balance = lambda: Decimal('100.0000')
db.query = lambda sql, params=(): []
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
check('V25 pemilih kosong → no-op', rc == 0 and 'no-op' in buf.getvalue())
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 1200.0, '2026-08-05T00:00:00',
'2026-08-05T00:00:00')])
dist.fetch_balance = lambda: Decimal('0.0000')
db.query = _orig_query
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
check('V25 saldo kecil → no-op', rc == 0 and 'no-op' in buf.getvalue())
# ————— V26: run nyata tanpa key → RuntimeError —————
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = ''
try:
dist.run_distribution(dry_run=False)
raise AssertionError('V26 gagal: tanpa key harusnya RuntimeError')
except RuntimeError:
check('V26 tanpa VEX_BP_PRIVATE_KEY → RuntimeError', True)
# ————— V21/V23/V24: run nyata — pending→sent, memo per run —————
make_voters_db(db.DB_PATH, [('aaa1', '1.0', 300.0, '2026-08-05T00:00:00',
'2026-08-05T00:00:00'),
('bbb2', '1.0', 700.0, '2026-08-05T00:00:00',
'2026-08-05T00:00:00')])
dist.fetch_balance = lambda: Decimal('100.0000')
dist.BP_PRIVATE_KEY = '5Ktest'
dist.DISTRIBUTE_MAX_ATTEMPTS = 3
calls = {'runs': [], 'pays': [], 'updates': [], 'run_updates': []}
def fake_record_run(run_date, bal, voters, stake, sent, status, created):
calls['runs'].append((run_date, status))
return 7
def fake_record_payment(run_id, owner, amount, memo, status, created,
txid=None, error=None):
calls['pays'].append((owner, amount, memo, status))
return len(calls['pays'])
def fake_update(pid, status, txid=None, error=None, updated_at=None):
calls['updates'].append((pid, status, txid))
def fake_run_update(run_id, status, total_sent):
calls['run_updates'].append((run_id, status, total_sent))
db.record_run = fake_record_run
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)
buf = io.StringIO()
with redirect_stdout(buf):
rc = dist.run_distribution(dry_run=False)
check('V21 run nyata exit 0', rc == 0)
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)
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('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',
calls['run_updates'] == [(7, 'ok', 100.0)])
# ————— V22: guard anti-duplikat — timeout lalu terverifikasi mendarat —————
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txL' + to, fails=1)
dist.verify_txid = lambda txid: True # tx sebenarnya mendarat
calls['updates'] = []
calls['run_updates'] = []
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V22 tx timeout tapi mendarat → sent tanpa resend',
all(u[1] == 'sent' for u in calls['updates']))
check('V22 terverifikasi dengan txid lama',
all(u[2].startswith('txL') for u in calls['updates']))
check('V22 run status ok',
calls['run_updates'][0][1] == 'ok')
# ————— V22: verifikasi gagal (None) → tahan, jangan resend —————
dist.build_signed_transfer = lambda to, amount, memo: FakeSigned('txH' + to, fails=1)
dist.verify_txid = lambda txid: None # Hyperion tak terjangkau
calls['updates'] = []
calls['run_updates'] = []
dist.DISTRIBUTE_MAX_ATTEMPTS = 1 # sekali coba → ditahan
with redirect_stdout(io.StringIO()):
dist.run_distribution(dry_run=False)
check('V22 verifikasi None → ditahan (failed)',
all(u[1] == 'failed' for u in calls['updates']))
check('V22 run status partial',
calls['run_updates'][0][1] == 'partial')
print('\nSEMUA UJI PASS')
if __name__ == '__main__':
sys.exit(main())