13 KiB
13 KiB
AGENTS.md
Two tools scan the Vexanium blockchain voters table for accounts whose only vote goes to this BP (databisnisid): a Node.js reference script and the Python production tool that persists results to SQLite. A third tool (distribute.py) pays those voters daily: it signs vex.token::transfer actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake.
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, depsrequests+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 signedvex.token::transferper voter with memoDATABISNISID PROFIT SHARE YYYY-MM-DD. Preview the plan without signing/writing:./venv/bin/python distribute.py --dry-run. RequiresVEX_BP_PRIVATE_KEY(BP active key) in env/.env; without it--dry-runstill works, a real run raises. Failed rows are recordedfailedand rejoined by the next day's run — no manual cleanup needed. - Storage backend:
db.pyabstracts it. Defaultsqlite(VEX_DB_PATH, stdlibsqlite3, WAL). Optionalmysql(VEX_DB_BACKEND=mysql+VEX_DB_HOST/PORT/USER/PASS/NAME, PyMySQL). Oracle tests stay on sqlite;test_mariadb.pyis opt-in (skips unlessVEX_DB_BACKEND=mysql). Query SQL is written once with%splaceholders (translated to?for sqlite);db.queryalways returns a list. - Test MariaDB/MySQL via docker:
docker compose -f docker-compose.dev.yml up -d(mariadb:11 containerdatabisnisid-mariadb, localhost-only127.0.0.1:3306, db/user/passdatabisnisid/databisnis/databisnis, named volume, healthcheck). Stop/remove withdocker compose -f docker-compose.dev.yml down; wipe data withdocker compose -f docker-compose.dev.yml down -v. Verify withdocker 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 rundocker stack deploy -c docker-compose.yml databisnisid. Stack = mariadb (internal) + web (gunicorn dashboard, published:5000) + scan (scan_loop.py, runsget_voters.pyatSCAN_HOURSdefault0,8,16, local timezoneTZdefaultAsia/Jakarta, plus one scan at container start viaSCAN_RUN_ON_START).mariadbis pinned byplacement.constraints: node.hostname == server5.saltis.idbecause its data lives in the host bind mount/data/db/mariadb/databisnisid/dataon that node;web/scancan run on any node and reach it over the overlay networkappnet. Inspect:docker stack services databisnisid,docker service logs databisnisid_scan,docker stack rm databisnisid.docker stack deployignoresbuild:(images must already be in the registry) and ignoresenv_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 namedserver5.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.pyimportsconfig→.envhonored;DASH_WORKERS(default 2) controls workers,DASH_HOST/DASH_PORTthe bind. Dev server (single-process) still works via./venv/bin/python dashboard.py. Paged 50/page, sortedstaked DESC, live owner search (/api/search). Data freshness comes from the daily scan run — the dashboard never scans. - Config: all tunables load from env /
.envviaconfig.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→.envto override;.envis 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.pymust 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
TARGET_BP(databisnisid) andAPI_NODE(https://v2.vexascan.com:2096) are the defaults inconfig.py(env-overridable); the JS reference still hardcodes them at the top of the file.- The public Vexanium RPC node is flaky/timeout-prone; both scripts have retry logic built in — don't remove or bypass it. A full scan of the voters table takes minutes.
- The system contract on Vexanium is
vexcore(notvexio), scope is alsovexcore. Do not "fix" this to match EOS convention. - VEX stake is stored scaled by 10000; the Python tool divides by 10000 before storing.
- The node sometimes returns
stakedas a string instead of a number — normalize coerces to float (spec §V8). - The voters table has a sentinel first row (
owner="...........q", uint64 0) that must be skipped. - Authoritative vote weight is
last_vote_weight(high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6). - Freshness filter (spec §V13):
last_vote(estimated last re-vote date) is derived at scan time from the Vexanium weight formulalast_vote_weight = staked_raw × 2^(years since 2000)→log2(weight / (staked×10000)). Voters whoselast_voteis older thanVEX_STALE_DAYS(default 28) — or can't be derived (zero/empty weight) — are dropped innormalize()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), sonormalize()clamps anylast_votepast scan time down tonow. - The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and
/api/searchJSON but is not rendered as a column. - Token contract is
vex.token(NOTeosio.token— that name doesn't exist on Vexanium), 4-decimal VEX, chain_idf9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f. Distribution signsvex.token::transferwith the BPactivekey. - 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 noexecuted) is never re-sent the same run — that payment staysfailedand rejoins the next day. No-op (exit 0, no writes) when the voters table is empty or balance < 0.0001. The dashboard's/historyview rendersdistribute_runs+distribute_paymentsread-only. - Dashboard layout (spec §V28): desktop/tablet balances the STAKE column near page center;
/historyuses six-column run/payment grids; mobile turns history rows into labeled cards, including MEMO and TXID. HTML responses useCache-Control: no-store; stylesheet URL is versioned with?v=for deploy cache-busting. - Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (
account.core_liquid_balance) fetched fromGET {DATABISNIS_API}/v2/state/get_account?account=<BP>. The dashboard fetches it live but caches per-worker in memory forDASH_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, withscanned_at+ derivedlast_vote; never appends history).db.py— storage abstraction (sqlite default | mysql via PyMySQL);connect/query/queryone/replace_snapshot;%s→?for sqlite;queryreturns list. Juga tabel distribusi:distribute_runs+distribute_payments(append-only) + helperensure_distribute_schema/record_run/record_payment/update_payment_status/update_run_status/list_runs/list_payments.get_voters.js— reference implementation only.distribute.py— pembayaran harian: fetch liquid balance →compute_shares(Decimal floor 4-des, sisa di akun) → signvex.token::transfervia pyntelope (trx.linkambil ABI+TAPOS dari node,signdenganVEX_BP_PRIVATE_KEY,.send()) → catatsent/failedper 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 viadb.py, styled perDESIGN.md;app.js= debounced live owner search (fetch/api/search), degrades to the server-side?q=GET form if JS is off./history= riwayat distribusi read-only;run-list/pay-listuse six-column desktop/tablet grids and labeled mobile cards.gunicorn.conf.py— gunicorn production config (bind/workers fromconfig, sync worker).run.sh— launcher:./run.sh=./venv/bin/gunicorn -c gunicorn.conf.py dashboard:appfrom any cwd.config.py— loads env/.env(python-dotenv) →TARGET_BP,API_NODE,DB_PATH,DB_BACKEND,DB_HOST,DB_PORT,DB_USER,DB_PASS,DB_NAME,MIN_STAKED_VEX,PAGE_SIZE,DASH_HOST,DASH_PORT,DASH_WORKERS,VEX_STALE_DAYS,DATABISNIS_API,DASH_LIQUID_TTL,BP_PRIVATE_KEY,DISTRIBUTE_HOUR,DISTRIBUTE_MAX_ATTEMPTS; shared by get_voters & dashboard.scan_loop.py— scheduler dalam container stack: menunggu batasSCAN_HOURS(lokal viaTZ), panggilget_voters.main(); skan-awalSCAN_RUN_ON_START+ tunggu DB siap; loop tak pernah keluar.Dockerfile— image webdatabisnisid-web(gunicorn dashboard;DASH_HOST=0.0.0.0di stack agar ingress menjangkaunya).Dockerfile.scan— imagedatabisnisid-scan(scan_loop; sertakantzdata). Di stack produksi keduanya di-push ke registrygit.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 registrygit.proit.id/proitlab/databisnisid-*; mariadb bind mount/data/db/mariadb/databisnisid/data+ placementnode.hostname == server5.saltis.id; network overlayappnet.docker-compose.dev.yml— mariadb uji lokal (127.0.0.1:3306, volumemariadb_data).DESIGN.md— Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).voters.db— SQLite output (daily snapshot, gitignored in spirit).
Conventions
- Comments and console output are in Indonesian — keep new output/comments in Indonesian.
- Dashboard UI labels are Indonesian uppercase captions (e.g. "DAFTAR PEMILIH", "TOTAL PEMILIH").
- JS style: 2-space indent, semicolons, single quotes, trailing commas, async/await.
- Python style: 4-space indent, stdlib
sqlite3,requests,flask, PEP8. - New SQL shared across backends:
%splaceholders (never?),ESCAPE '!'for LIKE (backslash breaks MySQL string literals), no MySQL-only syntax.