From 344de409cdb0e56cbc8b9daee14fb251627cde93 Mon Sep 17 00:00:00 2001 From: dsutanto Date: Wed, 5 Aug 2026 20:37:50 +0700 Subject: [PATCH] history: payments hanya run terbaru, TANGGAL pindah ke judul, MEMO dihapus (DB+UI) --- AGENTS.md | 4 ++-- SPEC.md | 9 ++++---- dashboard.py | 9 ++++++-- db.py | 47 +++++++++++++++++++++++++++++++----------- distribute.py | 2 +- static/style.css | 5 ++++- templates/history.html | 12 ++++------- templates/index.html | 2 +- test_dashboard.py | 15 +++++++++++--- test_distribute.py | 19 ++++++++++------- 10 files changed, 83 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ce8bed..cebce10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - 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. -- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/VOTE equal width with AKUN left and STAKE/VOTE centered; tablet keeps the fixed STAKE column near page center; `/history` uses six-column run/payment grids; mobile turns history rows into labeled cards, including MEMO and TXID. HTML responses use `Cache-Control: no-store`; stylesheet URL is versioned with `?v=` for deploy cache-busting. +- Dashboard layout (spec §V28): desktop gives AKUN/STAKE/VOTE equal width with AKUN left and STAKE/VOTE centered; tablet keeps the fixed STAKE column near page center; `/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 — 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 {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 @@ -39,7 +39,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote - `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. - `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; `run-list`/`pay-list` use six-column desktop/tablet grids and labeled mobile cards. +- `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; `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`, `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. diff --git a/SPEC.md b/SPEC.md index cff994a..3dc96a5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -52,9 +52,9 @@ api: POST `https://v2.vexascan.com:2096/v1/chain/push_transaction` (signed via p 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 +db: table `distribute_payments` (payment_id PK, run_id FK, owner, amount, txid, status[pending|sent|failed], error, created_at, updated_at) via `db.py`; append; ⊖ kolom memo (V23 hanya on-chain) 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; 6-kolom desktop/tablet, kartu berlabel mobile +web: GET `/history` → HTML runs summary + per-voter log, read-only, gaya DESIGN.md; runs 6-kolom, payments 4-kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru + tanggal di judul, kartu berlabel mobile env: `VEX_BP_PRIVATE_KEY` (secret swarm / env dev), `DISTRIBUTE_HOUR` (default 10), `DISTRIBUTE_MAX_ATTEMPTS` — via `config.py` (`.env`) ## §V — Invariants @@ -79,12 +79,12 @@ V19: target transfer = kontrak `vex.token` (konstanta chain hardcoded; ⊥ diuba 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 +V23: memo per run = `DATABISNISID PROFIT SHARE YYYY-MM-DD` — sama utk seluruh transfer run, hanya on-chain (⊖ disimpan DB) 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 -V28: web list desktop → kolom AKUN/STAKE/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE & VOTE TERAKHIR center; tablet grid lama; history runs/payments 6 kolom desktop/tablet; mobile → kartu berlabel ∀ field termasuk MEMO/TXID; HTML `no-store` + stylesheet versioned +V28: web list desktop → kolom AKUN/STAKE/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE & VOTE TERAKHIR center; tablet grid lama; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML `no-store` + stylesheet versioned ## §T — Tasks id|status|task|cites @@ -118,6 +118,7 @@ T27|x|tests: test_distribute.py (mock chain+pyntelope, temp DB: math share, memo T28|x|hygiene: .gitignore += `.oc/`; stage penghapusan README.md (stub kosong)| T29|x|dashboard layout: grid STAKE seimbang desktop/tablet; `/history` 6-kolom + kartu mobile berlabel; CSS cache-bust + HTML no-store|V28,I.web T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE center|V28,I.web +T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web ## §B — Bug log id|date|cause|fix diff --git a/dashboard.py b/dashboard.py index 22a48ca..09d19f6 100644 --- a/dashboard.py +++ b/dashboard.py @@ -208,10 +208,15 @@ def api_search(): @app.get('/history') def history(): - """V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih.""" + """V27: riwayat distribusi profit-share (read-only) — runs + per-pemilih. + + Payments hanya run terbaru (V28); tanggal run terbaru ditampilkan di judul. + """ runs = db.list_runs() - payments = db.list_payments() + latest = runs[0][0] if runs else None + payments = db.list_payments(run_id=latest) return render_template('history.html', runs=runs, payments=payments, + latest_date=runs[0][1] if runs else None, bp=TARGET_BP) diff --git a/db.py b/db.py index fc1a088..72900a5 100644 --- a/db.py +++ b/db.py @@ -116,7 +116,6 @@ _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, ' @@ -141,11 +140,32 @@ def ensure_distribute_schema(): else: cur.execute(_RUN_DDL_MYSQL) cur.execute(_PAY_DDL_MYSQL) + _drop_pay_memo(cur) conn.commit() finally: conn.close() +def _drop_pay_memo(cur): + """Migrasi: buang kolom memo lama dari distribute_payments (idempoten). + + Kolom `memo` pernah disimpan (V23) lalu dihapus — DB produksi yang sudah + punya kolom perlu di-alter; DB baru langsung tanpa kolom. + """ + if DB_BACKEND == 'sqlite': + cur.execute( + "SELECT name FROM pragma_table_info('distribute_payments')") + cols = {row[0] for row in cur.fetchall()} + else: + cur.execute( + "SELECT COLUMN_NAME FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + " AND TABLE_NAME = 'distribute_payments'") + cols = {row[0] for row in cur.fetchall()} + if 'memo' in cols: + cur.execute(_translate('ALTER TABLE distribute_payments DROP COLUMN memo')) + + def record_run(run_date, balance_start, total_voters, total_staked, total_sent, status, created_at): """Insert baris `distribute_runs`, kembalikan run_id.""" @@ -165,7 +185,7 @@ def record_run(run_date, balance_start, total_voters, total_staked, conn.close() -def record_payment(run_id, owner, amount, memo, status, created_at, +def record_payment(run_id, owner, amount, status, created_at, txid=None, error=None): """Insert baris `distribute_payments` → payment_id.""" ensure_distribute_schema() @@ -174,9 +194,9 @@ def record_payment(run_id, owner, amount, memo, status, created_at, 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, + '(run_id, owner, amount, txid, status, error, created_at, ' + ' updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)'), + (run_id, owner, amount, txid, status, error, created_at, created_at)) conn.commit() return cur.lastrowid @@ -224,11 +244,14 @@ def list_runs(): 'FROM distribute_runs ORDER BY run_id DESC') -def list_payments(): - """Transfer per-pemilih, terbaru dulu (gabung run untuk tanggal).""" +def list_payments(run_id=None): + """Transfer per-pemilih, terbaru dulu; opsional filter satu run.""" 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') + sql = ('SELECT payment_id, run_id, owner, amount, txid, status, error, ' + ' created_at, updated_at FROM distribute_payments') + params = () + if run_id is not None: + sql += ' WHERE run_id = %s' + params = (run_id,) + sql += ' ORDER BY payment_id DESC' + return query(sql, params) diff --git a/distribute.py b/distribute.py index fa66428..6c8675b 100644 --- a/distribute.py +++ b/distribute.py @@ -160,7 +160,7 @@ def run_distribution(dry_run=False): 0.0, 'ok', created_at) pending = [] for owner, share in shares: - pid = db.record_payment(run_id, owner, float(share), memo, 'pending', + pid = db.record_payment(run_id, owner, float(share), 'pending', created_at) pending.append((pid, owner, share)) diff --git a/static/style.css b/static/style.css index 117a992..35917be 100644 --- a/static/style.css +++ b/static/style.css @@ -193,7 +193,7 @@ button.pill { } .pay-list .list-header, .pay-list .list-row { - grid-template-columns: 1fr 140px 170px 100px 1.2fr 260px; + grid-template-columns: 1fr 170px 110px 280px; } .list-header { font-family: var(--font-mono); @@ -229,6 +229,9 @@ button.pill { .list-row .vote-date { text-align: center; } .list-header span:nth-child(3), .list-header span:nth-child(4) { text-align: center; } +.pay-list .list-header span:nth-child(2), +.pay-list .list-header span:nth-child(3) { text-align: center; } +.pay-list .list-header span:nth-child(4) { text-align: left; } .txid { font-size: 12px; letter-spacing: 1px; diff --git a/templates/history.html b/templates/history.html index fe33d89..1d71206 100644 --- a/templates/history.html +++ b/templates/history.html @@ -7,7 +7,7 @@ - +
@@ -59,26 +59,22 @@ -

TRANSFER PER PEMILIH

+

TRANSFER PER PEMILIH{% if latest_date %} · {{ latest_date }}{% endif %}

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 '—' }} + {{ pay[5]|upper }} + {{ pay[4] or '—' }}
{% else %}
BELUM ADA TRANSFER
diff --git a/templates/index.html b/templates/index.html index 9fbb7e8..6ef0bda 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,7 +7,7 @@ - +
diff --git a/test_dashboard.py b/test_dashboard.py index b92bc75..8cc7603 100644 --- a/test_dashboard.py +++ b/test_dashboard.py @@ -55,12 +55,17 @@ def main(): dashboard._get_liquid_vex = lambda: 50691.4676 # suite utama ⊥ jaringan nyata # ————— V27: seed riwayat distribusi utk uji /history ————— + # run lama di-seed duluan agar run_id-nya lebih kecil (⊥ run terbaru). today = datetime.now().strftime('%Y-%m-%d') + yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d') dbm.ensure_distribute_schema() + old_rid = dbm.record_run(yesterday, 50000.0, 1, 1000.0, 80.0, 'ok', + '2026-08-04T10:00:00') + dbm.record_payment(old_rid, 'oldacct', 40.0, 'sent', + '2026-08-04T10:00:01', txid='txold999') 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', + dbm.record_payment(rid, 'acct001', 50.0, 'sent', '2026-08-05T10:00:01', txid='txabc123') client = dashboard.app.test_client() @@ -104,9 +109,13 @@ def main(): 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 tanggal di judul TRANSFER PER PEMILIH', + f'TRANSFER PER PEMILIH · {today}' in html) check('V27 transfer per-pemilih tampil', 'acct001' in html) + check('V27 hanya run terbaru (transfer lama tak tampil)', + 'oldacct' not in html) check('V27 status SENT tampil', 'SENT' in html) - check('V27 memo tercatat', f'DATABISNISID PROFIT SHARE {today}' in html) + check('V27 memo tak dirender', f'DATABISNISID PROFIT SHARE {today}' not in html) check('V27 txid tercatat', 'txabc123' in html) # ————— V11: search server-side ?q= (substring, case-insensitive) ————— diff --git a/test_distribute.py b/test_distribute.py index 6c4e00c..3f7ed13 100644 --- a/test_distribute.py +++ b/test_distribute.py @@ -150,9 +150,9 @@ def main(): calls['runs'].append((run_date, status)) return 7 - def fake_record_payment(run_id, owner, amount, memo, status, created, + def fake_record_payment(run_id, owner, amount, status, created, txid=None, error=None): - calls['pays'].append((owner, amount, memo, status)) + calls['pays'].append((owner, amount, status)) return len(calls['pays']) def fake_update(pid, status, txid=None, error=None, updated_at=None): @@ -165,7 +165,13 @@ def main(): 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) + signed = [] + + def fake_sign(to, amount, memo): + signed.append(memo) + return FakeSigned('tx' + to) + + dist.build_signed_transfer = fake_sign buf = io.StringIO() with redirect_stdout(buf): rc = dist.run_distribution(dry_run=False) @@ -173,11 +179,10 @@ def main(): 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) + len([p for p in calls['pays'] if p[2] == '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('V23 memo on-chain per run', + all(m == f'DATABISNISID PROFIT SHARE {today}' for m in signed)) 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',