Files
databisnisid/AGENTS.md
T

35 KiB
Raw Blame History

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, 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 restricted to the reward window (not stale AND account mature, last_vote > now−28d AND first_seen_at ≤ now−3d, §V40/V52: new voters (first_seen recent/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 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. A real run is a no-op (exit 0) unless DISTRIBUTE_ENABLED=true — default is off (kill-switch, V35); --dry-run always works. Failed rows are recorded failed and rejoined by the next day's run — no manual cleanup needed. Send safety (V59/V61): if a transfer broadcasts but Hyperion can't confirm it, the payment is held for real — marked failed at the first attempt and never re-signed (a resend would carry a fresh tapos/expiration → new txid → possible double-send). verify_txid → None (Hyperion unreachable) and executed: false (index lag, V50/B14) BOTH hold now — the send-loop exception path runs _verify_settled (retries the verify 3× with 2s delay to let Hyperion catch up; None → False immediately, pool fully dead) and only True marks sent + breaks, anything else holds (V61/B21). Chain rejections that come back as HTTP-500 bodies (pyntelope does NOT raise on them — duplicate/insufficient-balance, pattern V46/B11) are detected by _send_rejected (V61: WHITELIST — success is only a dict with transaction_id + receipt executed/no status; any other 500 body or soft_fail → rejected) and routed through the verify/retry path, never recorded sent for a tx that didn't land. Pacing (V63): the send loop sleeps DISTRIBUTE_SEND_DELAY (default 30s) between voters — N voters → N−1 sleeps, none after the last — so the node/Hyperion can catch up with indexing and broadcast load stays even (real run only; --dry-run never sleeps). All /v2/* reads guard isinstance(data, dict) so a non-dict 200 (proxy misconfig) is skipped, never a crash (V61/B23). Node resilience (V58): all chain RPC calls fail over across the API_NODES pool (VEX_API_NODES CSV, default v2.vexascan.com:2096 + https://api.databisnis.id) — balance fetch, ABI+TAPOS, and broadcast; the real-run balance fetch retries with backoff ~10 min before aborting to the next schedule, --dry-run fails fast. Hyperion resilience (V60): every /v2/* read (verify_txid, claim.reward_from_tx, dashboard SALDO LIQUID) fails over across the HYPERION_NODES pool (VEX_HYPERION_NODES CSV, default = HYPERION_API + API_NODES dedup — both hosts verified to serve Hyperion) via distribute._get_json (GET mirror of the V58 _post_json, 3 rounds, 2s), so a single Hyperion blip no longer force-holds payments or fails reward measurement.
  • Daily reward claim (production): ./venv/bin/python claim_loop.py — the claim service scheduler. claim.py computes the claim window as last_claim_time + 24h (from the vexcore producers table) in UTC-naive time (_utcnow(), matching the chain's UTC last_claim_time; the container's local TZ must not shift the window — V49/B13), sleeps until near it, then polls vexcore::claimrewards every CLAIM_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 says executed OR last_claim_time actually advanced past its pre-send value (_claim_time_advanced) — on both the send-success and send-exception paths (_confirm_landed), because pyntelope's send() does NOT raise on a chain rejection (HTTP 500 comes back as a JSON body, V46/B11), and Hyperion returning executed: false for a just-landed-but-not-yet-indexed tx must NOT count as "didn't land" (V50/B14). Unconfirmed ⇒ silent retry (no claim_runs row, no success notify) — self-heals when Hyperion indexes the tx on the next poll. The landing baseline (last_claim_time before send) is captured once per poll window in poll_claim and 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_landed also retries the post-send last_claim_time read so a lagging RPC node doesn't cause a false negative. The pre-claim liquid balance is likewise captured once per poll window (before_balance) and passed to every _try_claim_once (V56/B18) — if a claim lands on attempt 1, the next (rejected "already claimed") attempt must still measure the reward via the window-start balance, not a re-read before that already includes the credit (which reads delta 0 → false KLAIM MENDARAT · REWARD TAK TERUKUR, e.g. the 2026-08-10 claim bfa9ab02… reward 1679.3365 recorded against rejected tx b0a8b2f9…). next_window raises if all producer reads fail (never clamps to now prematurely). Reward is measured from the claim tx itself (sum of vex.bpay+vex.vpay → BP transfers via Hyperion, reward_from_tx), retried up to 4× at SETTLE_SECONDS (30s) so a just-landed tx not yet indexed by Hyperion doesn't read as None (V55/B17); if Hyperion is down it falls back to the liquid-balance delta, but only a positive delta (after > before) counts as measured — a 0 delta is ambiguous (stale node balance) and is treated as unmeasured, never as a genuine zero; if neither can be read the claim is recorded with fee pending and an internal KLAIM MENDARAT · REWARD TAK TERUKUR alert is sent (never a misleading 0-reward success — a real 0-reward claim must be evidenced by reward_from_tx returning Decimal('0') from an executed tx). 10% (VEX_BP_FEE_PERCENT) is transferred to VEX_BP_FEE_WALLET (default bpdbsjasprod) with memo BP FEE YYYY-MM-DD, reusing distribute.build_signed_transfer. A claim cycle is complete only when the claim is recorded in claim_runs AND the fee is sent; an unsent fee (pending/failed) is retried each cycle and resumed on restart (crash-safe). No mutex with distribute.py — distribution freezes the balance at run start, so a mid-run claim is deferred to the next run.
  • 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 + 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 run docker stack deploy -c docker-compose.yml databisnisid. Stack = mariadb (internal) + web (gunicorn dashboard, published :5001; also receives TZ + DISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN from .env for the BERITA banner, V38) + scan (scan_loop.py, runs get_voters.py at SCAN_HOURS default 0,8,16, local timezone TZ default Asia/Jakarta, plus one scan at container start via SCAN_RUN_ON_START) + distribute (distribute_loop.py, runs distribute.py twice-weekly at DISTRIBUTE_WEEKDAY default wed,sat (CSV, multi-day V44) + hour DISTRIBUTE_HOUR default 10, one-off DISTRIBUTE_FIRST_RUN default baked 2026-08-17; schedule read via config, konsisten dgn dashboard); key VEX_BP_PRIVATE_KEY read from the bind-mounted /app/.env → host /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env via config.py load_dotenv, :ro) + claim (claim_loop.py, poll claimrewards at the 24h window, key + Telegram also from the same NFS bind). mariadb is pinned by placement.constraints: node.hostname == server5.saltis.id because its data lives in the host bind mount /data/db/mariadb/databisnisid/data on that node; web/scan/distribute/claim exclude node server2U (node.hostname != server2U) but otherwise 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. Vote bands (V40/V42/V52): BARU (new first-time voters, first_seen_at within 3 days, own table on top, never paid — maturity is measured from first_seen_at, NOT last_vote which is week-quantized) → main list: KADALUARSA (28–31 days, pinned amber rows at the top with VOTE ULANG badge + 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 mint REVOTE tag (legend TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG), no separate band, no maturity wait; since V43 the mint tag shows only while last_vote > now−VEX_REVOTE_TAG_DAYS (default 1 day) — after that they're plain VALID (still paid); VALID rows within VEX_WARN_DAYS (default 3) of the KADALUARSA cutoff get an amber dashed VOTE ULANG SEGERA badge (legend TAG 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 / .env via config.py (python-dotenv): VEX_TARGET_BP, VEX_API_NODE, VEX_API_NODES (CSV pool RPC failover distribusi V58, default = VEX_API_NODE + https://api.databisnis.id), VEX_HYPERION_NODES (CSV pool Hyperion failover V60, default = HYPERION_API + API_NODES dedup), 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, HYPERION_API (rename dari DATABISNIS_API; nama lama masih dibaca utk backward-compat), DASH_LIQUID_TTL, VEX_BP_PRIVATE_KEY, DISTRIBUTE_HOUR, DISTRIBUTE_WEEKDAY (CSV multi-hari V44, default wed,sat), DISTRIBUTE_FIRST_RUN (default baked 2026-08-17), DISTRIBUTE_MAX_ATTEMPTS, DISTRIBUTE_SEND_DELAY (jeda detik ANTAR kirim antar pemilih, default 30, V63), 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 → .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 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.py must 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) and API_NODE (https://v2.vexascan.com:2096) are the defaults in config.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 (not vexio), scope is also vexcore. 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 staked as 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 formula last_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 V40 normalize() stores every BP voter with a derivable last_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 on last_vote age; distribution pays last_vote > now−28d). Maturity (3 days) is measured from first_seen_at, not last_vote — last_vote is week-quantized so a new voter voting Mon–Thu would look 4–6 days old and bypass the wait (B15); V52 uses voter_first_seen.first_seen_at (scan-precise to the second) for the new-voter wait. first_seen_at/scanned_at are stored UTC-naive by the scan (V62/B24) — all age cutoffs compute datetime.now(timezone.utc), so the comparison is apples-to-apples; a local-TZ stamp would hold new voters in BARU ~7h longer (B24). 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. Since V41, voter_first_seen.first_seen_at separates 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 only VEX_REVOTE_TAG_DAYS (1) and VALID rows nearing the KADALUARSA cutoff (within VEX_WARN_DAYS, 3) get a VOTE ULANG SEGERA badge — 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.amount where status='sent' (0,0000 for never-paid), joined per page via a LEFT JOIN subquery. Vote weight stays in the DB and /api/search JSON but is not rendered as a column.
  • Token contract is vex.token (NOT eosio.token — that name doesn't exist on Vexanium), 4-decimal VEX, chain_id f9f432b1851b5c179d2091a96f593aaed50ec7466b74f89301f957a83e56ce1f. Distribution signs vex.token::transfer with the BP active key.
  • 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 {HYPERION_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 run. 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.
  • Claim (spec §V32–V34): the BP reward is claimed once per 24h window via vexcore::claimrewards (owner = BP). The claim service polls every CLAIM_RETRY_SECONDS only near the window (last_claim_time from the vexcore producers table + 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_rows responses are always {"rows":[...]} — parse data.get('rows'), never data[0] (B7, V39). Landed detection = Hyperion executed OR last_claim_time advanced past pre-send value, checked on BOTH the send-success and send-exception paths (_confirm_landed) because pyntelope's send() returns HTTP 500 as a body instead of raising on a chain rejection (V46, B11); Hyperion executed: false on a not-yet-indexed tx still falls back to the last_claim_time advance check (V50, B14). next_window raises if all producer reads fail so a flaky node never triggers a premature claim. Reward = sum of vex.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 → fee pending + internal KLAIM MENDARAT · REWARD TAK TERUKUR alert, never a misleading 0-reward success (B10). 10% fee floored at 4-dec goes to bpdbsjasprod (VEX_BP_FEE_WALLET), memo BP 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 is sent; 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); /history uses a six-column run grid and a four-column payment grid (AKUN/JUMLAH/STATUS/TXID) that shows only the latest run with its date in the title, and links each TXID to https://vexascan.com/transaction/{txid} — no TANGGAL or MEMO column (memo is on-chain only, not stored); mobile turns history rows into labeled cards. HTML responses use Cache-Control: no-store; stylesheet URL is versioned with ?v= for deploy cache-busting.
  • Liquid balance (spec §V16): a 4th stat cell "SALDO LIQUID" shows the BP account's liquid VEX (account.core_liquid_balance) fetched from GET {node}/v2/state/get_account?account=<BP> across the HYPERION_NODES pool (V60 — host mati → cadangan, SALDO tetap live). 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-min + BP target, semua umur vote disimpan V40; hanya last_vote tak terverifikasi dibuang) → db.replace_snapshot (each run replaces the table = daily snapshot, with scanned_at + derived last_vote; never appends history) → db.record_first_seen (V41: catat kemunculan pertama tiap owner ke voter_first_seen, idempoten). V62: scanned_at/first_seen_at disimpan UTC-naive (datetime.now(timezone.utc)) — konsisten dgn SEMUA cutoff umur (dashboard + distribusi) & last_vote (epoch UTC); jangan kembali ke datetime.now() lokal (B24: pemilih BARU tertahan +7 jam).
  • 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. Juga tabel klaim: claim_runs + helper ensure_claim_schema/record_claim/update_claim_fee/pending_claim_fee. Juga tabel V41 voter_first_seen (owner PK + first_seen_at, append-only) + helper ensure_first_seen_schema/record_first_seen — dipakai dashboard membedakan PEMILIH BARU vs REVOTE. Juga helper V57 eligible_voters(owner=None) — single-source SQL jendela reward (payout window, V40/V52): last_vote > now−STALE AND first_seen_at IS NOT NULL AND first_seen_at ≤ now−MATURITY (cutoff UTC-naive); tanpa owner → semua baris (owner, staked) utk distribusi; dgn owner → baris akun itu ([]|one row) utk endpoint cek.
  • get_voters.js — reference implementation only.
  • distribute.py — pembayaran harian: fetch liquid balance → baca pemilih band reward V40/V52 (db.eligible_voters() — V57 single source: last_vote > now−28d AND first_seen_at ≤ now−3d, pemilih baru (first_seen baru/NULL) & basi ⊥ dibayar; akun yang sudah matang dibayar langsung, ⊖ peduli umur vote, V52) → 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; notifikasi Telegram mulai/selesai/gagal via telegram.py (best-effort, ⊥ dry-run/no-op). V58 failover node pool: _post_json coba SEMUA API_NODES per putaran (semua gagal → RuntimeError); build_signed_transfer coba tiap node utk link+sign (net ter-bind → send ikut node sama); fetch_balance_with_retry backoff [30,60,90,120,150,180]s (~10 mnt) di real-run sebelum menyerah ke jadwal berikutnya, dry-run satu attempt. V59 send aman: verify_txid → None (Hyperion down) = TAHAN sungguhan (break + failed di attempt 1, ⊖ resend dgn tapos baru yang bisa ganda). V61 send-path hardening: _send_rejected(resp) WHITELIST — sukses HANYA dict ber-transaction_id + receipt executed/tanpa-status, body HTTP-500 apa pun (pyntelope ⊥ raise, pola V46/B11) → tolak → raise → jalur verify/retry, ⊖ pernah sent utk tx yg ⊥ mendarat; _verify_settled(txid) retry verify_txid 3× dgn delay utk lag indeks (None → False langsung, pool mati semua) — jalur exception send-loop hold (failed+break) pada apa pun selain True, ⊖ RESEND utk executed:false (B21). V60 Hyperion failover: _get_json (GET mirror _post_json, 3 putaran, 2s, coba semua HYPERION_NODES per putaran → semua gagal → None) dipakai verify_txid — None kini berarti SEMUA host Hyperion gagal, jadi hold V59 jarang palsu; semua pembaca /v2/* guard isinstance(data, dict) (non-dict 200 → skip/skip-node/None, ⊖ crash run, B23). V63 jeda antar kirim: send-loop tidur DISTRIBUTE_SEND_DELAY (default 30s) ANTAR pemilih — N pemilih → N−1 tidur (⊥ setelah terakhir), beri node/Hyperion waktu mengejar indeks; real-run saja (dry-run ⊖ tidur). test_distribute.py — oracle (mock chain+pyntelope, temp DB).
  • claim.py — klaim reward BP harian: baca last_claim_time (tabel producers, 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 poll vexcore::claimrewards (sign pyntelope VEX_BP_PRIVATE_KEY) tiap CLAIM_RETRY_SECONDS; deteksi mendarat via _confirm_landed di KEDUA jalur (send sukses & exception) = Hyperion executed ATAU last_claim_time maju dari baseline (V46/B11 — send() pyntelope ⊥ raise saat chain menolak, HTTP 500 sbg body; Hyperion executed: false utk 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 via dist._get_json pool HYPERION_NODES, V60) prioritas — di-RETRY 4× dgn SETTLE_SECONDS (30) beri waktu Hyperion indeks tx baru (V55/B17); selisih saldo fallback HANYA delta positif (after > before), delta 0 ambigu → ⊥ dianggap reward 0; baseline saldo (before_balance) dibaca SEKALI per window & dibawa tiap attempt (V56/B18) — klaim mendarat attempt 1 lalu attempt 2 ditolak (already claimed) ⊖ boleh baca-ulang before yg sudah termasuk reward → delta 0 → alert palsu; reward ⊥ terukur → fee pending + notif KLAIM MENDARAT · REWARD TAK TERUKUR (V33/B10/V55/V56), ⊖ pernah KLAIM REWARD SUKSES 0.0000 palsu; fee 10% (VEX_BP_FEE_PERCENT) floor 4-des → VEX_BP_FEE_WALLET via distribute.build_signed_transfer, memo BP FEE YYYY-MM-DD; fee yang ditolak chain (HTTP-500 body via dist._send_rejected, V61/B20) → failed + retry, ⊖ pernah sent utk fee yg ⊖ mendarat; parse get_table_rows pakai data.get('rows') (bentuk asli dict, ⊥ data[0] — B7/V39); step() = state machine (resume fee → jadwal → poll), source of truth claim_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 (service claim): _wait_db (duplikat scan_loop) lalu claim.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/NFS config.env.
  • 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. Three vote-age bands (V40/V52): the voters list is split into BARU (top, PEMILIH BARU · BELUM MATANG, new accounts by first_seen_at within 3 days) and the main list (voter-list section, .reward cells, V29) where KADALUARSA rows are pinned at the top with amber styling + VOTE ULANG badge + legend before the paged VALID rows, which LEFT-joins a status='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 mint REVOTE tag (legend TAG MINT = REVOTE KURANG DARI 1 HARI · VALID LANGSUNG, shows only while last_vote > now−VEX_REVOTE_TAG_DAYS) — no separate band, no maturity wait; only genuinely new voters sit in BARU tagged BARU; since V43 VALID rows within VEX_WARN_DAYS of the KADALUARSA cutoff get an amber dashed VOTE ULANG SEGERA badge + legend TAG 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); /api/voter/<owner> (V57) returns {"owner", "is_valid_voter"} — payout-eligibility check via the SAME db.eligible_voters(owner) window distribute pays from (never a second SQL copy), DB-snapshot only, always 200; Banner BERITA (V38) shows the next distribution run from DISTRIBUTE_HOUR/DISTRIBUTE_WEEKDAY/DISTRIBUTE_FIRST_RUN + TZ (mirror of distribute_loop.next_boundary, ⊥ impor lintas-image; drift guarded in test_dashboard; the banner clock is injectable via dashboard._now() — the route calls the helper instead of datetime.now() inline so the oracle fixes a date instead of depending on the real wall clock, V64/B25); the SETIAP ... · HH:00 recurrence 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). A stats-note caption states the list is single-vote-to-BP accounts ≥ min stake (V47); account names link to https://vexascan.com/account/{owner} in the index list, /history pay-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 a MATURITY PERIOD countdown 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 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, API_NODES (pool RPC distribusi, CSV VEX_API_NODES), HYPERION_API, HYPERION_NODES (pool Hyperion V60, CSV VEX_HYPERION_NODES, default HYPERION_API + API_NODES dedup), 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, DASH_LIQUID_TTL, BP_PRIVATE_KEY, DISTRIBUTE_HOUR, DISTRIBUTE_MAX_ATTEMPTS, DISTRIBUTE_SEND_DELAY, 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 batas SCAN_HOURS (lokal via TZ), panggil get_voters.main(); skan-awal SCAN_RUN_ON_START + tunggu DB siap; loop tak pernah keluar.
  • distribute_loop.py — scheduler dua-kali-minggu dalam container stack (service distribute): tunggu DISTRIBUTE_WEEKDAY (default wed,sat, CSV multi-hari V44) + DISTRIBUTE_HOUR (lokal via TZ), plus satu one-off DISTRIBUTE_FIRST_RUN (YYYY-MM-DD, default baked 2026-08-17); jadwal dibaca via config (konsisten dgn dashboard V38); panggil distribute.main(), loop tak pernah keluar; gagal dicatat dan dicoba di jadwal berikutnya; ⊥ distribusi-awal saat start (snapshot bisa basi).
  • Dockerfile — image web databisnisid-web (gunicorn dashboard; DASH_HOST=0.0.0.0 di stack agar ingress menjangkaunya). Dockerfile.scan — image databisnisid-scan (scan_loop; sertakan tzdata). Dockerfile.dist — image databisnisid-dist (distribute_loop; sertakan tzdata + pyntelope via requirements). Dockerfile.claim — image databisnisid-claim (claim_loop + claim + distribute untuk fee; sertakan tzdata + pyntelope). Di stack produksi keempatnya di-push ke registry git.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 registry git.proit.id/proitlab/databisnisid-*; mariadb bind mount /data/db/mariadb/databisnisid/data + placement node.hostname == server5.saltis.id; network overlay appnet; distribute & claim bawa VEX_BP_PRIVATE_KEY via bind /mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env:/app/.env:ro (dibaca config.py load_dotenv; ⊥ interpolasi). docker-compose.dev.yml — mariadb uji lokal (127.0.0.1:3306, volume mariadb_data).
  • DESIGN.md — Bugatti austere style guide; the dashboard's CSS maps its tokens (canvas #000000, hairline #262626, weight 400 everywhere, fonts Saira Condensed / EB Garamond / JetBrains Mono).
  • 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: %s placeholders (never ?), ESCAPE '!' for LIKE (backslash breaks MySQL string literals), no MySQL-only syntax.