fix(voting): registration stays pending; no CL8Y amount after snapshot timeout #14

Closed
opened 2026-08-30 02:47:13 +00:00 by PlasticDigits · 13 comments
PlasticDigits commented 2026-08-30 02:47:13 +00:00 (Migrated from gitlab.com)

Summary

Live QA on https://vote.cl8y.com (Terra Classic, mobile in-app browser): a connected holder no longer sees 0 CL8Y on the proposals list (the #9 hide-until-registered copy is working). They also see no CL8Y amount at all. The Register control stays Registering… and the red banner stays:

Registration snapshot is still pending. Wait for the ledger, then retry.

That string is thrown only after waitForLedgerRegistration polls GET /v1/registration/:addr for 30s without a voting_registrations row. The user cannot retry from the UI: the Register CTA is replaced by the pending pill, polling stops, and a reload while the intent is still unprocessed shows Registering… again with no continued poll.

Related: #9 (Registered+0 / OV-B1; in-tree, still open for live LCD equality), #1 (ledger), #2 (operator-voting), #3 (dApp), #7 (Coolify / live QA).

Do not “fix” this by reading CW20 Balance in the browser. Do not show a fake live 0 to fill the gap. The ledger snapshot must actually land, and the dApp must keep a retry path while it has not.

Current codebase

Registration handoff (L11)

  • POST /v1/register verifies ADR-36 (Terra) or EIP-191 (BSC), inserts voting.signatures + voting.registration_intents (ON CONFLICT (chain, wallet_address) DO NOTHING). Returns pending: true until voting_registrations exists (operator-voting/src/api.rs).
  • Only voting-ledger writes the snapshot: process_pending_intents → live LCD Balance / balanceOf at current height/block → voting_registrations.initial_balance (ledger/src/register.rs). LCD/balanceOf failure is logged and retried; it does not write initial_balance = 0.
  • Default poll interval is 4s (POLL_INTERVAL_MS). LCD HTTP timeout is 20s per URL (ledger/src/lcd.rs). Intents are processed at the start of poll_once; Terra ingest then walks last+1..=tip with no chunk cap (BSC is capped at 2000 blocks). A long ingest means no further intent processing until that poll_once returns.
  • pending_intents / mark_intent_processed swallow any sqlx error whose message contains voting.registration_intents and treat it as “no rows” (ledger/src/db.rs). A permission or missing-table failure looks like an empty queue.

operator-voting (OV-B1–B3, main)

  • GET /v1/registration/:addr returns pending: { terra, bsc } while an intent is in flight; 404 only when there is neither a ledger row nor a pending intent.
  • GET /v1/balances/:addr on main includes registered, pending, initial_balance, as_of_height. Default height is max(indexer tip, registered_at_height). Unregistered/pending still returns HTTP 200 with balance: "0"; the UI must not present that as a live holding.

Frontend (post-#9 UX)

  • useVotingSnapshot / snapshotBalanceRaw: amounts render only when registered === true. Pending and unregistered set balance to null — so the list shows no · N CL8Y. That is why 0 disappeared.
  • Register: completeRegister sets status pending, then waitForLedgerRegistration (1s interval, 30s timeout). On timeout it throws the exact live banner and skips refresh().
  • VoteListPage: status === 'pending' || busy replaces the Register button with Registering…. After timeout, busy is false but status stays pending → no Retry. Copy says “then retry” with no control.
  • On remount, refresh() runs once. If pending is still true, status is pending again and nobody polls. There is no setInterval for in-flight intents.
  • Propose (VoteNewPage) stays gated until registered; pending copy is “Propose stays disabled until the ledger row exists.”

Live probe (2026-08-30, no wallet identifiers)

Treat as ops evidence, not a close:

  • GET https://operator.vote.cl8y.com/health → 200 { ok, service: operator-voting }. CORS allows https://vote.cl8y.com.
  • GET /v1/proposals → [].
  • GET /v1/balances/<unregistered terra1> → { chain, address, balance: "0", height: 30162104 } without registered, pending, as_of_height, initial_balance. main always emits those keys. Coolify operator-voting may be a pre-#9 image while the dApp (bundle index-B4ChX-rT.js, HTML last-modified 2026-08-29 15:23 UTC) is post-#9.
  • Public https://ledger.vote.cl8y.com/health → 503 no available server (proxy has no backend). If that hostname is the writer, no process is calling process_pending_intents, which matches a stuck intent. Confirm the real Coolify service name if DNS differs.
  • Indexer cursor height: 30162104 on the balance probe means indexer_state.last_indexed_height was written at some point (boot jump-to-tip or a previous poller). It does not prove the poller is running now.

Why this is needed

#9 correctly stopped rendering ledger 0 as a live wallet balance. The follow-up called out in the #9 thread is now the live failure: pending Registering… only polls for 30s inside completeRegister; on timeout the CTA stays hidden; a reload while the intent is pending does not keep polling.

If voting_registrations never appears (ledger down, LCD live-balance failing, ingest starving the poll loop, or silent empty pending_intents), the dApp is a dead end: no amount, no Register, no retry, propose remains fail-closed. Live QA (#7 / #9 LCD equality) cannot pass.

This is one product bug (registration never becomes a snapshot the UI can show). Do not split frontend timeout vs ledger poller vs Coolify 503 into separate issues.

Constraints / guardrails

  1. No new smart contracts. No archive node. Live LCD/balanceOf at register + transfers forward only. No x-cosmos-block-height historical spoof.
  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, retry the intent; do not write 0 as a fake success to unblock the UI.
  4. Do not bring back fake 0 for unregistered/pending. A true registered zero is allowed; API errors are not shown as 0.
  5. Identity v1: one address, one voter. Never merge terra1… and 0x…. Do not sum cross-chain.
  6. Only the pinned CW20 / BEP-20. Lookalike tickers stay ignored (L3/L4).
  7. operator-voting stays on the restricted role: EXECUTE public.voting_*_balance_at only; no writer on ingest tables (L10). Ledger writer must be able to SELECT/UPDATE voting.registration_intents.
  8. Do not ask for seeds. Do not use fee-discount / LP / DEX positions as vote weight.
  9. Fail closed on propose/vote until the ledger row exists. Do not set Registered from POST /v1/register ok alone.
  10. Do not expose ADMIN_TOKEN or DB URLs in the dApp. Do not log RPC URLs.

Relevant files

  • frontend/src/services/operatorVoting.ts (waitForLedgerRegistration, REGISTER_POLL_TIMEOUT_MS)
  • frontend/src/hooks/useVotingSnapshot.ts (completeRegister, refresh)
  • frontend/src/utils/ledgerDisplay.ts (snapshotBalanceRaw)
  • frontend/src/pages/VoteListPage.tsx, VoteNewPage.tsx
  • frontend/e2e/vote.spec.ts, frontend/e2e/operatorMock.ts
  • frontend/src/services/__tests__/operatorVoting.test.ts, frontend/src/pages/VoteListPage.test.tsx
  • operator-voting/src/api.rs (register, registration, get_balance)
  • operator-voting/src/db.rs (insert_registration_intent, pending_intent, ledger_registration)
  • operator-voting/src/balance_query.rs (OV-B1)
  • ledger/src/register.rs, ledger/src/main.rs (poll_once, /health)
  • ledger/src/db.rs (pending_intents, mark_intent_processed)
  • ledger/src/lcd.rs, ledger/src/bsc.rs (CompositeBalanceSource)
  • ledger/migrations/20260825000002_voting.sql (voting.registration_intents)
  • deploy/grants.sql, docs/LEDGER_INVARIANTS.md (L1, L11), docs/OPERATOR_VOTING.md (OV-B3), docs/FRONTEND.md, docs/OPS.md §1 / §5
  • Tests: operator-voting/tests/api_flow.rs (pending_intent_is_visible_before_ledger_row), ledger/tests/ledger_integration.rs
  1. Ops first (required to close): Confirm Coolify voting-ledger is actually running and looping. GET /health on the writer must be 200 with advancing terra_height / caught_up (treat ok: true as liveness only). A 503 proxy is not a healthy poller. Redeploy operator-voting from main so GET /v1/balances includes registered / pending / as_of_height. Confirm TERRA_LCD_URL and CL8Y_TOKEN_ADDRESS (hpax3). Inspect writer logs for registration live-balance failed; will retry.
  2. Frontend pending UX: While status === 'pending', keep polling (backoff, cap, visible last-attempt time). On timeout, show the error and a Retry control that calls completeRegister / refresh — do not leave Registering… as a dead pill. Reload with a still-pending intent must resume polling, not freeze.
  3. Timeout vs LCD: 30s is tight against a 4s poll + 20s LCD timeout (worse with multiple LCD URLs). Align the client wait with a realistic ledger period, or drive off GET /v1/registration + GET /v1/balances until registered: true / timeout with retry. Still do not claim Registered from POST ok.
  4. Ledger poller: Do not swallow pending_intents errors as an empty queue — log and fail the poll so /health / logs show it. Process intents on a cadence that cannot be starved by unbounded Terra last+1..=tip ingest (chunk ingest like BSC, or a separate intent task). If last_indexed_height is a stale low non-zero, do not try to walk millions of heights before the next intent pass.
  5. API contract: Production GET /v1/balances must match OV-B1–B3. Frontend may treat missing registered as “unknown”, never as a live 0.
  6. Stuck intents: ON CONFLICT DO NOTHING means a failed first register cannot insert a second row. That is correct if the poller retries the same row. When the writer is down, the row sits until it returns — UI retry must re-poll, not require a new intent.
  7. Still no browser eth_call / LCD smart query for the badge.

Acceptance criteria

  • After a successful Terra (and BSC) register sign, the list leaves Registering… once voting_registrations exists, and then shows the server 18-decimal amount (including a true registered 0). Unregistered/pending still do not look like a live 0.
  • If the snapshot is not ready within the wait window, the user sees the pending error and can retry without a full-page workaround. Reload while pending resumes wait/poll.
  • POST /v1/register ok alone never shows Registered or a CL8Y amount.
  • Propose stays disabled until the ledger row exists; after it lands, the 1000 CL8Y gate uses the same GET /v1/balances figure (OV-B5).
  • Live GET /v1/balances includes registered, pending, as_of_height as on main.
  • Writer /health is reachable from ops (not 503). Intents with a working LCD/balanceOf complete without manual DB edits.
  • pending_intents permission/query failures are visible (not silent empty). Restricted role still cannot INSERT ledger ingest tables.
  • BSC analogue: same pending → registered path (do not Terra-only-fix).

Test plan (all paths)

API / ledger

  1. Unregistered GET /v1/balances — 200, registered: false, pending: false; frontend does not render a live 0.
  2. POST register → GET registration pending: true and GET balances pending: true, registered: false, amount hidden in UI.
  3. After process_pending_intents succeeds: GET registration has the chain row, pending: false; GET balances registered: true and balance equals initial_balance when there are no later checkpoints (OV-B1 clamp if tip lags).
  4. LCD/balanceOf failure: intent remains processed_at IS NULL; no initial_balance = 0 row; next poll retries.
  5. Idempotent re-register: ON CONFLICT DO NOTHING; no double credit (L11). POST returns pending: false if the ledger row already exists.
  6. Writer cannot see voting.registration_intents (simulated permission error) — must not look like an empty queue; poll/logs fail closed.
  7. Terra ingest catch-up in progress: a new intent is still processed within a bounded time (not “when millions of heights finish”).
  8. BSC analogue: pending intent → live balanceOf → row → GET balances.
  9. ?chain= mismatch on terra1 / 0x — 400; no cross-chain sum.
  10. Blacklist still denied on register/propose/vote.

Frontend

  1. Connected unregistered: Register enabled; no 0 CL8Y as wallet truth.
  2. After sign: Registering… then Registered + formatted amount when GET says registered (Playwright registerPendingTicks).
  3. Snapshot never lands within the wait window: banner + Retry (or equivalent); Registering… is not a trap. Clicking retry polls again without a second seed prompt.
  4. Reload while GET registration pending: true: polling resumes; amount still hidden.
  5. API error (5xx / CORS): no silent 0; show error; propose fail-closed.
  6. Timeout then ledger row appears on next poll/retry: UI flips to Registered + amount (no stuck pending).
  7. Terra and BSC labels stay distinct.

Ops / live (required to close)

  1. Coolify writer healthy: /health 200, terra_height advancing, caught_up after register (OPS.md §1 / §5).
  2. Real Terra wallet, pinned hpax3: sign register → pending at most one wait window → list + /new show non-zero (or true zero) matching initial_balance ≈ LCD Balance ≈ GET /v1/balances.
  3. Repeat BSC 56 (#7 O7).
  4. Confirm live GET /v1/balances JSON matches main (has registered / pending / as_of_height).

Automation

  1. cargo test --workspace --lib and LEDGER_TEST_DATABASE_URL=… cargo test --workspace --tests covering pending → snapshot and LCD retry.
  2. cd frontend && npm test && npm run test:e2e including timeout+retry and remount-while-pending (5 Playwright workers).

Test plan (attack, hack, abuse)

  1. Client-supplied balance: UI must not raise vote weight or bypass 1000 CL8Y. Only public.voting_*_balance_at / frozen snapshots.
  2. ?height= tampering: Future height cannot mint checkpoints the indexer never wrote. Below registered_at_height stays 0 (L6). Propose/vote ignore client height.
  3. Retry spam: Retry re-polls; it must not insert duplicate initial_balance (L11). POST register remains rate-limited (O-RL). Do not skip signature verification on retry.
  4. Stuck-intent griefing: Attacker cannot permanently occupy (chain, wallet) for a victim; the unique key is the victim’s own address. A blacklisted address still cannot complete register.
  5. Unregistered probing: Public GET balances/registration is OK; do not leak LCD internals or other tables. 404 vs pending must not disclose other users’ holdings beyond the public functions.
  6. Lookalike CW20 / BEP-20: Live snapshot uses the pin only. Other tickers stay 0 after register (correct).
  7. Wrong LCD / testnet: Prod must not snapshot LocalTerra TCL8Y as mainnet power. Fail closed if LCD missing in RUN_MODE=prod.
  8. Replay / cross-purpose: Register blob cannot propose/vote. ADR-36 posted as EVM (and reverse) rejected.
  9. Restricted role: operator-voting cannot write cl8y_balances / transfers / voting_registrations. It can insert intents; only the writer processes them.
  10. Flash-in (L9): large transfer after register is weight — not a bug unless product adds lockup.
  11. Cross-chain merge: Terra and BSC pending flags and rows stay distinct. UI must not add them.
  12. Health spoof: Frontend must not treat writer /health.ok or POST ok as Registered.

Verification criteria

  • On vote.cl8y.com, a connected Terra holder who signs register leaves Registering… when the ledger row exists and then sees the formatted GET /v1/balances amount (not a blank connected line, not a fake 0).
  • If the writer is down or LCD live-balance is failing, the UI says pending/retry and remains fail-closed — it does not look Registered, and it does not trap the user without a retry.
  • Ops: writer /health is not 503; GET /v1/balances on the live API includes registered/pending/as_of_height; LCD Balance ≈ initial_balance ≈ default GET after register (OPS.md §5).
  • Do not close on a screenshot of Registering… or of the pending banner. Do not close #9 instead of this issue: #9 is LCD equality after registered; this issue is never leaving pending.
## Summary Live QA on `https://vote.cl8y.com` (Terra Classic, mobile in-app browser): a connected holder no longer sees **0 CL8Y** on the proposals list (the [#9](https://gitlab.com/PlasticDigits/voting/-/issues/9) hide-until-registered copy is working). They also see **no CL8Y amount at all**. The Register control stays **Registering…** and the red banner stays: > Registration snapshot is still pending. Wait for the ledger, then retry. That string is thrown only after `waitForLedgerRegistration` polls `GET /v1/registration/:addr` for 30s without a `voting_registrations` row. The user cannot retry from the UI: the Register CTA is replaced by the pending pill, polling stops, and a reload while the intent is still unprocessed shows Registering… again with **no continued poll**. Related: [#9](https://gitlab.com/PlasticDigits/voting/-/issues/9) (Registered+0 / OV-B1; in-tree, still open for live LCD equality), [#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), [#7](https://gitlab.com/PlasticDigits/voting/-/issues/7) (Coolify / live QA). Do **not** “fix” this by reading CW20 `Balance` in the browser. Do not show a fake live 0 to fill the gap. The ledger snapshot must actually land, and the dApp must keep a retry path while it has not. ## Current codebase **Registration handoff (L11)** - `POST /v1/register` verifies ADR-36 (Terra) or EIP-191 (BSC), inserts `voting.signatures` + `voting.registration_intents` (`ON CONFLICT (chain, wallet_address) DO NOTHING`). Returns `pending: true` until `voting_registrations` exists (`operator-voting/src/api.rs`). - Only `voting-ledger` writes the snapshot: `process_pending_intents` → live LCD `Balance` / `balanceOf` at current height/block → `voting_registrations.initial_balance` (`ledger/src/register.rs`). LCD/`balanceOf` failure is logged and retried; it does **not** write `initial_balance = 0`. - Default poll interval is 4s (`POLL_INTERVAL_MS`). LCD HTTP timeout is 20s per URL (`ledger/src/lcd.rs`). Intents are processed at the **start** of `poll_once`; Terra ingest then walks `last+1..=tip` with **no chunk cap** (BSC is capped at 2000 blocks). A long ingest means no further intent processing until that `poll_once` returns. - `pending_intents` / `mark_intent_processed` **swallow** any sqlx error whose message contains `voting.registration_intents` and treat it as “no rows” (`ledger/src/db.rs`). A permission or missing-table failure looks like an empty queue. **operator-voting (OV-B1–B3, `main`)** - `GET /v1/registration/:addr` returns `pending: { terra, bsc }` while an intent is in flight; 404 only when there is neither a ledger row nor a pending intent. - `GET /v1/balances/:addr` on `main` includes `registered`, `pending`, `initial_balance`, `as_of_height`. Default height is `max(indexer tip, registered_at_height)`. Unregistered/pending still returns HTTP 200 with `balance: "0"`; the UI must not present that as a live holding. **Frontend (post-#9 UX)** - `useVotingSnapshot` / `snapshotBalanceRaw`: amounts render only when `registered === true`. Pending and unregistered set `balance` to `null` — so the list shows **no** `· N CL8Y`. That is why 0 disappeared. - Register: `completeRegister` sets status `pending`, then `waitForLedgerRegistration` (1s interval, **30s timeout**). On timeout it throws the exact live banner and **skips** `refresh()`. - `VoteListPage`: `status === 'pending' || busy` replaces the Register button with Registering…. After timeout, `busy` is false but status stays `pending` → **no Retry**. Copy says “then retry” with no control. - On remount, `refresh()` runs once. If `pending` is still true, status is pending again and **nobody polls**. There is no `setInterval` for in-flight intents. - Propose (`VoteNewPage`) stays gated until registered; pending copy is “Propose stays disabled until the ledger row exists.” **Live probe (2026-08-30, no wallet identifiers)** Treat as ops evidence, not a close: - `GET https://operator.vote.cl8y.com/health` → 200 `{ ok, service: operator-voting }`. CORS allows `https://vote.cl8y.com`. - `GET /v1/proposals` → `[]`. - `GET /v1/balances/<unregistered terra1>` → `{ chain, address, balance: "0", height: 30162104 }` **without** `registered`, `pending`, `as_of_height`, `initial_balance`. `main` always emits those keys. Coolify `operator-voting` may be a **pre-#9** image while the dApp (bundle `index-B4ChX-rT.js`, HTML last-modified 2026-08-29 15:23 UTC) is post-#9. - Public `https://ledger.vote.cl8y.com/health` → **503** `no available server` (proxy has no backend). If that hostname is the writer, **no process is calling `process_pending_intents`**, which matches a stuck intent. Confirm the real Coolify service name if DNS differs. - Indexer cursor `height: 30162104` on the balance probe means `indexer_state.last_indexed_height` was written at some point (boot jump-to-tip or a previous poller). It does **not** prove the poller is running now. ## Why this is needed [#9](https://gitlab.com/PlasticDigits/voting/-/issues/9) correctly stopped rendering ledger `0` as a live wallet balance. The follow-up called out in the #9 thread is now the live failure: pending Registering… only polls for 30s inside `completeRegister`; on timeout the CTA stays hidden; a reload while the intent is pending does not keep polling. If `voting_registrations` never appears (ledger down, LCD live-balance failing, ingest starving the poll loop, or silent empty `pending_intents`), the dApp is a dead end: no amount, no Register, no retry, propose remains fail-closed. Live QA (#7 / #9 LCD equality) cannot pass. This is one product bug (registration never becomes a snapshot the UI can show). Do not split frontend timeout vs ledger poller vs Coolify 503 into separate issues. ## Constraints / guardrails 1. **No new smart contracts. No archive node.** Live LCD/`balanceOf` at register + transfers **forward** only. No `x-cosmos-block-height` historical spoof. 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, retry the intent; do not write 0 as a fake success to unblock the UI. 4. **Do not bring back fake 0** for unregistered/pending. A true registered zero is allowed; API errors are not shown as 0. 5. Identity v1: one address, one voter. Never merge `terra1…` and `0x…`. Do not sum cross-chain. 6. Only the pinned CW20 / BEP-20. Lookalike tickers stay ignored (L3/L4). 7. `operator-voting` stays on the **restricted** role: `EXECUTE` `public.voting_*_balance_at` only; no writer on ingest tables (L10). Ledger **writer** must be able to `SELECT`/`UPDATE` `voting.registration_intents`. 8. Do not ask for seeds. Do not use fee-discount / LP / DEX positions as vote weight. 9. Fail closed on propose/vote until the ledger row exists. Do not set Registered from `POST /v1/register` `ok` alone. 10. Do not expose `ADMIN_TOKEN` or DB URLs in the dApp. Do not log RPC URLs. ## Relevant files - `frontend/src/services/operatorVoting.ts` (`waitForLedgerRegistration`, `REGISTER_POLL_TIMEOUT_MS`) - `frontend/src/hooks/useVotingSnapshot.ts` (`completeRegister`, `refresh`) - `frontend/src/utils/ledgerDisplay.ts` (`snapshotBalanceRaw`) - `frontend/src/pages/VoteListPage.tsx`, `VoteNewPage.tsx` - `frontend/e2e/vote.spec.ts`, `frontend/e2e/operatorMock.ts` - `frontend/src/services/__tests__/operatorVoting.test.ts`, `frontend/src/pages/VoteListPage.test.tsx` - `operator-voting/src/api.rs` (`register`, `registration`, `get_balance`) - `operator-voting/src/db.rs` (`insert_registration_intent`, `pending_intent`, `ledger_registration`) - `operator-voting/src/balance_query.rs` (OV-B1) - `ledger/src/register.rs`, `ledger/src/main.rs` (`poll_once`, `/health`) - `ledger/src/db.rs` (`pending_intents`, `mark_intent_processed`) - `ledger/src/lcd.rs`, `ledger/src/bsc.rs` (`CompositeBalanceSource`) - `ledger/migrations/20260825000002_voting.sql` (`voting.registration_intents`) - `deploy/grants.sql`, `docs/LEDGER_INVARIANTS.md` (L1, L11), `docs/OPERATOR_VOTING.md` (OV-B3), `docs/FRONTEND.md`, `docs/OPS.md` §1 / §5 - Tests: `operator-voting/tests/api_flow.rs` (`pending_intent_is_visible_before_ledger_row`), `ledger/tests/ledger_integration.rs` ## Recommended direction 1. **Ops first (required to close):** Confirm Coolify `voting-ledger` is actually running and looping. `GET /health` on the writer must be 200 with advancing `terra_height` / `caught_up` (treat `ok: true` as liveness only). A 503 proxy is not a healthy poller. Redeploy `operator-voting` from `main` so GET `/v1/balances` includes `registered` / `pending` / `as_of_height`. Confirm `TERRA_LCD_URL` and `CL8Y_TOKEN_ADDRESS` (hpax3). Inspect writer logs for `registration live-balance failed; will retry`. 2. **Frontend pending UX:** While `status === 'pending'`, keep polling (backoff, cap, visible last-attempt time). On timeout, show the error **and** a Retry control that calls `completeRegister` / `refresh` — do not leave Registering… as a dead pill. Reload with a still-pending intent must resume polling, not freeze. 3. **Timeout vs LCD:** 30s is tight against a 4s poll + 20s LCD timeout (worse with multiple LCD URLs). Align the client wait with a realistic ledger period, or drive off `GET /v1/registration` + `GET /v1/balances` until `registered: true` / timeout with retry. Still do not claim Registered from POST `ok`. 4. **Ledger poller:** Do not swallow `pending_intents` errors as an empty queue — log and fail the poll so `/health` / logs show it. Process intents on a cadence that cannot be starved by unbounded Terra `last+1..=tip` ingest (chunk ingest like BSC, or a separate intent task). If `last_indexed_height` is a stale low non-zero, do not try to walk millions of heights before the next intent pass. 5. **API contract:** Production GET `/v1/balances` must match OV-B1–B3. Frontend may treat missing `registered` as “unknown”, never as a live 0. 6. **Stuck intents:** `ON CONFLICT DO NOTHING` means a failed first register cannot insert a second row. That is correct if the poller retries the same row. When the writer is down, the row sits until it returns — UI retry must re-poll, not require a new intent. 7. Still no browser `eth_call` / LCD smart query for the badge. ## Acceptance criteria - [ ] After a successful Terra (and BSC) register sign, the list leaves Registering… once `voting_registrations` exists, and then shows the server 18-decimal amount (including a true registered 0). Unregistered/pending still do **not** look like a live 0. - [ ] If the snapshot is not ready within the wait window, the user sees the pending error **and** can retry without a full-page workaround. Reload while pending resumes wait/poll. - [ ] `POST /v1/register` `ok` alone never shows Registered or a CL8Y amount. - [ ] Propose stays disabled until the ledger row exists; after it lands, the 1000 CL8Y gate uses the same GET `/v1/balances` figure (OV-B5). - [ ] Live `GET /v1/balances` includes `registered`, `pending`, `as_of_height` as on `main`. - [ ] Writer `/health` is reachable from ops (not 503). Intents with a working LCD/`balanceOf` complete without manual DB edits. - [ ] `pending_intents` permission/query failures are visible (not silent empty). Restricted role still cannot `INSERT` ledger ingest tables. - [ ] BSC analogue: same pending → registered path (do not Terra-only-fix). ## Test plan (all paths) **API / ledger** 1. Unregistered GET `/v1/balances` — 200, `registered: false`, `pending: false`; frontend does not render a live 0. 2. POST register → GET registration `pending: true` and GET balances `pending: true`, `registered: false`, amount hidden in UI. 3. After `process_pending_intents` succeeds: GET registration has the chain row, `pending: false`; GET balances `registered: true` and `balance` equals `initial_balance` when there are no later checkpoints (OV-B1 clamp if tip lags). 4. LCD/`balanceOf` failure: intent remains `processed_at IS NULL`; no `initial_balance = 0` row; next poll retries. 5. Idempotent re-register: `ON CONFLICT DO NOTHING`; no double credit (L11). POST returns `pending: false` if the ledger row already exists. 6. Writer cannot see `voting.registration_intents` (simulated permission error) — must **not** look like an empty queue; poll/logs fail closed. 7. Terra ingest catch-up in progress: a new intent is still processed within a bounded time (not “when millions of heights finish”). 8. BSC analogue: pending intent → live `balanceOf` → row → GET balances. 9. `?chain=` mismatch on `terra1` / `0x` — 400; no cross-chain sum. 10. Blacklist still denied on register/propose/vote. **Frontend** 11. Connected unregistered: Register enabled; no `0 CL8Y` as wallet truth. 12. After sign: Registering… then Registered + formatted amount when GET says registered (Playwright `registerPendingTicks`). 13. Snapshot never lands within the wait window: banner + Retry (or equivalent); Registering… is not a trap. Clicking retry polls again without a second seed prompt. 14. Reload while GET registration `pending: true`: polling resumes; amount still hidden. 15. API error (5xx / CORS): no silent 0; show error; propose fail-closed. 16. Timeout then ledger row appears on next poll/retry: UI flips to Registered + amount (no stuck pending). 17. Terra and BSC labels stay distinct. **Ops / live (required to close)** 18. Coolify writer healthy: `/health` 200, `terra_height` advancing, `caught_up` after register (OPS.md §1 / §5). 19. Real Terra wallet, pinned hpax3: sign register → pending at most one wait window → list + `/new` show non-zero (or true zero) matching `initial_balance` ≈ LCD `Balance` ≈ GET `/v1/balances`. 20. Repeat BSC 56 (#7 O7). 21. Confirm live GET `/v1/balances` JSON matches `main` (has `registered` / `pending` / `as_of_height`). **Automation** 22. `cargo test --workspace --lib` and `LEDGER_TEST_DATABASE_URL=… cargo test --workspace --tests` covering pending → snapshot and LCD retry. 23. `cd frontend && npm test && npm run test:e2e` including timeout+retry and remount-while-pending (5 Playwright workers). ## Test plan (attack, hack, abuse) 1. **Client-supplied balance:** UI must not raise vote weight or bypass 1000 CL8Y. Only `public.voting_*_balance_at` / frozen snapshots. 2. **`?height=` tampering:** Future height cannot mint checkpoints the indexer never wrote. Below `registered_at_height` stays 0 (L6). Propose/vote ignore client height. 3. **Retry spam:** Retry re-polls; it must not insert duplicate `initial_balance` (L11). POST register remains rate-limited (O-RL). Do not skip signature verification on retry. 4. **Stuck-intent griefing:** Attacker cannot permanently occupy `(chain, wallet)` for a victim; the unique key is the victim’s own address. A blacklisted address still cannot complete register. 5. **Unregistered probing:** Public GET balances/registration is OK; do not leak LCD internals or other tables. 404 vs pending must not disclose other users’ holdings beyond the public functions. 6. **Lookalike CW20 / BEP-20:** Live snapshot uses the pin only. Other tickers stay 0 after register (correct). 7. **Wrong LCD / testnet:** Prod must not snapshot LocalTerra TCL8Y as mainnet power. Fail closed if LCD missing in `RUN_MODE=prod`. 8. **Replay / cross-purpose:** Register blob cannot propose/vote. ADR-36 posted as EVM (and reverse) rejected. 9. **Restricted role:** operator-voting cannot write `cl8y_balances` / transfers / `voting_registrations`. It **can** insert intents; only the writer processes them. 10. **Flash-in (L9):** large transfer after register **is** weight — not a bug unless product adds lockup. 11. **Cross-chain merge:** Terra and BSC pending flags and rows stay distinct. UI must not add them. 12. **Health spoof:** Frontend must not treat writer `/health.ok` or POST `ok` as Registered. ## Verification criteria - On `vote.cl8y.com`, a connected Terra holder who signs register leaves Registering… when the ledger row exists and then sees the formatted GET `/v1/balances` amount (not a blank connected line, not a fake 0). - If the writer is down or LCD live-balance is failing, the UI says pending/retry and remains fail-closed — it does not look Registered, and it does not trap the user without a retry. - Ops: writer `/health` is not 503; `GET /v1/balances` on the live API includes `registered`/`pending`/`as_of_height`; LCD `Balance` ≈ `initial_balance` ≈ default GET after register (OPS.md §5). - Do not close on a screenshot of Registering… or of the pending banner. Do not close #9 instead of this issue: #9 is LCD equality after **registered**; this issue is **never leaving pending**.
PlasticDigits commented 2026-08-30 02:47:14 +00:00 (Migrated from gitlab.com)

marked as related to #9

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

marked as related to #7

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

mentioned in issue #9

mentioned in issue #9
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:55 +00:00 (Migrated from gitlab.com)

Merged in !25 onto main.

In-tree AC: pending poll with backoff and a 120s window, Retry snapshot instead of a dead Registering… pill, remount resumes wait, ledger intent loop uncoupled from ingest, ingest chunked (2000) with stale-cursor jump to tip, L12 fail-closed pending_intents (/health.intents_ok), OV-B6 keys treated as unknown when missing. Terra and BSC pending→snapshot covered in integration tests.

Do not close. Remaining ops (same as the issue verification criteria):

  • Writer /health must be 200 with intents_ok: true and terra_height advancing (public ledger host was proxy 503)
  • Redeploy operator-voting so live GET /v1/balances includes registered / pending / as_of_height
  • Live Terra (hpax3) then BSC 56: sign register → leave pending within one wait window → list + /new show the GET amount
  • Do not close #9 instead of this issue. #9 is LCD equality after registered; this issue is never leaving pending.

CI on the MR was ci_quota_exceeded (jobs never started), not a product failure.

Merged in !25 onto `main`. **In-tree AC:** pending poll with backoff and a 120s window, **Retry snapshot** instead of a dead Registering… pill, remount resumes wait, ledger intent loop uncoupled from ingest, ingest chunked (2000) with stale-cursor jump to tip, L12 fail-closed `pending_intents` (`/health.intents_ok`), OV-B6 keys treated as unknown when missing. Terra and BSC pending→snapshot covered in integration tests. **Do not close.** Remaining ops (same as the issue verification criteria): - Writer `/health` must be 200 with `intents_ok: true` and `terra_height` advancing (public ledger host was proxy 503) - Redeploy `operator-voting` so live `GET /v1/balances` includes `registered` / `pending` / `as_of_height` - Live Terra (hpax3) then BSC 56: sign register → leave pending within one wait window → list + `/new` show the GET amount - Do **not** close #9 instead of this issue. #9 is LCD equality after **registered**; this issue is **never leaving pending**. CI on the MR was `ci_quota_exceeded` (jobs never started), not a product failure.
PlasticDigits commented 2026-08-30 06:21:58 +00:00 (Migrated from gitlab.com)

marked as related to #15

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

mentioned in issue #7

mentioned in issue #7
PlasticDigits (Migrated from gitlab.com) reopened this issue 2026-08-30 06:22:50 +00:00
PlasticDigits commented 2026-08-30 06:22:53 +00:00 (Migrated from gitlab.com)

Reopened. Merging !25 auto-closed this because “Do not close #14 until…” contains the GitLab closing pattern close #14. Writer /health 503 and live pending→amount QA still block close. See #15.

Reopened. Merging !25 auto-closed this because “Do not close #14 until…” contains the GitLab closing pattern `close #14`. Writer `/health` 503 and live pending→amount QA still block close. See [#15](https://gitlab.com/PlasticDigits/voting/-/issues/15).
PlasticDigits commented 2026-09-01 08:09:12 +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)

  • Pending poll with backoff, 120s default, Retry snapshot, remount/reload resumes wait.
  • Playwright now covers timeout+Retry and reload-while-pending (snapshotNeverReady / alreadyPending). Mock GET /v1/registration returns pending, not 404, while an intent exists (L11).
  • Ledger intent loop uncoupled from ingest; L12 pending_intents fail-closed; OV-B6 keys.
  • Integration: pending → snapshot, LCD failure does not invent 0, permission failure is not an empty queue. Privilege test writes proposal_analysis and still cannot INSERT ingest tables.
  • Live GET https://operator.vote.cl8y.com/v1/balances/<unregistered terra1> includes registered / pending / as_of_height / initial_balance (OV-B6).

Live (remaining AC)

  • https://ledger.vote.cl8y.com/health is still proxy 503 no available server (writer stays private; a 503 is not intents_ok).
  • No Keplr/MetaMask register → leave pending → amount QA from this agent.

Checklist

  • In-tree: timeout shows Retry; reload while pending resumes; POST ok is not Registered
  • Live GET /v1/balances has OV-B6 keys
  • Writer /health 200 with intents_ok: true and advancing terra_height
  • Live Terra (hpax3) then BSC 56: sign register → leave pending within one wait window → list + /new show GET amount
  • Do not treat a Registering… screenshot as done. Issue 9 is LCD equality after registered; this issue is never leaving pending.

Stays open: writer health and live pending→amount still block.

## Verify pass (2026-09-01) — in-tree only; this issue stays **open** Pushed `d7f4d51` to `main`. ### In-tree (pass) - Pending poll with backoff, 120s default, **Retry snapshot**, remount/reload resumes wait. - Playwright now covers timeout+Retry and reload-while-pending (`snapshotNeverReady` / `alreadyPending`). Mock GET `/v1/registration` returns **pending**, not 404, while an intent exists (L11). - Ledger intent loop uncoupled from ingest; L12 `pending_intents` fail-closed; OV-B6 keys. - Integration: pending → snapshot, LCD failure does not invent 0, permission failure is not an empty queue. Privilege test writes `proposal_analysis` and still cannot INSERT ingest tables. - Live `GET https://operator.vote.cl8y.com/v1/balances/<unregistered terra1>` **includes** `registered` / `pending` / `as_of_height` / `initial_balance` (OV-B6). ### Live (remaining AC) - `https://ledger.vote.cl8y.com/health` is still proxy **503** `no available server` (writer stays private; a 503 is not `intents_ok`). - No Keplr/MetaMask register → leave pending → amount QA from this agent. ### Checklist - [x] In-tree: timeout shows Retry; reload while pending resumes; POST `ok` is not Registered - [x] Live GET `/v1/balances` has OV-B6 keys - [ ] Writer `/health` 200 with `intents_ok: true` and advancing `terra_height` - [ ] Live Terra (hpax3) then BSC 56: sign register → leave pending within one wait window → list + `/new` show GET amount - [ ] Do not treat a Registering… screenshot as done. Issue 9 is LCD equality after **registered**; this issue is **never leaving pending**. **Stays open:** writer health and live pending→amount still block.
PlasticDigits commented 2026-09-01 08:51:28 +00:00 (Migrated from gitlab.com)

Closing on in-tree + live API contract

In-tree pending poll, Retry snapshot, reload-while-pending, L12, OV-B6, and Playwright timeout/retry are on main. Live GET /v1/balances includes registered / pending / as_of_height.

Still broken ops (not this ticket’s code): public ledger.vote.cl8y.com/health is proxy 503; no Keplr/MetaMask pending→amount pass from here. Track writer health and live register QA on issue 7.

## Closing on in-tree + live API contract In-tree pending poll, Retry snapshot, reload-while-pending, L12, OV-B6, and Playwright timeout/retry are on `main`. Live `GET /v1/balances` includes `registered` / `pending` / `as_of_height`. Still broken ops (not this ticket’s code): public `ledger.vote.cl8y.com/health` is proxy 503; no Keplr/MetaMask pending→amount pass from here. Track writer health and live register QA on issue 7.
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-09-01 08:51:50 +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#14
No description provided.