Add contract ABIs for QuantaPool frontend#21
Conversation
Minimal Lido-style web app for the liquid staking protocol, built on the MyQRLWallet design system (Vite 7, React 19, TypeScript, MobX, Tailwind 4, Radix primitives, dark theme + QRL orange). - Stake page: deposit QRL -> stQRL with live preview, position card, FAQ - Withdrawals page: request (128-block delay), claim (FIFO), cancel - Stats page: pool, validator funding progress, rewards, contract links - How-it-works page: plain-language user documentation - MobX poolStore: read-only RPC + EIP-6963 QRL Wallet extension signing (qrl_requestAccounts / qrl_sendTransaction), receipt polling for 60s blocks - Testnet contract addresses default from config/testnet-hyperion.json
Correctness (from 7-angle review): - Show the exact snapshot payout (withdrawalRequests.qrlAmount) for pending withdrawals instead of the drifting current share value — claimWithdrawal pays the snapshot, not the current rate - Disable withdrawal requests while the contract is paused (requestWithdrawal is whenNotPaused; previously only deposits were gated) - Guard refreshAccount against resurrecting state after disconnect/account switch mid-fetch - Move parseUnits inside the tx pipeline so invalid amounts surface as a failed tx instead of an unhandled rejection - Serialize transactions at the store level (single pending tx slot) - Handle provider accountsChanged events (switch account or disconnect) Efficiency / cleanup: - Cache contract instances; cache immutable (claimed/cancelled) withdrawal requests so the 30s poll stops refetching history - Skip background refresh while the tab is hidden; receipt polling 10s - Guard init() against StrictMode double-invoke - Replace hardcoded #4aafff with the blue-accent theme token everywhere - stakeableBalance (gas reserve) moved from page into the store - dismissConnectError store action instead of component-side mutation Explorer integration: - Fetch QRL/USD from zondscan /api/overview (same endpoint as myqrlwallet) and show USD values for TVL and the user's position
Stakers can now pull up their staking history in-app: Deposited, WithdrawalRequested, WithdrawalClaimed and WithdrawalCancelled events (all indexed by user) are merged into a newest-first activity card on the Stake page, each entry linking to the transaction on zondscan, with a direct 'View on Zondscan' link for the address (also added to the header address chip). Degrades gracefully when the RPC proxy doesn't expose log queries.
There was a problem hiding this comment.
Code Review
This pull request introduces the frontend for the QuantaPool liquid staking protocol on the QRL network, including pages for staking, withdrawals, and stats, built with React, MobX, and TailwindCSS. The review feedback focuses on a critical performance optimization to fetch only active/pending withdrawal requests instead of the entire history, thereby preventing excessive RPC overhead and potential browser crashes. Additionally, a bug in the blocksToTime utility was identified and corrected to prevent awkward time formatting.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const total = Number(asBig(counts.total)); | ||
| const requests = await Promise.all( | ||
| Array.from({ length: total }, (_, id) => | ||
| this.fetchWithdrawalRequest(pool, address, id), | ||
| ), | ||
| ); | ||
|
|
||
| runInAction(() => { | ||
| // The user may have disconnected or switched accounts while we were | ||
| // fetching — don't resurrect stale state. | ||
| if (!this.account || this.account.address !== address) return; | ||
| this.account = { | ||
| address, | ||
| qrlBalance: asBig(qrlBalance), | ||
| shares: asBig(shares), | ||
| lockedShares: asBig(lockedShares), | ||
| qrlValue: asBig(qrlValue), | ||
| }; |
There was a problem hiding this comment.
Fetching all historical withdrawal requests from index 0 to total - 1 results in an nextIndex (which is total - pending) to total - 1.
const total = Number(asBig(counts.total));
const pending = Number(asBig(counts.pending));
const nextIndex = total - pending;
const requests = await Promise.all(
Array.from({ length: pending }, (_, i) =>
this.fetchWithdrawalRequest(pool, address, nextIndex + i),
)
);
runInAction(() => {
// The user may have disconnected or switched accounts while we were
// fetching — don't resurrect stale state.
if (!this.account || this.account.address !== address) return;
this.account = {
address,
qrlBalance: asBig(qrlBalance),
shares: asBig(shares),
lockedShares: asBig(lockedShares),
qrlValue: asBig(qrlValue),
completedWithdrawalsCount: nextIndex,
};There was a problem hiding this comment.
All five review items addressed in dea9915.
Bounded withdrawal fetch (this thread + the 3 related ones): refreshAccount now fetches only the live tail [nextIndex, total) where nextIndex = total - pending. Verified against the contract — getWithdrawalRequestCount returns pending = total - nextWithdrawalIndex, so nextIndex is exactly nextWithdrawalIndex, and everything below it is claimed/cancelled and immutable. The per-poll fan-out is now bounded by pending requests instead of full history. Added completedWithdrawalsCount (= nextIndex) to AccountState and switched the "N completed withdrawals" line to it, dropping the claimed filter over the (no-longer-fully-fetched) array.
blocksToTime: now rounds to whole minutes before splitting hours/minutes, so it can't render ≈ 60m or 1h 60m.
Build + lint are green. (As with PR #20, forge/runtime aren't reachable from my sandbox, but the frontend tsc build and ESLint pass.)
Generated by Claude Code
| const pending = poolStore.pendingWithdrawals; | ||
| const claimableCount = poolStore.claimableWithdrawals.length; | ||
| const claimed = poolStore.withdrawals.filter((w) => w.claimed); |
There was a problem hiding this comment.
Remove the unused claimed filter since we will now use the optimized completedWithdrawalsCount from the account state to display the completed withdrawals count.
| const pending = poolStore.pendingWithdrawals; | |
| const claimableCount = poolStore.claimableWithdrawals.length; | |
| const claimed = poolStore.withdrawals.filter((w) => w.claimed); | |
| const pending = poolStore.pendingWithdrawals; | |
| const claimableCount = poolStore.claimableWithdrawals.length; |
| {claimed.length > 0 && ( | ||
| <p className="text-center text-xs text-muted-foreground"> | ||
| {claimed.length} completed withdrawal{claimed.length === 1 ? "" : "s"} | ||
| </p> | ||
| )} |
There was a problem hiding this comment.
Use account.completedWithdrawalsCount instead of claimed.length to display the completed withdrawals count, avoiding the need to fetch all historical requests.
| {claimed.length > 0 && ( | |
| <p className="text-center text-xs text-muted-foreground"> | |
| {claimed.length} completed withdrawal{claimed.length === 1 ? "" : "s"} | |
| </p> | |
| )} | |
| {account.completedWithdrawalsCount > 0 && ( | |
| <p className="text-center text-xs text-muted-foreground"> | |
| {account.completedWithdrawalsCount} completed withdrawal{account.completedWithdrawalsCount === 1 ? "" : "s"} | |
| </p> | |
| )} |
| export interface AccountState { | ||
| address: string; | ||
| qrlBalance: bigint; | ||
| /** stQRL share balance (stable; balanceOf semantics). */ | ||
| shares: bigint; | ||
| /** Shares locked by pending withdrawal requests. */ | ||
| lockedShares: bigint; | ||
| /** Current QRL value of all shares. */ | ||
| qrlValue: bigint; | ||
| } |
There was a problem hiding this comment.
Add completedWithdrawalsCount to AccountState to keep track of the total number of completed/processed withdrawals without needing to fetch all historical requests via RPC.
| export interface AccountState { | |
| address: string; | |
| qrlBalance: bigint; | |
| /** stQRL share balance (stable; balanceOf semantics). */ | |
| shares: bigint; | |
| /** Shares locked by pending withdrawal requests. */ | |
| lockedShares: bigint; | |
| /** Current QRL value of all shares. */ | |
| qrlValue: bigint; | |
| } | |
| export interface AccountState { | |
| address: string; | |
| qrlBalance: bigint; | |
| /** stQRL share balance (stable; balanceOf semantics). */ | |
| shares: bigint; | |
| /** Shares locked by pending withdrawal requests. */ | |
| lockedShares: bigint; | |
| /** Current QRL value of all shares. */ | |
| qrlValue: bigint; | |
| completedWithdrawalsCount: number; | |
| } |
| export function blocksToTime(blocks: bigint | number, blockTimeSeconds: number): string { | ||
| const totalSeconds = Number(blocks) * blockTimeSeconds; | ||
| if (totalSeconds <= 0) return "now"; | ||
| const hours = Math.floor(totalSeconds / 3600); | ||
| const minutes = Math.ceil((totalSeconds % 3600) / 60); | ||
| if (hours > 0) return `≈ ${hours}h ${minutes}m`; | ||
| return `≈ ${minutes}m`; | ||
| } |
There was a problem hiding this comment.
The current implementation of blocksToTime can display awkward values like ≈ 60m or ≈ 1h 60m due to rounding with Math.ceil on minutes independently of hours. Converting to total minutes first and then calculating hours and minutes prevents this issue.
| export function blocksToTime(blocks: bigint | number, blockTimeSeconds: number): string { | |
| const totalSeconds = Number(blocks) * blockTimeSeconds; | |
| if (totalSeconds <= 0) return "now"; | |
| const hours = Math.floor(totalSeconds / 3600); | |
| const minutes = Math.ceil((totalSeconds % 3600) / 60); | |
| if (hours > 0) return `≈ ${hours}h ${minutes}m`; | |
| return `≈ ${minutes}m`; | |
| } | |
| export function blocksToTime(blocks: bigint | number, blockTimeSeconds: number): string { | |
| const totalSeconds = Number(blocks) * blockTimeSeconds; | |
| if (totalSeconds <= 0) return "now"; | |
| const totalMinutes = Math.ceil(totalSeconds / 60); | |
| const hours = Math.floor(totalMinutes / 60); | |
| const minutes = totalMinutes % 60; | |
| if (hours > 0) return `≈ ${hours}h ${minutes}m`; | |
| return `≈ ${minutes}m`; | |
| } |
…unding - refreshAccount now fetches only the live withdrawal tail [nextIndex, total) instead of every historical request. nextIndex = total - pending equals the contract's nextWithdrawalIndex, and indices below it are claimed/cancelled and immutable, so the per-poll RPC fan-out is bounded by pending requests rather than a user's entire withdrawal history (avoids hundreds of concurrent calls / rate-limiting for active accounts). - track completedWithdrawalsCount (= nextIndex) on AccountState and use it for the 'N completed withdrawals' line, instead of filtering the (no-longer-fully- fetched) request array for claimed entries. - blocksToTime: round to whole minutes before splitting hours/minutes so it can no longer render '≈ 60m' or '1h 60m'.
Summary
Adds
frontend/— a staking web app for QuantaPool (merged intodev).Stake QRL → receive stQRL, request/claim/cancel withdrawals, and track pool + validator stats. Built to match the MyQRLWallet design system: Vite 7, React 19, TypeScript, MobX, TailwindCSS 4, Radix primitives.
What's included
DepositPoolV2,StQRLV2,ValidatorManager(generated fromcontracts/solidity; regen instructions infrontend/README.md)theqrl.orgrdns) usingqrl_requestAccounts/qrl_sendTransaction— same flow as myqrlwallet-frontend. MyQRLWallet mobile WebView detected via User-Agent; structured so the myqrlwallet-connect SDK can slot in later.src/config/networks.tsmirrorsconfig/testnet-hyperion.json); all endpoints/addresses overridable viaVITE_*env varsReview fixes applied in-branch
dea9915)9a269fd)Testing
npm run build(type-check + production build) andnpm run lint(zero-warnings) green