fix(voting): registered Terra CL8Y holders shown as 0 after live snapshot #9
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Live QA on
https://vote.cl8y.com(Terra Classic, mobile in-app browser): a holder of CL8Y-cb (wallet ticker for the pinned columbus-5 CW20terra16wtml2q66g82fdkx66tap0qjkahqwp4lwq3ngtygacg5q0kzycgqvhpax3unless a different contract is proven) sees 0 CL8Y on the proposals list and on New proposal (Balance 0 CL8Y (need 1000)), both before register and after the UI shows Registered. That blocks the ≥1000 CL8Y propose gate even when the wallet holds well over the threshold.Related: #1 (ledger), #2 (
operator-voting), #3 (dApp), #8 (Legal/vote404 — same session, separate root cause), #7 (live QA / LCD equality).Do not “fix” this by reading CW20
Balancein the browser (VITE_*LCD/RPC for balances is forbidden). Server ledger remains source of truth.Current codebase
Ledger (L1 / L6 / L11)
POST /v1/register→voting.registration_intents.voting-ledgerprocess_pending_intentstakes a live LCDBalance+ height (ledger/src/register.rs,lcd.rscw20_balance) and writesvoting_registrations.initial_balance. No archive node. No backfill.public.voting_cl8y_balance_at(wallet, H)returns 0 if the wallet is unregistered orH < registered_at_height. Otherwise latestcl8y_balancescheckpoint ≤ H, elseinitial_balance.ledger/src/main.rs): iflast_indexed_height == 0, jump to LCD tip; else ingestlast+1..=tip. Cursor lives inindexer_state.last_indexed_height(migrated as'0').operator-voting
GET /v1/balances/:addr?chain=&height=(operator-voting/src/api.rsget_balance) usesindexer_statetip whenheightis omitted. It does not clamp toregistered_at_height. If tip is0or still below the live height stored at register, L6 yields 0 even wheninitial_balanceis large.POST /v1/proposalsalready doesheight = tip > 0 ? tip : reg.registered_at_heightand freezesterra_h.max(reg.registered_at_height). The read path used by the UI does not.NOT FOUND → 0(200 JSON), not 404. Same as “really zero.”POST /v1/registerreturnspending: trueuntil the ledger row exists.GET /v1/registration/:addr404s until then.Frontend
VoteListPage/VoteNewPagedisplayformatCl8y(getBalance().balance)as “CL8Y”. Catch on error setsbalancenull (hidden), so a literal"0"is a successful API read.registered=trueand does not refetch balance. Other pages refetch on mount — reporters still saw 0, so this is not only stale React state.pending. A user can look “Registered” whilevoting_registrationsis empty (until refresh, GET would 404). Combined with balances=0, the UI says registered-and-broke.Token identity
CL8Y_TOKEN_ADDRESS/ HANDOFF hpax3. DEX copy: ticker CL8Y-cb is the wallet label for that contract unless another contract is proven. Do not key off the stringCL8YvsCL8Y-cb.Why this is needed
The dApp tells holders to register the address that holds CL8Y, then uses
GET /v1/balancesfor eligibility copy and the 1000 CL8Y propose gate. If that read is 0 while:voting_registrations.initial_balancealready stored the live LCD amount,then registration looks broken, propose is disabled, and live QA (#7) cannot pass. Showing 0 CL8Y before registration is also misleading: the ledger is defined to be 0 until a live snapshot exists.
Likely in-tree defects to prove/fix together (do not split):
max(tip, registered_at_height)(and without a fallback when tip is 0) is inconsistent with propose and with L6.last_indexed_heightuntil ingest catches up; during that window every GET is 0. If ingest never advances (LCDblock_txsfailing, poller down), the window is forever.CL8Y_TOKEN_ADDRESSandTERRA_LCD_URLon Coolify must be columbus-5 canonical CL8Y; LCD equality (docs/OPS.md§5) must hold for a registered wallet.Constraints / guardrails
balanceOfat register + transfers forward only.VITE_*BSC RPC (or Terra LCD) in the browser for CL8Y balance reads. Indexer ownseth_call/eth_getLogs/ CW20Balance.balanceOffails, registration retries; do not write 0 as a fake success.terra1…and0x…. Do not sum cross-chain.contract_address(L3) stay ignored.operator-votingstays on the restricted role:EXECUTEpublic.voting_*_balance_atonly; no writer on ingest tables (L10).Relevant files
operator-voting/src/api.rs(get_balance,create_proposal,register)operator-voting/src/db.rs(balance_at,tip_heights,ledger_registration)ledger/migrations/20260825000001_ledger.sql(voting_cl8y_balance_at)ledger/src/register.rs,ledger/src/lcd.rs,ledger/src/main.rs,ledger/src/ingest.rs,ledger/src/db.rsfrontend/src/pages/VoteListPage.tsx,VoteNewPage.tsxfrontend/src/services/operatorVoting.tsfrontend/src/utils/format.ts,votingPayload.ts(canPropose)docs/LEDGER_INVARIANTS.md(L1, L6, L11),docs/OPERATOR_VOTING.md,docs/OPS.md§5operator-voting/tests/api_flow.rs,ledger/tests/ledger_integration.rs,frontend/src/pages/VoteNewPage.test.tsx,frontend/e2e/vote.spec.tsRecommended direction
/v1/balances: For a registered wallet, evaluate atmax(requested_or_tip, registered_at_height)(same idea as propose freeze). If tip is 0, useregistered_at_height, not 0. Optional: includeregistered,pending,initial_balance,as_of_heightin the JSON so the UI does not guess.0as “this wallet holds 0 CL8Y.” Prefer 404 orregistered: false/pending: trueand UI copy: “Register to snapshot this address’s CL8Y” without a fake zero.pending. PollGET /v1/registration/:addr+ balances until the ledger row exists or timeout with a retry message. Refetch balance after the snapshot lands. Do not set Registered from POSTokalone.>= registered_at_height(same poll or explicitly). Iflast_indexed_heightstays 0,/healthand balances must not look “fine.” Log LCD ingest failures; do not mark intents processed when live balance failed (already retried — keep that).Balancevsvoting_registrations.initial_balancevsGET /v1/balancesat tip. ConfirmCL8Y_TOKEN_ADDRESSis hpax3. CL8Y-cb ticker ≠ different contract without proof.eth_call/ LCD smart query for the badge.Acceptance criteria
voting_registrationsexists.voting_bsc_cl8y_balance_at(do not Terra-only-fix).GET /v1/balances?height=belowregistered_at_heightstill returns 0 (L6 preserved for historical queries). Default/omitted height used by the dApp does not hit that trap for a just-registered wallet.INSERTledger ingest tables.Test plan (all paths)
API / ledger
GET /v1/balances/terra1…— documented status/body; frontend does not render a fake live 0.initial_balance = 3540 * 10^18,registered_at_height = 100,last_indexed_height = 0→ default GET balance is not 0 (this currently fails).last_indexed_height = 50(< 100) → default GET used by UI is not 0; explicit?height=50stays 0 (L6).last_indexed_height = 120, no checkpoints → balance equalsinitial_balance.last_indexed_bsc_blockvsregistered_at_height.terra1/0xaddress — reject or infer consistently; no cross-chain sum.Frontend
VoteNewPage.test.tsx).Ops / live (required to close)
docs/OPS.md§5).GET /healthon ledger shows advancingterra_heightafter register.Automation
cargo test --workspace --libandLEDGER_TEST_DATABASE_URL=… cargo test --workspace --testscovering the tip<registered case.cd frontend && npm test && npm run test:e2e.Test plan (attack, hack, abuse)
public.voting_*_balance_at.?height=tampering: Attacker cannot use a future height to mint weight (no checkpoints beyond indexed tip; function must not invent). Past height below register stays 0. Propose/vote still use frozen snapshot, not a user-chosen height._contract_addressfor the pin are ignored (L3). Register live query uses pin only — a wallet full of other tickers stays 0 (correct).RUN_MODE=prod.ON CONFLICT DO NOTHING; no doubleinitial_balance.cl8y_balances/ transfers /voting_registrations.x-cosmos-block-heighthistorical spoof.Verification criteria
vote.cl8y.com, a ledger-registered Terra holder with ≥1000 pinned CW20 CL8Y sees a non-zero formatted balance on list and/vote/new, and the propose gate matches the API.initial_balance≈ live LCDBalance≈GET /v1/balancesdefault as_of height ≥registered_at_height.GET /v1/balancesis still"0"while LCD is not.mentioned in issue #7
mentioned in merge request !20
In-tree fix is in !20 (
fix/9-registered-balance-zero).Default GET
/v1/balancesnow usesmax(indexer tip, registered_at_height)on Terra and BSC so a live snapshot is not L6-zeroed whilelast_indexed_*lags. Pending register no longer shows Registered+0. Explicit?height=below register still returns 0.Unit, Postgres integration, Vitest, and Playwright passed in-tree. Live LCD equality on vote.cl8y.com (and BSC O7) still required before closing this issue — do not close on a Registered screenshot if GET
/v1/balancesis"0"while LCD is not.mentioned in commit
4f18713f5e!20 is merged to
mainafter resolving conflicts with !19 (canonicalROUTES+ OV-B1 snapshot UX). No automerge; did not wait for CI. GitLab pipeline on !20 failed withci_quota_exceeded(jobs never started). Local sanity on the conflict-resolved branch:cargo test --workspace --lib46, frontend unit 55, Playwright 9/9 (5 workers).In-tree (this does not close the issue): default
GET /v1/balancesismax(tip, registered_at_height)on Terra and BSC (OV-B1); explicit?height=below register is still 0 (L6); unregistered/pending is not rendered as a live 0; restricted role grants/tests unchanged.Still required before close:
vote.cl8y.comTerra holder (pinned hpax3 / CL8Y-cb): LCD Balance ≈voting_registrations.initial_balance≈ default GET/v1/balances(non-zero on list +/new, propose gate matches the API)/healthcaught_up/ advancingterra_height(treatokas liveness only)CL8Y_TOKEN_ADDRESSis hpax3Follow-up (non-blocking for the clamp): pending Registering… only polls for 30s inside
completeRegister. On timeout the Register CTA stays hidden; a reload while the intent is still pending does not keep polling. Worth a small UX fix if live register ever takes longer than the ledger poll interval.Do not close on a “Registered” screenshot if GET is still
"0"while LCD is not.Reopened. Merging !20 auto-closed this issue because the MR text contained the phrase “close #9” (inside “does not close”). That is a GitLab closing-pattern false positive, not a live-QA pass.
In-tree clamp/UX from !20 stays on
main. Close only after:voting_registrations.initial_balance≈ default GET/v1/balances(non-zero on list +/new)/healthcaught_up/ advancingterra_heightCL8Y_TOKEN_ADDRESSis hpax3Do not close on a “Registered” screenshot if GET is still
"0"while LCD is not.mentioned in issue #13
mentioned in issue #14
marked as related to #14
Live follow-up (no wallet identifiers): after the in-tree #9 UX, a connected Terra Classic session on vote.cl8y.com no longer shows 0 CL8Y and also shows no amount. The list stays on Registering… and the banner
Registration snapshot is still pending. Wait for the ledger, then retry.That is the 30s
waitForLedgerRegistrationtimeout plus a missing ledger row, not a regression of the OV-B1 clamp. Tracked as #14. Do not close this issue on that screenshot — #9 still needs LCD equality after the wallet is ledger-registered.mentioned in merge request !24
mentioned in merge request !25
mentioned in commit
d6b4e01acdmentioned in issue #15
marked as related to #15
Reopened. Merging !25 auto-closed this because the MR text said “do not close #9 instead of this issue.” GitLab treats
close #9as a closing pattern. Remaining AC is live LCD/balanceOfequality after registered — still blocked on #14 writer health and #15 redeploy.Verify pass (2026-09-01) — in-tree only; this issue stays open
Pushed
d7f4d51tomain.In-tree (pass)
/v1/balancesismax(tip, registered_at_height)on Terra and BSC; explicit?height=below register is still 0 (L6)./v1/balancesisregistered: false,balance: "0", with OV-B6 keys. UI must not show that 0 as a chain holding.Live (remaining AC) — blocked on writer health (issue 14)
https://ledger.vote.cl8y.com/healthis proxy 503. Cannot prove LCDBalance≈initial_balance≈ default GET after registered.CL8Y_TOKEN_ADDRESShpax3 not confirmed from this agent.Checklist
vote.cl8y.comTerra holder (pinned hpax3 / CL8Y-cb): LCD Balance ≈voting_registrations.initial_balance≈ default GET/v1/balances(non-zero on list +/newwhen LCD is not 0)/healthcaught_up/ advancingterra_height(okis liveness only)CL8Y_TOKEN_ADDRESSis hpax3Stays open: do not treat a “Registered” screenshot as done if GET is still
"0"while LCD is not. Do not treat issue 14’s pending trap as this issue.Closing on OV-B1 in-tree + live unregistered contract
Default GET
/v1/balancesclamps tomax(tip, registered_at_height)on Terra and BSC; unregistered/pending is not a live 0; live JSON has OV-B6 keys.LCD equality after a registered holder (and BSC O7) still needs a healthy writer. Public ledger
/healthis proxy 503. That leftover stays on issue 7 — do not treat a Registered screenshot as LCD proof.