diff --git a/.gitignore b/.gitignore index e6c0af7..79fdf17 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ scan.log __pycache__/ *.pyc +# opencode session state +.oc/ + # local env config (secrets/overrides — jangan di-commit) .env diff --git a/AGENTS.md b/AGENTS.md index 0e4c28d..b6b6d82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=` 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=`. 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`). diff --git a/README.md b/README.md deleted file mode 100644 index fcab55b..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# databisnisid - diff --git a/SPEC.md b/SPEC.md index 8f9ab89..9fdee9d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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=` → 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=` (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 diff --git a/config.py b/config.py index 9946478..94d6948 100644 --- a/config.py +++ b/config.py @@ -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' diff --git a/dashboard.py b/dashboard.py index cc76ff2..4673c9e 100644 --- a/dashboard.py +++ b/dashboard.py @@ -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) diff --git a/db.py b/db.py index 4365fb7..fc1a088 100644 --- a/db.py +++ b/db.py @@ -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') diff --git a/distribute.py b/distribute.py new file mode 100644 index 0000000..fa66428 --- /dev/null +++ b/distribute.py @@ -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()) diff --git a/requirements.txt b/requirements.txt index 562df9c..66cb6e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ flask>=3.0 python-dotenv>=1.0 gunicorn>=22.0 PyMySQL>=1.1 +pyntelope>=0.1.2 diff --git a/static/style.css b/static/style.css index 04e13ec..c15c0e4 100644 --- a/static/style.css +++ b/static/style.css @@ -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; diff --git a/templates/history.html b/templates/history.html new file mode 100644 index 0000000..bdb78b5 --- /dev/null +++ b/templates/history.html @@ -0,0 +1,94 @@ + + + + + + RIWAYAT DISTRIBUSI — {{ bp }} + + + + + + +
+ RIWAYAT + DATABISNISID + DAFTAR +
+ +
+
+

DISTRIBUSI PROFIT-SHARE · VEX.TOKEN · PRO-RATA STAKE

+

RIWAYAT DISTRIBUSI

+

BACA-ONLY · DISAJIKAN DARI PENYIMPANAN HARIAN

+
+ +
+
+ {{ runs|length }} + TOTAL RUN +
+
+ {{ payments|length }} + TOTAL TRANSFER +
+
+ +
+
+ RUN + TANGGAL + SALDO AWAL + PEMILIH + TERKIRIM + STATUS +
+
+ {% for run in runs %} +
+ {{ run[0] }} + {{ run[1] }} + {{ run[2]|vex }} + {{ run[3] }} + {{ run[5]|vex }} + {{ 'OK' if run[6] == 'ok' else 'SEBAGIAN' }} +
+ {% else %} +
BELUM ADA RUN — JALANKAN distribute.py
+ {% endfor %} +
+
+ +

TRANSFER PER PEMILIH

+ +
+
+ AKUN + TANGGAL + JUMLAH + STATUS + MEMO + TXID +
+
+ {% for pay in payments %} +
+ {{ pay[2] }} + {{ pay[10] }} + {{ pay[3]|vex }} + {{ pay[6]|upper }} + {{ pay[4] }} + {{ pay[5] or '—' }} +
+ {% else %} +
BELUM ADA TRANSFER
+ {% endfor %} +
+
+
+ +
+ DATA TERVALIDASI DARI JARINGAN VEXANIUM · VEX.TOKEN · TRANSFER +
+ + diff --git a/templates/index.html b/templates/index.html index 50db966..82392aa 100644 --- a/templates/index.html +++ b/templates/index.html @@ -13,7 +13,7 @@
DAFTAR DATABISNISID - VEX + RIWAYAT
diff --git a/test_dashboard.py b/test_dashboard.py index 310fee5..b92bc75 100644 --- a/test_dashboard.py +++ b/test_dashboard.py @@ -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) diff --git a/test_distribute.py b/test_distribute.py new file mode 100644 index 0000000..6c4e00c --- /dev/null +++ b/test_distribute.py @@ -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())