Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,21 @@ import StakingIcon from 'src/images/icons/staking.svg';
import UnlockIcon from 'src/images/icons/unlock.svg';
import WithdrawIcon from 'src/images/icons/withdraw.svg';
import { shortenAddress } from 'src/utils/addresses';
import { useAddressParam } from 'src/utils/useAddressParam';
import { usePageInvariant } from 'src/utils/navigation';
import { useIsMiniPay } from 'src/utils/useIsMiniPay';
import { StakingMode, useStakingMode } from 'src/utils/useStakingMode';
import useTabs from 'src/utils/useTabs';
import { Address } from 'viem';
import { useAccount } from 'wagmi';

export default function Page() {
const account = useAccount();
const address = account?.address;
usePageInvariant(!!address, '/');
const addressOverride = useAddressParam();
const address = addressOverride || account?.address;
const isReadOnly = !!addressOverride;
const isMiniPay = useIsMiniPay();
usePageInvariant(!!address || isMiniPay, '/');

const { signingFor, isVoteSigner } = useVoteSignerToAccount(address);
const { balance: walletBalance } = useBalance(signingFor);
Expand Down Expand Up @@ -94,7 +99,13 @@ export default function Page() {
<h2>Total Balance</h2>
<Amount valueWei={totalBalance} className="-mt-1 text-3xl md:text-4xl" />
</div>
{isVoteSigner ? (
{isReadOnly ? (
<div className="align-right flex flex-col items-end">
Comment on lines +102 to +103
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep vote-signer banner when rendering read-only account

This new isReadOnly branch masks the existing vote-signer UI path, so /?address=<vote-signer> now shows “Viewing account ” even though most balances and tables are still derived from signingFor (via useBalance(signingFor), useLockedStatus(signingFor), etc.). In that scenario the page labels one account while rendering another account’s staking/governance data, which is misleading for shared read-only links. Preserve the vote-signer context (or align data sourcing with the displayed address) in read-only mode.

Useful? React with 👍 / 👎.

<h2 className="font-medium text-sm text-taupe-600">Viewing account</h2>
<span className="hidden font-mono text-sm md:flex">{address}</span>
<span className="font-mono text-sm md:hidden">{shortenAddress(address!)}</span>
</div>
) : isVoteSigner ? (
<div className="align-right flex flex-col items-end">
<h2 className="font-medium">Vote Signer For</h2>
<span className="hidden font-mono text-sm md:flex">{signingFor}</span>
Expand All @@ -104,6 +115,7 @@ export default function Page() {
<LockButtons className="hidden md:flex" mode={mode} />
)}
</div>
{isMiniPay && !isReadOnly && totalLocked === 0n && <StakeCeloCta />}
{mode === 'CELO' ? (
<AccountStats
walletBalance={walletBalance}
Expand All @@ -119,7 +131,9 @@ export default function Page() {
scheduledWithdrawalAmount={withdrawals.scheduledWithdrawalAmount}
/>
)}
{isVoteSigner || <LockButtons className="flex justify-between md:hidden" mode={mode} />}
{!isReadOnly && !isVoteSigner && (
<LockButtons className="flex justify-between md:hidden" mode={mode} />
)}
Comment on lines +134 to +136
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Disable write actions when account is marked read-only

In read-only mode (address query param present), this change hides only the lock buttons but still renders the transactional tables below. Those tables expose actions like stake/unstake/activate via useTransactionModal (see ActiveStakesTable), and TransactionFlow executes against the connected wallet from useAccount(), not the overridden address being viewed. That creates a misleading state where users can initiate wallet transactions while viewing another account’s data.

Useful? React with 👍 / 👎.

<TableTabs
groupToStake={groupToStake}
addressToGroup={addressToGroup}
Expand Down Expand Up @@ -405,6 +419,32 @@ function TableTabs({
);
}

function StakeCeloCta() {
const showTxModal = useTransactionModal();

return (
<div className="space-y-3 border border-taupe-300 bg-white px-3 py-4 md:px-5 md:py-5">
<h3 className="font-serif text-xl sm:text-2xl">Stake your CELO</h3>
<p className="text-sm sm:text-base">
Earn ~2% annually in exchange for agreeing to a three-day lockup. Staked CELO also gives you
access to participate in Celo Governance decisions so you can help shape the future of Celo,
the network powering MiniPay.
</p>
<SolidButton
onClick={() =>
showTxModal(TransactionFlowType.StakeStCELO, { action: StakeActionType.Stake })
}
Comment on lines +434 to +436
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Open CELO lock flow from the MiniPay stake CTA

The new StakeCeloCta button opens TransactionFlowType.StakeStCELO, but Governance visibility in MiniPay is gated by lockedBalance > 0n in src/components/nav/NavBar.tsx. In the MiniPay scenario this means a user with no locked CELO can follow this CTA, complete staking, and still never satisfy the nav gate (because stCELO deposit does not increase LockedGold), so the CTA fails to unlock the Governance access it promises.

Useful? React with 👍 / 👎.

className="bg-purple-300 text-white"
>
<div className="flex items-center space-x-1.5">
<Image src={StakingIcon} width={12} height={12} alt="" />
<span>Stake</span>
</div>
</SolidButton>
</div>
);
}

function AccountPageSkeleton() {
return (
<>
Expand Down
18 changes: 17 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useEffect, useMemo } from 'react';
import { Fade } from 'src/components/animation/Fade';
import { SkeletonBlock } from 'src/components/animation/Skeleton';
import { SolidButton } from 'src/components/buttons/SolidButton';
Expand All @@ -17,6 +18,21 @@ import { useIsMiniPay } from 'src/utils/useIsMiniPay';
import { useStakingMode } from 'src/utils/useStakingMode';

export default function Page() {
const isMiniPay = useIsMiniPay();
const router = useRouter();

useEffect(() => {
if (isMiniPay) {
router.replace('/account');
Comment on lines +25 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate MiniPay home redirect on wallet availability

This redirect is unconditional for MiniPay, so it also runs when no wallet address is connected (or before wagmi finishes hydrating). In that state, /account immediately redirects back to / via usePageInvariant(!!address, '/') in src/app/account/page.tsx, creating a //account navigation loop for MiniPay users and leaving no stable landing page. Redirecting only after account readiness (or when address is present) avoids that loop.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve minipay override when redirecting from home

The home redirect drops the current query string, so a session opened at /?minipay is immediately sent to /account without minipay, and useIsMiniPay() on the destination page falls back to window.ethereum?.isMiniPay. In a normal browser this disables the override after one navigation (and can bounce users back to / when no wallet is connected), which breaks the new query-param MiniPay emulation flow.

Useful? React with 👍 / 👎.

}
}, [isMiniPay, router]);

if (isMiniPay) return null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid running validator query before MiniPay early return

MiniPay now redirects away from / and returns null, but this happens only after useValidatorGroups() runs, so each MiniPay home visit still triggers the full validator-group fetch path and its error-toast side effects (useToastError in src/features/validators/useValidatorGroups.ts) for data that is never rendered. This adds avoidable RPC load and can show irrelevant errors during redirect.

Useful? React with 👍 / 👎.


return <StakingPage />;
}

function StakingPage() {
const { groups, totalVotes } = useValidatorGroups();

return (
Expand Down
9 changes: 8 additions & 1 deletion src/components/nav/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useCallback } from 'react';
import { ChevronIcon } from 'src/components/icons/Chevron';
import { CeloGlyph } from 'src/components/logos/Celo';
import { DropdownMenu } from 'src/components/menus/Dropdown';
import { useLockedBalance } from 'src/features/account/hooks';
import Bridge from 'src/images/icons/bridge.svg';
import Dashboard from 'src/images/icons/dashboard.svg';
import Delegate from 'src/images/icons/delegate.svg';
Expand All @@ -18,7 +19,7 @@ import { useAccount } from 'wagmi';

const LINKS = (isWalletConnected?: boolean) => [
{ label: 'Staking', to: '/', icon: Staking },
{ label: 'Governance', to: '/governance', icon: Governance },
{ label: 'Governance', to: '/governance', icon: Governance, hideInMiniPayUntilStaked: true },
{ label: 'Delegate', to: '/delegate', icon: Delegate, hideInMiniPay: true },
{ label: 'Bridge', to: '/bridge', icon: Bridge, hideInMiniPay: true },
{ label: 'Names', to: 'https://names.celo.org', icon: ENS, hideInMiniPay: true },
Expand All @@ -30,6 +31,8 @@ export function NavBar({ collapsed }: { collapsed?: boolean }) {
const { address } = useAccount();
const trackEvent = useTrackEvent();
const isMiniPay = useIsMiniPay();
const { lockedBalance } = useLockedBalance(address);
const hasStaked = lockedBalance > 0n;
Comment on lines +34 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate locked-balance polling to MiniPay-only rendering

The navbar now calls useLockedBalance(address) unconditionally for every connected user, but hasStaked is only used for the MiniPay-specific governance filter. Because useLockedBalance enables periodic refetching (BALANCE_REFRESH_INTERVAL, 5s) and emits toast errors on failures, non-MiniPay users now incur continuous extra RPC reads and can see unrelated error toasts for a feature branch they never use.

Useful? React with 👍 / 👎.

Comment on lines +34 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compute MiniPay staking gate from signing account

The MiniPay Governance gate is based on useLockedBalance(address), which checks only the connected wallet, but governance eligibility in this codebase is resolved through vote-signer indirection (useVoteSignerToAccount in src/features/governance/hooks/useVotingStatus.ts and src/features/transactions/TransactionFlow.tsx). If a MiniPay wallet is a vote signer for another staked account, address can have 0n locked balance while the signing account still has voting power, so this nav filter hides Governance for a user who can otherwise participate.

Useful? React with 👍 / 👎.


const handleNavClick = useCallback(
(item: string) => {
Expand All @@ -43,6 +46,7 @@ export function NavBar({ collapsed }: { collapsed?: boolean }) {
<ul className="flex list-none items-center justify-center space-x-6 overflow-hidden">
{LINKS(!!address)
.filter((l) => !(isMiniPay && l.hideInMiniPay))
.filter((l) => !(isMiniPay && l.hideInMiniPayUntilStaked && !hasStaked))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate MiniPay governance on CELO stake, not stCELO balance

The new MiniPay Governance filter depends on hasStaked, but hasStaked is derived only from useStCELOBalance; MiniPay users are forced into CELO mode (src/utils/useStakingMode.tsx:23) and stake through the CELO flow, so they can complete staking while stCELOBalances.total remains 0n. In that path, Governance stays hidden indefinitely instead of reappearing after the user has staked.

Useful? React with 👍 / 👎.

.map((l) => {
const isSelected = l.to === pathname || (l.to !== '/' && pathname?.startsWith(l.to));

Expand Down Expand Up @@ -77,6 +81,8 @@ export function MobileNavDropdown({ className }: { className?: string }) {
const { address } = useAccount();
const trackEvent = useTrackEvent();
const isMiniPay = useIsMiniPay();
const { lockedBalance } = useLockedBalance(address);
const hasStaked = lockedBalance > 0n;

const handleNavClick = useCallback(
(item: string) => {
Expand All @@ -97,6 +103,7 @@ export function MobileNavDropdown({ className }: { className?: string }) {
menuClasses="space-y-8 py-6 px-8"
menuItems={LINKS(!!address)
.filter((l) => !(isMiniPay && l.hideInMiniPay))
.filter((l) => !(isMiniPay && l.hideInMiniPayUntilStaked && !hasStaked))
.map((l) => {
return (
<Link
Expand Down
3 changes: 2 additions & 1 deletion src/features/locking/useLockedStatus.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { lockedGoldABI } from '@celo/abis';
import { useQuery } from '@tanstack/react-query';
import { useToastError } from 'src/components/notifications/useToastError';
import { GCTime, StaleTime } from 'src/config/consts';
import { BALANCE_REFRESH_INTERVAL, GCTime, StaleTime } from 'src/config/consts';
import { Addresses } from 'src/config/contracts';
import { useAccountDetails } from 'src/features/account/hooks';
import { LockedStatus, PendingWithdrawal } from 'src/features/locking/types';
Expand All @@ -24,6 +24,7 @@ export function useLockedStatus(address?: Address) {
},
gcTime: GCTime.Short,
staleTime: StaleTime.Short,
refetchInterval: BALANCE_REFRESH_INTERVAL,
});

useToastError(error, 'Error fetching locked balances and withdrawals');
Expand Down
13 changes: 7 additions & 6 deletions src/features/transactions/TransactionFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export function TransactionFlow<FormDefaults extends {}>({
votingPower.isLoading
) {
Component = <SpinnerWithLabel className="py-20">Loading account data...</SpinnerWithLabel>;
} else if (confirmationDetails) {
Component = (
<TransactionConfirmation confirmation={confirmationDetails} closeModal={closeModal} />
);
} else if (!isRegistered && !isVoteSigner && !requiresStCelo && mode === 'CELO') {
Component = <AccountRegisterForm refetchAccountDetails={refetchAccountDetails} />;
} else if (
Expand All @@ -74,10 +78,11 @@ export function TransactionFlow<FormDefaults extends {}>({
!isVoteSigner &&
!willVoteAndHasVotingPower
) {
Component = <LockForm showTip={true} />;
header = 'Lock CELO';
Component = <LockForm showTip={true} onConfirmed={onConfirmed} />;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve original action after prerequisite lock completes

Passing onConfirmed into the prerequisite LockForm changes the flow for Stake, Delegate, Upvote, and Vote when the user has no locked CELO: after a successful lock, confirmationDetails is set and TransactionFlow switches to TransactionConfirmation (which closes/reloads) instead of returning to the originally requested form. This regresses the existing one-modal progression and forces users to restart the action (often losing context like proposal-specific voting intent).

Useful? React with 👍 / 👎.

} else if (requiresStCelo && stCELOBalances.total <= 0n) {
Component = <StakeStCeloForm showTip={true} />;
} else if (!confirmationDetails) {
} else {
const action = (defaultFormValues as any).action as string;
if (action) {
if (action === DelegateActionType.Transfer) {
Expand All @@ -92,10 +97,6 @@ export function TransactionFlow<FormDefaults extends {}>({
}

Component = <FormComponent defaultFormValues={defaultFormValues} onConfirmed={onConfirmed} />;
} else {
Component = (
<TransactionConfirmation confirmation={confirmationDetails} closeModal={closeModal} />
);
}

return (
Expand Down
12 changes: 12 additions & 0 deletions src/utils/useAddressParam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useState } from 'react';
import { isAddress } from 'viem';

export function useAddressParam(): Address | undefined {
const [address] = useState(() => {
if (typeof window === 'undefined') return undefined;
const param = new URLSearchParams(window.location.search).get('address');
if (param && isAddress(param)) return param as Address;
Comment on lines +5 to +8
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-read address query param on navigation updates

useAddressParam snapshots window.location.search once in useState and never updates it, so client-side transitions between different /account?address=... URLs will keep showing the first address until a full reload. This can leave the account page showing stale balances/labels after in-app navigation or browser history changes.

Useful? React with 👍 / 👎.

return undefined;
});
return address;
}
9 changes: 7 additions & 2 deletions src/utils/useIsMiniPay.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useState } from 'react';

export function useIsMiniPay() {
// @ts-ignore
const [isMiniPay] = useState(() => typeof window !== 'undefined' && !!window.ethereum?.isMiniPay);
const [isMiniPay] = useState(() => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
if (params.has('minipay')) return params.get('minipay') !== 'false';
Comment on lines +4 to +7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-read MiniPay override when the URL query changes

This hook snapshots window.location.search once in component state and never updates it, so client-side transitions (or back/forward) between URLs that differ in ?minipay keep using the stale initial value. As a result, MiniPay-gated behavior (redirects, nav filtering, fee-currency logic, mode forcing) can remain incorrect after navigation.

Useful? React with 👍 / 👎.

// @ts-ignore
return !!window.ethereum?.isMiniPay;
});
return isMiniPay;
}
Loading