fix(voting): registered Terra CL8Y holders shown as 0 after live snapshot #9

Closed
opened 2026-08-29 14:16:35 +00:00 by PlasticDigits · 18 comments
PlasticDigits commented 2026-08-29 14:16:35 +00:00 (Migrated from gitlab.com)

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 CW20 terra16wtml2q66g82fdkx66tap0qjkahqwp4lwq3ngtygacg5q0kzycgqvhpax3 unless 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 /vote 404 — same session, separate root cause), #7 (live QA / LCD equality).

Do not “fix” this by reading CW20 Balance in the browser (VITE_* LCD/RPC for balances is forbidden). Server ledger remains source of truth.

Current codebase

Ledger (L1 / L6 / L11)

  • Registration is an intent: POST /v1/register → voting.registration_intents. voting-ledger process_pending_intents takes a live LCD Balance + height (ledger/src/register.rs, lcd.rs cw20_balance) and writes voting_registrations.initial_balance. No archive node. No backfill.
  • public.voting_cl8y_balance_at(wallet, H) returns 0 if the wallet is unregistered or H < registered_at_height. Otherwise latest cl8y_balances checkpoint ≤ H, else initial_balance.
  • Terra poller (ledger/src/main.rs): if last_indexed_height == 0, jump to LCD tip; else ingest last+1..=tip. Cursor lives in indexer_state.last_indexed_height (migrated as '0').

operator-voting

  • GET /v1/balances/:addr?chain=&height= (operator-voting/src/api.rs get_balance) uses indexer_state tip when height is omitted. It does not clamp to registered_at_height. If tip is 0 or still below the live height stored at register, L6 yields 0 even when initial_balance is large.
  • POST /v1/proposals already does height = tip > 0 ? tip : reg.registered_at_height and freezes terra_h.max(reg.registered_at_height). The read path used by the UI does not.
  • Unregistered addresses: SQL NOT FOUND → 0 (200 JSON), not 404. Same as “really zero.”
  • POST /v1/register returns pending: true until the ledger row exists. GET /v1/registration/:addr 404s until then.

Frontend

  • VoteListPage / VoteNewPage display formatCl8y(getBalance().balance) as “CL8Y”. Catch on error sets balance null (hidden), so a literal "0" is a successful API read.
  • After Register, list page sets registered=true and does not refetch balance. Other pages refetch on mount — reporters still saw 0, so this is not only stale React state.
  • Register success ignores pending. A user can look “Registered” while voting_registrations is empty (until refresh, GET would 404). Combined with balances=0, the UI says registered-and-broke.
  • No distinction: not registered vs snapshot pending vs indexer lag vs true zero vs LCD/token mismatch.

Token identity

  • Canonical Terra CW20 is 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 string CL8Y vs CL8Y-cb.

Why this is needed

The dApp tells holders to register the address that holds CL8Y, then uses GET /v1/balances for eligibility copy and the 1000 CL8Y propose gate. If that read is 0 while:

  • the wallet’s CW20 balance is thousands of units, and/or
  • voting_registrations.initial_balance already 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):

  1. Balance read height — query at indexer tip without max(tip, registered_at_height) (and without a fallback when tip is 0) is inconsistent with propose and with L6.
  2. Indexer cursor vs register height — live LCD height at register can sit ahead of last_indexed_height until ingest catches up; during that window every GET is 0. If ingest never advances (LCD block_txs failing, poller down), the window is forever.
  3. UX / API semantics — 0 for “unknown wallet” vs “pending intent” vs “snapshotted zero”; UI must not claim a live wallet balance it does not have.
  4. Ops confirmation — CL8Y_TOKEN_ADDRESS and TERRA_LCD_URL on Coolify must be columbus-5 canonical CL8Y; LCD equality (docs/OPS.md §5) must hold for a registered wallet.

Constraints / guardrails

  1. No new smart contracts. No archive node. Live LCD/balanceOf at register + transfers forward only.
  2. No VITE_* BSC RPC (or Terra LCD) in the browser for CL8Y balance reads. Indexer owns eth_call / eth_getLogs / CW20 Balance.
  3. Do not invent amounts. If LCD/balanceOf fails, registration retries; do not write 0 as a fake success.
  4. Identity v1: one address, one voter. Never merge terra1… and 0x…. Do not sum cross-chain.
  5. Only the pinned CW20 / BEP-20. Lookalike tickers and forged contract_address (L3) stay ignored.
  6. operator-voting stays on the restricted role: EXECUTE public.voting_*_balance_at only; no writer on ingest tables (L10).
  7. L9 flash-in remains counted unless product later adds lockup — do not “fix” 0 by ignoring post-register transfers.
  8. Do not ask for seeds. Do not use fee-discount / LP / DEX positions as vote weight.

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.rs
  • frontend/src/pages/VoteListPage.tsx, VoteNewPage.tsx
  • frontend/src/services/operatorVoting.ts
  • frontend/src/utils/format.ts, votingPayload.ts (canPropose)
  • docs/LEDGER_INVARIANTS.md (L1, L6, L11), docs/OPERATOR_VOTING.md, docs/OPS.md §5
  • Tests: operator-voting/tests/api_flow.rs, ledger/tests/ledger_integration.rs, frontend/src/pages/VoteNewPage.test.tsx, frontend/e2e/vote.spec.ts
  1. GET /v1/balances: For a registered wallet, evaluate at max(requested_or_tip, registered_at_height) (same idea as propose freeze). If tip is 0, use registered_at_height, not 0. Optional: include registered, pending, initial_balance, as_of_height in the JSON so the UI does not guess.
  2. Unregistered / pending: Do not present ledger 0 as “this wallet holds 0 CL8Y.” Prefer 404 or registered: false / pending: true and UI copy: “Register to snapshot this address’s CL8Y” without a fake zero.
  3. Register UX: Honor pending. Poll GET /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 POST ok alone.
  4. Poller: After a successful Terra register, ingest must reach >= registered_at_height (same poll or explicitly). If last_indexed_height stays 0, /health and balances must not look “fine.” Log LCD ingest failures; do not mark intents processed when live balance failed (already retried — keep that).
  5. Ops: On staging, for a known holder: LCD Balance vs voting_registrations.initial_balance vs GET /v1/balances at tip. Confirm CL8Y_TOKEN_ADDRESS is hpax3. CL8Y-cb ticker ≠ different contract without proof.
  6. Still no browser eth_call / LCD smart query for the badge.

Acceptance criteria

  • A Terra address whose live CW20 CL8Y (pinned contract) is ≥1000 human units, once ledger-registered, shows that order of magnitude on list + new proposal (18-decimal format), not 0.
  • Propose CTA enables at the 1000 boundary using the same server figure (server remains source of truth).
  • Unregistered connected wallets do not look like “Connected · 0 CL8Y” as if the chain were queried.
  • Pending intent: UI shows registering/pending, not Registered+0, until voting_registrations exists.
  • BSC path: same height/block clamp for voting_bsc_cl8y_balance_at (do not Terra-only-fix).
  • GET /v1/balances?height= below registered_at_height still returns 0 (L6 preserved for historical queries). Default/omitted height used by the dApp does not hit that trap for a just-registered wallet.
  • Restricted role still cannot INSERT ledger ingest tables.

Test plan (all paths)

API / ledger

  1. Unregistered GET /v1/balances/terra1… — documented status/body; frontend does not render a fake live 0.
  2. Insert registration initial_balance = 3540 * 10^18, registered_at_height = 100, last_indexed_height = 0 → default GET balance is not 0 (this currently fails).
  3. Same row, last_indexed_height = 50 (< 100) → default GET used by UI is not 0; explicit ?height=50 stays 0 (L6).
  4. last_indexed_height = 120, no checkpoints → balance equals initial_balance.
  5. Transfer after register updates checkpoint; GET at tip follows L6 inclusive height.
  6. Intent processed only after live LCD success; LCD failure retries, no invented 0 row.
  7. Idempotent re-register does not double-credit (L11).
  8. BSC analogue: last_indexed_bsc_block vs registered_at_height.
  9. Wrong chain query param on a terra1 / 0x address — reject or infer consistently; no cross-chain sum.
  10. Propose still requires ≥1000 on that address’s chain; blacklist still denied.

Frontend

  1. Connected unregistered: no “0 CL8Y” as wallet truth; Register enabled.
  2. After sign, pending then Registered; balance appears when GET says non-zero (or explicit pending copy).
  3. New proposal: gated below 1000, enabled at 1000 raw (VoteNewPage.test.tsx).
  4. Error from API: no silent 0; show error, keep fail-closed on propose.
  5. Terra and BSC labels; never merge two addresses.

Ops / live (required to close)

  1. Real Terra wallet with CL8Y-cb on the pinned CW20: LCD equality vs ledger vs UI (docs/OPS.md §5).
  2. Repeat BSC 56 with BEP-20 CL8Y (#7 O7).
  3. GET /health on ledger shows advancing terra_height after register.

Automation

  1. cargo test --workspace --lib and LEDGER_TEST_DATABASE_URL=… cargo test --workspace --tests covering the tip<registered case.
  2. cd frontend && npm test && npm run test:e2e.

Test plan (attack, hack, abuse)

  1. Client-supplied balance: UI/query param must not raise vote weight or bypass 1000 CL8Y. Only public.voting_*_balance_at.
  2. ?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.
  3. Unregistered probing: Enumeration of balances is OK if public; do not leak other tables. Do not return LCD errors with internals.
  4. Lookalike CW20: Events without wasmd _contract_address for the pin are ignored (L3). Register live query uses pin only — a wallet full of other tickers stays 0 (correct).
  5. Wrong LCD / testnet: Prod must not snapshot LocalTerra TCL8Y as mainnet power. Fail closed if LCD missing in RUN_MODE=prod.
  6. Replay / cross-purpose sig: Register blob cannot propose/vote (existing domain separation).
  7. Race / double intent: ON CONFLICT DO NOTHING; no double initial_balance.
  8. Restricted role: operator-voting cannot write cl8y_balances / transfers / voting_registrations.
  9. Flash-in (L9): large transfer into a registered wallet before snapshot is weight — document, do not treat as a bug unless product adds lockup.
  10. Cross-chain merge: Terra and BSC rows stay distinct. UI must not add them.
  11. Archive header: still no x-cosmos-block-height historical spoof.

Verification criteria

  • On 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.
  • Staging query: initial_balance ≈ live LCD Balance ≈ GET /v1/balances default as_of height ≥ registered_at_height.
  • Unregistered connected state does not look like a confirmed zero holding.
  • Do not close on a screenshot of “Registered” if GET /v1/balances is still "0" while LCD is not.
## 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 CW20 `terra16wtml2q66g82fdkx66tap0qjkahqwp4lwq3ngtygacg5q0kzycgqvhpax3` unless 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](https://gitlab.com/PlasticDigits/voting/-/issues/1) (ledger), [#2](https://gitlab.com/PlasticDigits/voting/-/issues/2) (`operator-voting`), [#3](https://gitlab.com/PlasticDigits/voting/-/issues/3) (dApp), [#8](https://gitlab.com/PlasticDigits/voting/-/issues/8) (Legal `/vote` 404 — same session, separate root cause), [#7](https://gitlab.com/PlasticDigits/voting/-/issues/7) (live QA / LCD equality). Do **not** “fix” this by reading CW20 `Balance` in the browser (`VITE_*` LCD/RPC for balances is forbidden). Server ledger remains source of truth. ## Current codebase **Ledger (L1 / L6 / L11)** - Registration is an intent: `POST /v1/register` → `voting.registration_intents`. `voting-ledger` `process_pending_intents` takes a **live** LCD `Balance` + height (`ledger/src/register.rs`, `lcd.rs` `cw20_balance`) and writes `voting_registrations.initial_balance`. No archive node. No backfill. - `public.voting_cl8y_balance_at(wallet, H)` returns **0** if the wallet is unregistered **or** `H < registered_at_height`. Otherwise latest `cl8y_balances` checkpoint ≤ H, else `initial_balance`. - Terra poller (`ledger/src/main.rs`): if `last_indexed_height == 0`, jump to LCD tip; else ingest `last+1..=tip`. Cursor lives in `indexer_state.last_indexed_height` (migrated as `'0'`). **operator-voting** - `GET /v1/balances/:addr?chain=&height=` (`operator-voting/src/api.rs` `get_balance`) uses `indexer_state` tip when `height` is omitted. **It does not clamp to `registered_at_height`.** If tip is `0` or still **below** the live height stored at register, L6 yields **0** even when `initial_balance` is large. - `POST /v1/proposals` already does `height = tip > 0 ? tip : reg.registered_at_height` and freezes `terra_h.max(reg.registered_at_height)`. The **read** path used by the UI does not. - Unregistered addresses: SQL `NOT FOUND → 0` (200 JSON), not 404. Same as “really zero.” - `POST /v1/register` returns `pending: true` until the ledger row exists. `GET /v1/registration/:addr` 404s until then. **Frontend** - `VoteListPage` / `VoteNewPage` display `formatCl8y(getBalance().balance)` as “CL8Y”. Catch on error sets `balance` null (hidden), so a **literal `"0"`** is a successful API read. - After Register, list page sets `registered=true` and **does not refetch** balance. Other pages refetch on mount — reporters still saw 0, so this is not only stale React state. - Register success ignores `pending`. A user can look “Registered” while `voting_registrations` is empty (until refresh, GET would 404). Combined with balances=0, the UI says registered-and-broke. - No distinction: not registered vs snapshot pending vs indexer lag vs true zero vs LCD/token mismatch. **Token identity** - Canonical Terra CW20 is `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 string `CL8Y` vs `CL8Y-cb`. ## Why this is needed The dApp tells holders to register the address that holds CL8Y, then uses `GET /v1/balances` for eligibility copy and the 1000 CL8Y propose gate. If that read is 0 while: - the wallet’s CW20 balance is thousands of units, and/or - `voting_registrations.initial_balance` already 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): 1. **Balance read height** — query at indexer tip without `max(tip, registered_at_height)` (and without a fallback when tip is 0) is inconsistent with propose and with L6. 2. **Indexer cursor vs register height** — live LCD height at register can sit **ahead** of `last_indexed_height` until ingest catches up; during that window every GET is 0. If ingest never advances (LCD `block_txs` failing, poller down), the window is forever. 3. **UX / API semantics** — 0 for “unknown wallet” vs “pending intent” vs “snapshotted zero”; UI must not claim a live wallet balance it does not have. 4. **Ops confirmation** — `CL8Y_TOKEN_ADDRESS` and `TERRA_LCD_URL` on Coolify must be columbus-5 canonical CL8Y; LCD equality (`docs/OPS.md` §5) must hold for a registered wallet. ## Constraints / guardrails 1. **No new smart contracts. No archive node.** Live LCD/`balanceOf` at register + transfers **forward** only. 2. **No `VITE_*` BSC RPC** (or Terra LCD) in the browser for CL8Y **balance** reads. Indexer owns `eth_call` / `eth_getLogs` / CW20 `Balance`. 3. **Do not invent amounts.** If LCD/`balanceOf` fails, registration retries; do not write 0 as a fake success. 4. Identity v1: one address, one voter. Never merge `terra1…` and `0x…`. Do not sum cross-chain. 5. Only the pinned CW20 / BEP-20. Lookalike tickers and forged `contract_address` (L3) stay ignored. 6. `operator-voting` stays on the **restricted** role: `EXECUTE` `public.voting_*_balance_at` only; no writer on ingest tables (L10). 7. L9 flash-in remains counted unless product later adds lockup — do not “fix” 0 by ignoring post-register transfers. 8. Do not ask for seeds. Do not use fee-discount / LP / DEX positions as vote weight. ## 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.rs` - `frontend/src/pages/VoteListPage.tsx`, `VoteNewPage.tsx` - `frontend/src/services/operatorVoting.ts` - `frontend/src/utils/format.ts`, `votingPayload.ts` (`canPropose`) - `docs/LEDGER_INVARIANTS.md` (L1, L6, L11), `docs/OPERATOR_VOTING.md`, `docs/OPS.md` §5 - Tests: `operator-voting/tests/api_flow.rs`, `ledger/tests/ledger_integration.rs`, `frontend/src/pages/VoteNewPage.test.tsx`, `frontend/e2e/vote.spec.ts` ## Recommended direction 1. **GET `/v1/balances`:** For a registered wallet, evaluate at `max(requested_or_tip, registered_at_height)` (same idea as propose freeze). If tip is 0, use `registered_at_height`, not 0. Optional: include `registered`, `pending`, `initial_balance`, `as_of_height` in the JSON so the UI does not guess. 2. **Unregistered / pending:** Do not present ledger `0` as “this wallet holds 0 CL8Y.” Prefer 404 or `registered: false` / `pending: true` and UI copy: “Register to snapshot this address’s CL8Y” without a fake zero. 3. **Register UX:** Honor `pending`. Poll `GET /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 POST `ok` alone. 4. **Poller:** After a successful Terra register, ingest must reach `>= registered_at_height` (same poll or explicitly). If `last_indexed_height` stays 0, `/health` and balances must not look “fine.” Log LCD ingest failures; do not mark intents processed when live balance failed (already retried — keep that). 5. **Ops:** On staging, for a known holder: LCD `Balance` vs `voting_registrations.initial_balance` vs `GET /v1/balances` at tip. Confirm `CL8Y_TOKEN_ADDRESS` is hpax3. CL8Y-cb ticker ≠ different contract without proof. 6. Still no browser `eth_call` / LCD smart query for the badge. ## Acceptance criteria - [ ] A Terra address whose live CW20 CL8Y (pinned contract) is ≥1000 human units, once **ledger-registered**, shows that order of magnitude on list + new proposal (18-decimal format), not 0. - [ ] Propose CTA enables at the 1000 boundary using the **same** server figure (server remains source of truth). - [ ] Unregistered connected wallets do **not** look like “Connected · 0 CL8Y” as if the chain were queried. - [ ] Pending intent: UI shows registering/pending, not Registered+0, until `voting_registrations` exists. - [ ] BSC path: same height/block clamp for `voting_bsc_cl8y_balance_at` (do not Terra-only-fix). - [ ] `GET /v1/balances?height=` below `registered_at_height` still returns 0 (L6 preserved for historical queries). Default/omitted height used by the dApp does not hit that trap for a just-registered wallet. - [ ] Restricted role still cannot `INSERT` ledger ingest tables. ## Test plan (all paths) **API / ledger** 1. Unregistered `GET /v1/balances/terra1…` — documented status/body; frontend does not render a fake live 0. 2. Insert registration `initial_balance = 3540 * 10^18`, `registered_at_height = 100`, `last_indexed_height = 0` → default GET balance is **not** 0 (this currently fails). 3. Same row, `last_indexed_height = 50` (< 100) → default GET used by UI is **not** 0; explicit `?height=50` stays 0 (L6). 4. `last_indexed_height = 120`, no checkpoints → balance equals `initial_balance`. 5. Transfer after register updates checkpoint; GET at tip follows L6 inclusive height. 6. Intent processed only after live LCD success; LCD failure retries, no invented 0 row. 7. Idempotent re-register does not double-credit (L11). 8. BSC analogue: `last_indexed_bsc_block` vs `registered_at_height`. 9. Wrong chain query param on a `terra1` / `0x` address — reject or infer consistently; no cross-chain sum. 10. Propose still requires ≥1000 on **that** address’s chain; blacklist still denied. **Frontend** 11. Connected unregistered: no “0 CL8Y” as wallet truth; Register enabled. 12. After sign, pending then Registered; balance appears when GET says non-zero (or explicit pending copy). 13. New proposal: gated below 1000, enabled at 1000 raw (`VoteNewPage.test.tsx`). 14. Error from API: no silent 0; show error, keep fail-closed on propose. 15. Terra and BSC labels; never merge two addresses. **Ops / live (required to close)** 16. Real Terra wallet with CL8Y-cb on the pinned CW20: LCD equality vs ledger vs UI (`docs/OPS.md` §5). 17. Repeat BSC 56 with BEP-20 CL8Y (#7 O7). 18. `GET /health` on ledger shows advancing `terra_height` after register. **Automation** 19. `cargo test --workspace --lib` and `LEDGER_TEST_DATABASE_URL=… cargo test --workspace --tests` covering the tip&lt;registered case. 20. `cd frontend && npm test && npm run test:e2e`. ## Test plan (attack, hack, abuse) 1. **Client-supplied balance:** UI/query param must not raise vote weight or bypass 1000 CL8Y. Only `public.voting_*_balance_at`. 2. **`?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. 3. **Unregistered probing:** Enumeration of balances is OK if public; do not leak other tables. Do not return LCD errors with internals. 4. **Lookalike CW20:** Events without wasmd `_contract_address` for the pin are ignored (L3). Register live query uses pin only — a wallet full of other tickers stays 0 (correct). 5. **Wrong LCD / testnet:** Prod must not snapshot LocalTerra TCL8Y as mainnet power. Fail closed if LCD missing in `RUN_MODE=prod`. 6. **Replay / cross-purpose sig:** Register blob cannot propose/vote (existing domain separation). 7. **Race / double intent:** `ON CONFLICT DO NOTHING`; no double `initial_balance`. 8. **Restricted role:** operator-voting cannot write `cl8y_balances` / transfers / `voting_registrations`. 9. **Flash-in (L9):** large transfer into a registered wallet before snapshot **is** weight — document, do not treat as a bug unless product adds lockup. 10. **Cross-chain merge:** Terra and BSC rows stay distinct. UI must not add them. 11. **Archive header:** still no `x-cosmos-block-height` historical spoof. ## Verification criteria - On `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. - Staging query: `initial_balance` ≈ live LCD `Balance` ≈ `GET /v1/balances` default as_of height ≥ `registered_at_height`. - Unregistered connected state does not look like a confirmed zero holding. - Do not close on a screenshot of “Registered” if `GET /v1/balances` is still `"0"` while LCD is not.
PlasticDigits commented 2026-08-29 14:16:52 +00:00 (Migrated from gitlab.com)

mentioned in issue #7

mentioned in issue #7
PlasticDigits commented 2026-08-29 14:46:08 +00:00 (Migrated from gitlab.com)

mentioned in merge request !20

mentioned in merge request !20
PlasticDigits commented 2026-08-29 14:46:21 +00:00 (Migrated from gitlab.com)

In-tree fix is in !20 (fix/9-registered-balance-zero).

Default GET /v1/balances now uses max(indexer tip, registered_at_height) on Terra and BSC so a live snapshot is not L6-zeroed while last_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/balances is "0" while LCD is not.

In-tree fix is in !20 (`fix/9-registered-balance-zero`). Default GET `/v1/balances` now uses `max(indexer tip, registered_at_height)` on Terra and BSC so a live snapshot is not L6-zeroed while `last_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/balances` is `"0"` while LCD is not.
PlasticDigits commented 2026-08-29 15:01:56 +00:00 (Migrated from gitlab.com)

mentioned in commit 4f18713f5e

mentioned in commit 4f18713f5e6f3efe41051510f6e7ba71ca217208
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-29 15:01:56 +00:00
PlasticDigits commented 2026-08-29 15:02:42 +00:00 (Migrated from gitlab.com)

!20 is merged to main after resolving conflicts with !19 (canonical ROUTES + OV-B1 snapshot UX). No automerge; did not wait for CI. GitLab pipeline on !20 failed with ci_quota_exceeded (jobs never started). Local sanity on the conflict-resolved branch: cargo test --workspace --lib 46, frontend unit 55, Playwright 9/9 (5 workers).

In-tree (this does not close the issue): default GET /v1/balances is max(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.com Terra 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)
  • BSC O7 analogue
  • ledger /health caught_up / advancing terra_height (treat ok as liveness only)
  • confirm Coolify CL8Y_TOKEN_ADDRESS is hpax3

Follow-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.

!20 is merged to `main` after resolving conflicts with !19 (canonical `ROUTES` + OV-B1 snapshot UX). No automerge; did not wait for CI. GitLab pipeline on !20 failed with `ci_quota_exceeded` (jobs never started). Local sanity on the conflict-resolved branch: `cargo test --workspace --lib` 46, frontend unit 55, Playwright 9/9 (5 workers). **In-tree (this does not close the issue):** default `GET /v1/balances` is `max(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.com` Terra 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) - BSC O7 analogue - ledger `/health` `caught_up` / advancing `terra_height` (treat `ok` as liveness only) - confirm Coolify `CL8Y_TOKEN_ADDRESS` is hpax3 **Follow-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.
PlasticDigits (Migrated from gitlab.com) reopened this issue 2026-08-29 15:04:53 +00:00
PlasticDigits commented 2026-08-29 15:04:55 +00:00 (Migrated from gitlab.com)

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:

  • vote.cl8y.com Terra holder (pinned hpax3 / CL8Y-cb): LCD Balance ≈ voting_registrations.initial_balance ≈ default GET /v1/balances (non-zero on list + /new)
  • BSC O7 analogue
  • ledger /health caught_up / advancing terra_height
  • Coolify CL8Y_TOKEN_ADDRESS is hpax3

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: - vote.cl8y.com Terra holder (pinned hpax3 / CL8Y-cb): LCD Balance ≈ `voting_registrations.initial_balance` ≈ default GET `/v1/balances` (non-zero on list + `/new`) - BSC O7 analogue - ledger `/health` `caught_up` / advancing `terra_height` - Coolify `CL8Y_TOKEN_ADDRESS` is hpax3 Do not close on a “Registered” screenshot if GET is still `"0"` while LCD is not.
PlasticDigits commented 2026-08-30 02:46:50 +00:00 (Migrated from gitlab.com)

mentioned in issue #13

mentioned in issue #13
PlasticDigits commented 2026-08-30 02:47:14 +00:00 (Migrated from gitlab.com)

mentioned in issue #14

mentioned in issue #14
PlasticDigits commented 2026-08-30 02:47:14 +00:00 (Migrated from gitlab.com)

marked as related to #14

marked as related to #14
PlasticDigits commented 2026-08-30 02:47:25 +00:00 (Migrated from gitlab.com)

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 waitForLedgerRegistration timeout 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.

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 `waitForLedgerRegistration` timeout plus a missing ledger row, not a regression of the OV-B1 clamp. Tracked as [#14](https://gitlab.com/PlasticDigits/voting/-/issues/14). Do not close this issue on that screenshot — #9 still needs LCD equality after the wallet is **ledger-registered**.
PlasticDigits commented 2026-08-30 05:54:20 +00:00 (Migrated from gitlab.com)

mentioned in merge request !24

mentioned in merge request !24
PlasticDigits commented 2026-08-30 05:55:03 +00:00 (Migrated from gitlab.com)

mentioned in merge request !25

mentioned in merge request !25
PlasticDigits commented 2026-08-30 06:05:12 +00:00 (Migrated from gitlab.com)

mentioned in commit d6b4e01acd

mentioned in commit d6b4e01acd2e47cd710926dfc2b746b26d13482f
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-30 06:05:13 +00:00
PlasticDigits commented 2026-08-30 06:21:55 +00:00 (Migrated from gitlab.com)

mentioned in issue #15

mentioned in issue #15
PlasticDigits commented 2026-08-30 06:21:56 +00:00 (Migrated from gitlab.com)

marked as related to #15

marked as related to #15
PlasticDigits (Migrated from gitlab.com) reopened this issue 2026-08-30 06:22:47 +00:00
PlasticDigits commented 2026-08-30 06:22:51 +00:00 (Migrated from gitlab.com)

Reopened. Merging !25 auto-closed this because the MR text said “do not close #9 instead of this issue.” GitLab treats close #9 as a closing pattern. Remaining AC is live LCD/balanceOf equality after registered — still blocked on #14 writer health and #15 redeploy.

Reopened. Merging !25 auto-closed this because the MR text said “do not close #9 instead of this issue.” GitLab treats `close #9` as a closing pattern. Remaining AC is live LCD/`balanceOf` equality after **registered** — still blocked on [#14](https://gitlab.com/PlasticDigits/voting/-/issues/14) writer health and [#15](https://gitlab.com/PlasticDigits/voting/-/issues/15) redeploy.
PlasticDigits commented 2026-09-01 08:09:42 +00:00 (Migrated from gitlab.com)

Verify pass (2026-09-01) — in-tree only; this issue stays open

Pushed d7f4d51 to main.

In-tree (pass)

  • OV-B1: default GET /v1/balances is max(tip, registered_at_height) on Terra and BSC; explicit ?height= below register is still 0 (L6).
  • Unregistered/pending is not rendered as a live 0; Playwright covers that plus pending timeout Retry (issue 14 UX).
  • Restricted role still cannot INSERT ledger ingest tables.
  • Live unregistered Terra GET /v1/balances is registered: 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/health is proxy 503. Cannot prove LCD Balance ≈ initial_balance ≈ default GET after registered.
  • BSC O7 analogue not run.
  • Coolify CL8Y_TOKEN_ADDRESS hpax3 not confirmed from this agent.

Checklist

  • In-tree clamp + unregistered/pending UX + privilege
  • vote.cl8y.com Terra holder (pinned hpax3 / CL8Y-cb): LCD Balance ≈ voting_registrations.initial_balance ≈ default GET /v1/balances (non-zero on list + /new when LCD is not 0)
  • BSC 56 analogue
  • Ledger /health caught_up / advancing terra_height (ok is liveness only)
  • Coolify CL8Y_TOKEN_ADDRESS is hpax3

Stays 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.

## Verify pass (2026-09-01) — in-tree only; this issue stays **open** Pushed `d7f4d51` to `main`. ### In-tree (pass) - OV-B1: default GET `/v1/balances` is `max(tip, registered_at_height)` on Terra and BSC; explicit `?height=` below register is still 0 (L6). - Unregistered/pending is not rendered as a live 0; Playwright covers that plus pending timeout Retry (issue 14 UX). - Restricted role still cannot INSERT ledger ingest tables. - Live unregistered Terra GET `/v1/balances` is `registered: 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/health` is proxy **503**. Cannot prove LCD `Balance` ≈ `initial_balance` ≈ default GET after **registered**. - BSC O7 analogue not run. - Coolify `CL8Y_TOKEN_ADDRESS` hpax3 not confirmed from this agent. ### Checklist - [x] In-tree clamp + unregistered/pending UX + privilege - [ ] `vote.cl8y.com` Terra holder (pinned hpax3 / CL8Y-cb): LCD Balance ≈ `voting_registrations.initial_balance` ≈ default GET `/v1/balances` (non-zero on list + `/new` when LCD is not 0) - [ ] BSC 56 analogue - [ ] Ledger `/health` `caught_up` / advancing `terra_height` (`ok` is liveness only) - [ ] Coolify `CL8Y_TOKEN_ADDRESS` is hpax3 **Stays 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.
PlasticDigits commented 2026-09-01 08:51:34 +00:00 (Migrated from gitlab.com)

Closing on OV-B1 in-tree + live unregistered contract

Default GET /v1/balances clamps to max(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 /health is proxy 503. That leftover stays on issue 7 — do not treat a Registered screenshot as LCD proof.

## Closing on OV-B1 in-tree + live unregistered contract Default GET `/v1/balances` clamps to `max(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 `/health` is proxy 503. That leftover stays on issue 7 — do not treat a Registered screenshot as LCD proof.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-09-01 08:51:57 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
code/voting#9
No description provided.