fix(voting): registration stays pending; no CL8Y amount after snapshot timeout #14
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 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:That string is thrown only after
waitForLedgerRegistrationpollsGET /v1/registration/:addrfor 30s without avoting_registrationsrow. 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
Balancein 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/registerverifies ADR-36 (Terra) or EIP-191 (BSC), insertsvoting.signatures+voting.registration_intents(ON CONFLICT (chain, wallet_address) DO NOTHING). Returnspending: trueuntilvoting_registrationsexists (operator-voting/src/api.rs).voting-ledgerwrites the snapshot:process_pending_intents→ live LCDBalance/balanceOfat current height/block →voting_registrations.initial_balance(ledger/src/register.rs). LCD/balanceOffailure is logged and retried; it does not writeinitial_balance = 0.POLL_INTERVAL_MS). LCD HTTP timeout is 20s per URL (ledger/src/lcd.rs). Intents are processed at the start ofpoll_once; Terra ingest then walkslast+1..=tipwith no chunk cap (BSC is capped at 2000 blocks). A long ingest means no further intent processing until thatpoll_oncereturns.pending_intents/mark_intent_processedswallow any sqlx error whose message containsvoting.registration_intentsand 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/:addrreturnspending: { terra, bsc }while an intent is in flight; 404 only when there is neither a ledger row nor a pending intent.GET /v1/balances/:addronmainincludesregistered,pending,initial_balance,as_of_height. Default height ismax(indexer tip, registered_at_height). Unregistered/pending still returns HTTP 200 withbalance: "0"; the UI must not present that as a live holding.Frontend (post-#9 UX)
useVotingSnapshot/snapshotBalanceRaw: amounts render only whenregistered === true. Pending and unregistered setbalancetonull— so the list shows no· N CL8Y. That is why 0 disappeared.completeRegistersets statuspending, thenwaitForLedgerRegistration(1s interval, 30s timeout). On timeout it throws the exact live banner and skipsrefresh().VoteListPage:status === 'pending' || busyreplaces the Register button with Registering…. After timeout,busyis false but status stayspending→ no Retry. Copy says “then retry” with no control.refresh()runs once. Ifpendingis still true, status is pending again and nobody polls. There is nosetIntervalfor in-flight intents.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 allowshttps://vote.cl8y.com.GET /v1/proposals→[].GET /v1/balances/<unregistered terra1>→{ chain, address, balance: "0", height: 30162104 }withoutregistered,pending,as_of_height,initial_balance.mainalways emits those keys. Coolifyoperator-votingmay be a pre-#9 image while the dApp (bundleindex-B4ChX-rT.js, HTML last-modified 2026-08-29 15:23 UTC) is post-#9.https://ledger.vote.cl8y.com/health→ 503no available server(proxy has no backend). If that hostname is the writer, no process is callingprocess_pending_intents, which matches a stuck intent. Confirm the real Coolify service name if DNS differs.height: 30162104on the balance probe meansindexer_state.last_indexed_heightwas 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
0as a live wallet balance. The follow-up called out in the #9 thread is now the live failure: pending Registering… only polls for 30s insidecompleteRegister; on timeout the CTA stays hidden; a reload while the intent is pending does not keep polling.If
voting_registrationsnever appears (ledger down, LCD live-balance failing, ingest starving the poll loop, or silent emptypending_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
balanceOfat register + transfers forward only. Nox-cosmos-block-heighthistorical spoof.VITE_*BSC RPC (or Terra LCD) in the browser for CL8Y balance reads. Indexer ownseth_call/eth_getLogs/ CW20Balance.balanceOffails, retry the intent; do not write 0 as a fake success to unblock the UI.terra1…and0x…. Do not sum cross-chain.operator-votingstays on the restricted role:EXECUTEpublic.voting_*_balance_atonly; no writer on ingest tables (L10). Ledger writer must be able toSELECT/UPDATEvoting.registration_intents.POST /v1/registerokalone.ADMIN_TOKENor 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.tsxfrontend/e2e/vote.spec.ts,frontend/e2e/operatorMock.tsfrontend/src/services/__tests__/operatorVoting.test.ts,frontend/src/pages/VoteListPage.test.tsxoperator-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 / §5operator-voting/tests/api_flow.rs(pending_intent_is_visible_before_ledger_row),ledger/tests/ledger_integration.rsRecommended direction
voting-ledgeris actually running and looping.GET /healthon the writer must be 200 with advancingterra_height/caught_up(treatok: trueas liveness only). A 503 proxy is not a healthy poller. Redeployoperator-votingfrommainso GET/v1/balancesincludesregistered/pending/as_of_height. ConfirmTERRA_LCD_URLandCL8Y_TOKEN_ADDRESS(hpax3). Inspect writer logs forregistration live-balance failed; will retry.status === 'pending', keep polling (backoff, cap, visible last-attempt time). On timeout, show the error and a Retry control that callscompleteRegister/refresh— do not leave Registering… as a dead pill. Reload with a still-pending intent must resume polling, not freeze.GET /v1/registration+GET /v1/balancesuntilregistered: true/ timeout with retry. Still do not claim Registered from POSTok.pending_intentserrors 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 Terralast+1..=tipingest (chunk ingest like BSC, or a separate intent task). Iflast_indexed_heightis a stale low non-zero, do not try to walk millions of heights before the next intent pass./v1/balancesmust match OV-B1–B3. Frontend may treat missingregisteredas “unknown”, never as a live 0.ON CONFLICT DO NOTHINGmeans 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.eth_call/ LCD smart query for the badge.Acceptance criteria
voting_registrationsexists, and then shows the server 18-decimal amount (including a true registered 0). Unregistered/pending still do not look like a live 0.POST /v1/registerokalone never shows Registered or a CL8Y amount./v1/balancesfigure (OV-B5).GET /v1/balancesincludesregistered,pending,as_of_heightas onmain./healthis reachable from ops (not 503). Intents with a working LCD/balanceOfcomplete without manual DB edits.pending_intentspermission/query failures are visible (not silent empty). Restricted role still cannotINSERTledger ingest tables.Test plan (all paths)
API / ledger
/v1/balances— 200,registered: false,pending: false; frontend does not render a live 0.pending: trueand GET balancespending: true,registered: false, amount hidden in UI.process_pending_intentssucceeds: GET registration has the chain row,pending: false; GET balancesregistered: trueandbalanceequalsinitial_balancewhen there are no later checkpoints (OV-B1 clamp if tip lags).balanceOffailure: intent remainsprocessed_at IS NULL; noinitial_balance = 0row; next poll retries.ON CONFLICT DO NOTHING; no double credit (L11). POST returnspending: falseif the ledger row already exists.voting.registration_intents(simulated permission error) — must not look like an empty queue; poll/logs fail closed.balanceOf→ row → GET balances.?chain=mismatch onterra1/0x— 400; no cross-chain sum.Frontend
0 CL8Yas wallet truth.registerPendingTicks).pending: true: polling resumes; amount still hidden.Ops / live (required to close)
/health200,terra_heightadvancing,caught_upafter register (OPS.md §1 / §5)./newshow non-zero (or true zero) matchinginitial_balance≈ LCDBalance≈ GET/v1/balances./v1/balancesJSON matchesmain(hasregistered/pending/as_of_height).Automation
cargo test --workspace --libandLEDGER_TEST_DATABASE_URL=… cargo test --workspace --testscovering pending → snapshot and LCD retry.cd frontend && npm test && npm run test:e2eincluding timeout+retry and remount-while-pending (5 Playwright workers).Test plan (attack, hack, abuse)
public.voting_*_balance_at/ frozen snapshots.?height=tampering: Future height cannot mint checkpoints the indexer never wrote. Belowregistered_at_heightstays 0 (L6). Propose/vote ignore client height.initial_balance(L11). POST register remains rate-limited (O-RL). Do not skip signature verification on retry.(chain, wallet)for a victim; the unique key is the victim’s own address. A blacklisted address still cannot complete register.RUN_MODE=prod.cl8y_balances/ transfers /voting_registrations. It can insert intents; only the writer processes them./health.okor POSTokas Registered.Verification criteria
vote.cl8y.com, a connected Terra holder who signs register leaves Registering… when the ledger row exists and then sees the formatted GET/v1/balancesamount (not a blank connected line, not a fake 0)./healthis not 503;GET /v1/balanceson the live API includesregistered/pending/as_of_height; LCDBalance≈initial_balance≈ default GET after register (OPS.md §5).marked as related to #9
marked as related to #7
mentioned in issue #9
mentioned in merge request !24
mentioned in merge request !25
mentioned in commit
d6b4e01acdmentioned in issue #15
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):
/healthmust be 200 withintents_ok: trueandterra_heightadvancing (public ledger host was proxy 503)operator-votingso liveGET /v1/balancesincludesregistered/pending/as_of_height/newshow the GET amountCI on the MR was
ci_quota_exceeded(jobs never started), not a product failure.marked as related to #15
mentioned in issue #7
Reopened. Merging !25 auto-closed this because “Do not close #14 until…” contains the GitLab closing pattern
close #14. Writer/health503 and live pending→amount QA still block close. See #15.Verify pass (2026-09-01) — in-tree only; this issue stays open
Pushed
d7f4d51tomain.In-tree (pass)
snapshotNeverReady/alreadyPending). Mock GET/v1/registrationreturns pending, not 404, while an intent exists (L11).pending_intentsfail-closed; OV-B6 keys.proposal_analysisand still cannot INSERT ingest tables.GET https://operator.vote.cl8y.com/v1/balances/<unregistered terra1>includesregistered/pending/as_of_height/initial_balance(OV-B6).Live (remaining AC)
https://ledger.vote.cl8y.com/healthis still proxy 503no available server(writer stays private; a 503 is notintents_ok).Checklist
okis not Registered/v1/balanceshas OV-B6 keys/health200 withintents_ok: trueand advancingterra_height/newshow GET amountStays open: writer health and live pending→amount still block.
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. LiveGET /v1/balancesincludesregistered/pending/as_of_height.Still broken ops (not this ticket’s code): public
ledger.vote.cl8y.com/healthis proxy 503; no Keplr/MetaMask pending→amount pass from here. Track writer health and live register QA on issue 7.