28 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 on a weekly schedule: it signs vex.token::transfer actions with the BP active key to send the account's liquid VEX to every stored voter, proportional to stake. In the swarm stack the daily payout is driven by distribute_loop.py (service distribute), the scan equivalent of scan_loop.py. A fifth tool (claim.py/claim_loop.py, service claim) auto-claims the BP reward: at the 24h window it calls vexcore::claimrewards, then transfers 10% of the claimed reward to the fee wallet bpdbsjasprod (spec §V32–V34).
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 restricted to the reward window (not stale AND account mature,last_vote > now−28dANDfirst_seen_at ≤ now−3d, §V40/V52: new voters (first_seenrecent/NULL) and stale voters get nothing — returning re-voters with mature first_seen are paid regardless of vote age), 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. A real run is a no-op (exit 0) unlessDISTRIBUTE_ENABLED=true— default is off (kill-switch, V35);--dry-runalways works. Failed rows are recordedfailedand rejoined by the next day's run — no manual cleanup needed. - Daily reward claim (production):
./venv/bin/python claim_loop.py— theclaimservice scheduler.claim.pycomputes the claim window aslast_claim_time + 24h(from thevexcoreproducerstable) in UTC-naive time (_utcnow(), matching the chain's UTClast_claim_time; the container's local TZ must not shift the window — V49/B13), sleeps until near it, then pollsvexcore::claimrewardseveryCLAIM_RETRY_SECONDS(default 60) until the chain accepts; if 24h have already elapsed since the last claim the window clamps to now (missed-window recovery, no permanent spin-timeout). A claim is only treated as "landed" if Hyperion saysexecutedORlast_claim_timeactually advanced past its pre-send value (_claim_time_advanced) — on both the send-success and send-exception paths (_confirm_landed), because pyntelope'ssend()does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11), and Hyperion returningexecuted: falsefor a just-landed-but-not-yet-indexed tx must NOT count as "didn't land" (V50/B14). Unconfirmed ⇒ silentretry(noclaim_runsrow, no success notify) — self-heals when Hyperion indexes the tx on the next poll. The landing baseline (last_claim_timebefore send) is captured once per poll window inpoll_claimand passed to every_try_claim_once— it must NOT be re-read per attempt, or a landed-but-stale-first-read claim falls into a permanent retry loop (V54/B16);_confirm_landedalso retries the post-sendlast_claim_timeread so a lagging RPC node doesn't cause a false negative.next_windowraises if all producer reads fail (never clamps to now prematurely). Reward is measured from the claim tx itself (sum ofvex.bpay+vex.vpay→ BP transfers via Hyperion,reward_from_tx), falling back to the liquid-balance delta if Hyperion is down; if neither can be read the claim is recorded with feependingand an internalKLAIM MENDARAT · REWARD TAK TERUKURalert is sent (never a misleading 0-reward success). 10% (VEX_BP_FEE_PERCENT) is transferred toVEX_BP_FEE_WALLET(defaultbpdbsjasprod) with memoBP FEE YYYY-MM-DD, reusingdistribute.build_signed_transfer. A claim cycle is complete only when the claim is recorded inclaim_runsAND the fee is sent; an unsent fee (pending/failed) is retried each cycle and resumed on restart (crash-safe). No mutex withdistribute.py— distribution freezes the balance at run start, so a mid-run claim is deferred to the next run. - 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+databisnisid-dist+databisnisid-claim— 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, dist and claim images), then from a swarm manager rundocker stack deploy -c docker-compose.yml databisnisid. Stack = mariadb (internal) + web (gunicorn dashboard, published:5001; also receivesTZ+DISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUNfrom.envfor the BERITA banner, V38) + scan (scan_loop.py, runsget_voters.pyatSCAN_HOURSdefault0,8,16, local timezoneTZdefaultAsia/Jakarta, plus one scan at container start viaSCAN_RUN_ON_START) + distribute (distribute_loop.py, runsdistribute.pytwice-weekly atDISTRIBUTE_WEEKDAYdefaultwed,sat(CSV, multi-day V44) + hourDISTRIBUTE_HOURdefault10, one-offDISTRIBUTE_FIRST_RUNdefault baked2026-08-17; schedule read viaconfig, konsisten dgn dashboard); keyVEX_BP_PRIVATE_KEYread from the bind-mounted/app/.env→ host/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.envviaconfig.pyload_dotenv,:ro) + claim (claim_loop.py, pollclaimrewardsat the 24h window, key + Telegram also from the same NFS bind).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/scan/distribute/claimexclude nodeserver2U(node.hostname != server2U) but otherwise can 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. Vote bands (V40/V42/V52): BARU (new first-time voters,first_seen_atwithin 3 days, own table on top, never paid — maturity is measured fromfirst_seen_at, NOTlast_votewhich is week-quantized) → main list: KADALUARSA (28–31 days, pinned amber rows at the top withVOTE ULANGbadge + legend) then VALID (the paged 50/page list,staked DESC, live owner search via/api/search); voters older than 31 days are stored by the scan but hidden. Since V42, REVOTE (returning voters,voter_first_seen.first_seen_at ≤ now−3d) are VALID immediately — they join the main list tagged with a mintREVOTEtag (legendTAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG), no separate band, no maturity wait; since V43 the mint tag shows only whilelast_vote > now−VEX_REVOTE_TAG_DAYS(default 1 day) — after that they're plain VALID (still paid); VALID rows withinVEX_WARN_DAYS(default 3) of the KADALUARSA cutoff get an amber dashedVOTE ULANG SEGERAbadge (legendTAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH); only genuinely new voters pass the 3-day maturity in BARU. 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,VEX_MATURITY_DAYS,VEX_EXPIRED_DAYS,VEX_REVOTE_TAG_DAYS,VEX_WARN_DAYS,DATABISNIS_API,DASH_LIQUID_TTL,VEX_BP_PRIVATE_KEY,DISTRIBUTE_HOUR,DISTRIBUTE_WEEKDAY(CSV multi-hari V44, defaultwed,sat),DISTRIBUTE_FIRST_RUN(default baked2026-08-17),DISTRIBUTE_MAX_ATTEMPTS,DISTRIBUTE_ENABLED,TELEGRAM_BOT_TOKEN,TELEGRAM_COMMUNITY_CHAT_IDS,TELEGRAM_INTERNAL_CHAT_IDS,CLAIM_RETRY_SECONDS,VEX_BP_FEE_WALLET,VEX_BP_FEE_PERCENT. 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 distribute_loop.py telegram.py claim.py claim_loop.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_distribute_loop.py test_images.py test_claim.py. - Tests (the verification oracles):
./venv/bin/python test_get_voters.py,./venv/bin/python test_dashboard.py,./venv/bin/python test_distribute.py,./venv/bin/python test_distribute_loop.py,./venv/bin/python test_images.py, and./venv/bin/python test_claim.pymust all exit 0.test_images.py(V31) is a static check that each image (Dockerfile/Dockerfile.scan/Dockerfile.dist/Dockerfile.claim) copies every intra-project module its copied modules import.test_distribute_loop.py(V37/V44) is pure calendar logic — weekly schedule boundary (multi-day CSV) + one-off first-run. 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/V40):
last_vote(estimated last re-vote date) is derived at scan time from the Vexanium weight formulalast_vote_weight = staked_raw × 2^(weeks since 2000 / 52)→last_vote = 2000-01-01 + round(52 × log2(weight / (staked×10000))) weeks— the chain (stake2vote) quantizes the exponent to whole weeks (int64((now−2000-01-01)/(86400×7))/52.0), so derived dates land on 7-day boundaries (B12; earlier 365.25-day divisor drifted ~33 days forward for 2026 votes). Since V40normalize()stores every BP voter with a derivablelast_vote— any age; only unverifiable votes (zero/empty weight) are dropped. Banding (BARU/VALID/KADALUARSA), display and payout are computed at query time (dashboard cuts 28/31 days onlast_voteage; distribution payslast_vote > now−28d). Maturity (3 days) is measured fromfirst_seen_at, notlast_vote—last_voteis week-quantized so a new voter voting Mon–Thu would look 4–6 days old and bypass the wait (B15); V52 usesvoter_first_seen.first_seen_at(scan-precise to the second) for the new-voter wait. The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), sonormalize()clamps anylast_votepast scan time down tonow. Since V41,voter_first_seen.first_seen_atseparates PEMILIH BARU (new, first-seen within the 3-day maturity) from REVOTE (returning re-voters); since V42 REVOTE are VALID immediately (paid + shown in the main list, mint tag), only new voters wait out maturity; since V43 the mint tag lasts onlyVEX_REVOTE_TAG_DAYS(1) and VALID rows nearing the KADALUARSA cutoff (withinVEX_WARN_DAYS, 3) get aVOTE ULANG SEGERAbadge — display-only. - The web list shows RANK / AKUN / STAKE (VEX) / TOTAL REWARD (VEX) / VOTE TERAKHIR columns (3 stats cells). TOTAL REWARD = all-time sum of
distribute_payments.amountwherestatus='sent'(0,0000 for never-paid), joined per page via a LEFT JOIN subquery. 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 run. 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. - Claim (spec §V32–V34): the BP reward is claimed once per 24h window via
vexcore::claimrewards(owner= BP). Theclaimservice polls everyCLAIM_RETRY_SECONDSonly near the window (last_claim_timefrom thevexcoreproducerstable + 24h) — no all-day tx spam; a window already ≥24h past clamps to now so a missed claim is caught up, never spin-timeout (B8).get_table_rowsresponses are always{"rows":[...]}— parsedata.get('rows'), neverdata[0](B7, V39). Landed detection = HyperionexecutedORlast_claim_timeadvanced past pre-send value, checked on BOTH the send-success and send-exception paths (_confirm_landed) because pyntelope'ssend()returns HTTP 500 as a body instead of raising on a chain rejection (V46, B11); Hyperionexecuted: falseon a not-yet-indexed tx still falls back to thelast_claim_timeadvance check (V50, B14).next_windowraises if all producer reads fail so a flaky node never triggers a premature claim. Reward = sum ofvex.bpay+vex.vpay→ BP transfers read from the claim tx via Hyperion (reward_from_tx), balance-delta as fallback (V33); if neither can be read → feepending+ internalKLAIM MENDARAT · REWARD TAK TERUKURalert, never a misleading 0-reward success (B10). 10% fee floored at 4-dec goes tobpdbsjasprod(VEX_BP_FEE_WALLET), memoBP FEE YYYY-MM-DD, txid verified via Hyperion before any resend. A cycle completes only when the claim row (claim_runs) is recorded AND the fee issent; an unsent fee is retried each cycle and resumed on restart. No mutex with distribution — the daily payout freezes the balance at run start, so a claim landing mid-run is simply paid out the next run. - Dashboard layout (spec §V28): desktop gives AKUN/STAKE/REWARD/VOTE equal width with AKUN left and the other three centered; tablet keeps the fixed STAKE column (RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr);
/historyuses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID tohttps://vexascan.com/transaction/{txid}— no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses 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-min + BP target, semua umur vote disimpan V40; hanya last_vote tak terverifikasi dibuang) →db.replace_snapshot(each run replaces the table = daily snapshot, withscanned_at+ derivedlast_vote; never appends history) →db.record_first_seen(V41: catat kemunculan pertama tiap owner kevoter_first_seen, idempoten).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. Juga tabel klaim:claim_runs+ helperensure_claim_schema/record_claim/update_claim_fee/pending_claim_fee. Juga tabel V41voter_first_seen(owner PK +first_seen_at, append-only) + helperensure_first_seen_schema/record_first_seen— dipakai dashboard membedakan PEMILIH BARU vs REVOTE.get_voters.js— reference implementation only.distribute.py— pembayaran harian: fetch liquid balance → baca pemilih band reward V40/V52 (last_vote > now−28d AND first_seen_at ≤ now−3dvia LEFT JOINvoter_first_seen— pemilih baru (first_seenbaru/NULL) & basi ⊥ dibayar; akun yang sudah matang (first_seen ≤ now−3d) dibayar langsung, ⊖ peduli umur vote, V52) →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; notifikasi Telegram mulai/selesai/gagal viatelegram.py(best-effort, ⊥ dry-run/no-op).test_distribute.py— oracle (mock chain+pyntelope, temp DB).claim.py— klaim reward BP harian: bacalast_claim_time(tabelproducers, di-retry V46) →next_window= +24 jam, math UTC-naive via_utcnow()(V49/B13 — ⊥datetime.now()lokal yang geser +TZ → poll prematur) → (≥24 jam lewat → clamp ke now, recovery B8); tidur sampai mendekat, lalu pollvexcore::claimrewards(sign pyntelopeVEX_BP_PRIVATE_KEY) tiapCLAIM_RETRY_SECONDS; deteksi mendarat via_confirm_landeddi KEDUA jalur (send sukses & exception) = HyperionexecutedATAUlast_claim_timemaju dari baseline (V46/B11 —send()pyntelope ⊥ raise saat chain menolak, HTTP 500 sbg body; Hyperionexecuted: falseutk tx baru-mendarat-belum-terindeks juga dicek lewat_claim_time_advanced, V50/B14); tak terkonfirmasi → retry senyap; reward = isi tx via Hyperion (bpay+vpay → BP,reward_from_tx) prioritas, selisih saldo fallback; reward ⊥ terukur → feepending+ notifKLAIM MENDARAT · REWARD TAK TERUKUR(V33/B10); fee 10% (VEX_BP_FEE_PERCENT) floor 4-des →VEX_BP_FEE_WALLETviadistribute.build_signed_transfer, memoBP FEE YYYY-MM-DD; parseget_table_rowspakaidata.get('rows')(bentuk asli dict, ⊥data[0]— B7/V39);step()= state machine (resume fee → jadwal → poll), source of truthclaim_runs(fee pending/failed diulang + resume saat restart). Modul logika (⊥ CLI).test_claim.py— oracle (mock chain+pyntelope, bentuk respons asli, temp DB).claim_loop.py— scheduler harian dalam container stack (serviceclaim):_wait_db(duplikat scan_loop) laluclaim.step()terus, loop tak pernah keluar.telegram.py— kirim notifikasi status distribusi + klaim via Telegram Bot API (best-effort; ⊥ token/chat id → kanal no-op senyap; gagal kirim → log saja). Dua kanal:TELEGRAM_COMMUNITY_CHAT_IDS(CSV) = brief mulai/selesai distribusi saja;TELEGRAM_INTERNAL_CHAT_IDS(CSV) = detail distribusi + semua kegagalan + klaim reward (⊥ komunitas, V30/V36).send_text(text, chat_ids=None)= komunitas,send_internal(text)= internal; dari env/.env/NFSconfig.env.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. Three vote-age bands (V40/V52): the voters list is split into BARU (top,PEMILIH BARU · BELUM MATANG, new accounts byfirst_seen_atwithin 3 days) and the main list (voter-listsection,.rewardcells, V29) where KADALUARSA rows are pinned at the top with amber styling +VOTE ULANGbadge + legend before the paged VALID rows, which LEFT-joins astatus='sent'payments aggregate for the TOTAL REWARD column; since V42 the VALID list also holds REVOTE (returning re-voters,voter_first_seen.first_seen_at ≤ now−3d) tagged with a mintREVOTEtag (legendTAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG, shows only whilelast_vote > now−VEX_REVOTE_TAG_DAYS) — no separate band, no maturity wait; only genuinely new voters sit in BARU taggedBARU; since V43 VALID rows withinVEX_WARN_DAYSof the KADALUARSA cutoff get an amber dashedVOTE ULANG SEGERAbadge + legendTAG AMBER = KADALUARSA DALAM 3 HARI · N PEMILIH(display-only); search (?q=and/api/search) covers all bands and tags each result (group=baru|revote|valid|kadaluarsa+expired+expiring). Banner BERITA (V38) shows the next distribution run fromDISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN+TZ(mirror ofdistribute_loop.next_boundary, ⊥ impor lintas-image; drift guarded in test_dashboard); theSETIAP ... · HH:00recurrence line renders only for regular weekly rounds — hidden while the next event is the one-off first-run (_is_first_run_next, date ≠ weekly day). Astats-notecaption states the list is single-vote-to-BP accounts ≥ min stake (V47); account names link tohttps://vexascan.com/account/{owner}in the index list,/historypay-list, and live search (.owner-link, V51); a promo card (BY DATABISNISID · VEXWALLET) links the VexWallet Android app on the index page via the IDRS logo (static/idrs.png) +UNDUH DI PLAY STORE(V53); BARU rows show aMATURITY PERIODcountdown column (VALID DALAM N HARI) instead of TOTAL REWARD./history= riwayat distribusi read-only;run-list= six-column desktop/tablet grid,pay-list= four-column grid (AKUN/JUMLAH/STATUS/TXID) showing only the latest run with its date in the title, and labeled mobile cards.gunicorn.conf.py— gunicorn production config (bind/workers 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,VEX_MATURITY_DAYS,VEX_EXPIRED_DAYS,DATABISNIS_API,DASH_LIQUID_TTL,BP_PRIVATE_KEY,DISTRIBUTE_HOUR,DISTRIBUTE_MAX_ATTEMPTS,TELEGRAM_BOT_TOKEN,TELEGRAM_CHAT_IDS,CLAIM_RETRY_SECONDS,BP_FEE_WALLET,BP_FEE_PERCENT; 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.distribute_loop.py— scheduler dua-kali-minggu dalam container stack (servicedistribute): tungguDISTRIBUTE_WEEKDAY(defaultwed,sat, CSV multi-hari V44) +DISTRIBUTE_HOUR(lokal viaTZ), plus satu one-offDISTRIBUTE_FIRST_RUN(YYYY-MM-DD, default baked2026-08-17); jadwal dibaca viaconfig(konsisten dgn dashboard V38); panggildistribute.main(), loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).Dockerfile— image webdatabisnisid-web(gunicorn dashboard;DASH_HOST=0.0.0.0di stack agar ingress menjangkaunya).Dockerfile.scan— imagedatabisnisid-scan(scan_loop; sertakantzdata).Dockerfile.dist— imagedatabisnisid-dist(distribute_loop; sertakantzdata+ pyntelope via requirements).Dockerfile.claim— imagedatabisnisid-claim(claim_loop + claim + distribute untuk fee; sertakantzdata+ pyntelope). Di stack produksi keempatnya di-push ke registrygit.proit.id/proitlab/databisnisid-{web,scan,dist,claim}(⊥build:di compose)..dockerignore— venv/.env/artifak tak masuk build context.docker-compose.yml— STACK SWARM PRODUKSI (mariadb internal + web:5000+ scan + distribute + claim); image dari registrygit.proit.id/proitlab/databisnisid-*; mariadb bind mount/data/db/mariadb/databisnisid/data+ placementnode.hostname == server5.saltis.id; network overlayappnet;distribute&claimbawaVEX_BP_PRIVATE_KEYvia bind/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro(dibacaconfig.pyload_dotenv; ⊥ interpolasi).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.