Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
151 changes: 68 additions & 83 deletions apps/provider-console/src/components/layout/WalletStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,90 @@
"use client";
import React, { useState } from "react";
import { FormattedNumber } from "react-intl";
import {
Address,
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Spinner,
Tooltip,
TooltipContent,
TooltipTrigger
} from "@akashnetwork/ui/components";
import { LogOut, MoreHoriz, Wallet } from "iconoir-react";
import Link from "next/link";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, Spinner } from "@akashnetwork/ui/components";
import { cn } from "@akashnetwork/ui/utils";
import ClickAwayListener from "@mui/material/ClickAwayListener";
import { NavArrowDown, Wallet } from "iconoir-react";

import { useWallet } from "@src/context/WalletProvider";
import { useTotalWalletBalance } from "@src/hooks/useWalletBalance";
import { udenomToDenom } from "@src/utils/mathHelpers";
import { FormattedDecimal } from "../shared/FormattedDecimal";
import { ConnectWalletButton } from "../wallet/ConnectWalletButton";
import { WalletPopup } from "../wallet/WalletPopup";

export function WalletStatus() {
const { walletName, address, walletBalances, logout, isWalletLoaded, isWalletConnected } = useWallet();
const { walletName, walletBalances, isWalletLoaded, isWalletConnected } = useWallet();
const walletBalance = useTotalWalletBalance();
const [open, setOpen] = useState(false);

const onDisconnectClick = () => logout();

const WalletInfo = () => (
<div className="flex items-center pr-2">
<div className="pl-2 pr-2">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHoriz />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onDisconnectClick}>
<LogOut />
&nbsp;Disconnect Wallet
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>

<div className="flex items-center text-left">
<div className="flex items-center text-sm font-bold">
<Wallet className="text-xs" />
<Link className="ml-2 cursor-pointer leading-4" href={`https://stats.akash.network/addresses/${address}`} target="_blank">
<Tooltip>
<TooltipTrigger asChild>
<span>{walletName}</span>
</TooltipTrigger>
<TooltipContent>
<Address address={address} isCopyable disableTooltip />
</TooltipContent>
</Tooltip>
</Link>
</div>

{walletBalances && (
<div className="text-muted-foreground ml-2 flex items-center whitespace-nowrap font-bold">
<Tooltip>
<TooltipTrigger>
<Badge className="h-5 text-xs font-bold" variant="secondary">
<FormattedNumber value={walletBalance} style="currency" currency="USD" />
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="text-base">
<div>
<FormattedDecimal value={udenomToDenom(walletBalances.uakt, 2)} />
<span className="ml-1 text-xs">AKT</span>
</div>
<div>
<FormattedDecimal value={udenomToDenom(walletBalances.usdc, 2)} />
<span className="ml-1 text-xs">USDC</span>
</div>
</div>
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</div>
);
const getSplitText = (text: string, start: number, end: number) => {
if (text.length <= start + end) return text;
return `${text.slice(0, start)}...${text.slice(-end)}`;
};

return (
<>
{isWalletLoaded ? (
isWalletConnected ? (
<WalletInfo />
<div className="flex w-full items-center">
<div className="w-full py-2">
<DropdownMenu modal={false} open={open}>
<DropdownMenuTrigger asChild>
<div
className={cn("bg-background text-foreground flex items-center justify-center rounded-md border px-4 py-2 text-sm")}
onMouseOver={() => setOpen(true)}
>
<div className="flex items-center space-x-2" aria-label="Connected wallet name and balance">
<Wallet className="text-xs" />
{walletName?.length > 20 ? (
<span className="text-xs">{getSplitText(walletName, 4, 4)}</span>
) : (
<span className="text-xs">{walletName}</span>
)}
</div>

{walletBalance > 0 && <div className="px-2">|</div>}

<div className="text-xs">
{walletBalance > 0 && (
<FormattedNumber
value={walletBalance}
// eslint-disable-next-line react/style-prop-object
style="currency"
currency="USD"
/>
)}
</div>

<div>
<NavArrowDown className="ml-2 text-xs" />
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onMouseLeave={() => {
setOpen(false);
}}
>
<ClickAwayListener
onClickAway={() => {
setOpen(false);
}}
>
<div>
<WalletPopup walletBalances={walletBalances} />
</div>
</ClickAwayListener>
</DropdownMenuContent>
Comment on lines +30 to +78
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Restore an accessible trigger for the dropdown.

With the trigger now being a plain <div> that only sets open on onMouseOver, touch users can’t open the wallet menu at all and keyboard users can’t focus/activate it, so they lose the ability to see balances or disconnect. Please use a focusable button (or let the Radix trigger manage state) and wire onOpenChange so pointer, keyboard, and touch interactions all work.

-              <DropdownMenu modal={false} open={open}>
+              <DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
                 <DropdownMenuTrigger asChild>
-                  <div
-                    className={cn("bg-background text-foreground flex items-center justify-center rounded-md border px-4 py-2 text-sm")}
-                    onMouseOver={() => setOpen(true)}
-                  >
+                  <button
+                    type="button"
+                    className={cn("bg-background text-foreground flex items-center justify-center rounded-md border px-4 py-2 text-sm")}
+                    onMouseEnter={() => setOpen(true)}
+                    onFocus={() => setOpen(true)}
+                    onClick={() => setOpen((prev) => !prev)}
+                  >
 ...
-                  </div>
+                  </button>
                 </DropdownMenuTrigger>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<DropdownMenu modal={false} open={open}>
<DropdownMenuTrigger asChild>
<div
className={cn("bg-background text-foreground flex items-center justify-center rounded-md border px-4 py-2 text-sm")}
onMouseOver={() => setOpen(true)}
>
<div className="flex items-center space-x-2" aria-label="Connected wallet name and balance">
<Wallet className="text-xs" />
{walletName?.length > 20 ? (
<span className="text-xs">{getSplitText(walletName, 4, 4)}</span>
) : (
<span className="text-xs">{walletName}</span>
)}
</div>
{walletBalance > 0 && <div className="px-2">|</div>}
<div className="text-xs">
{walletBalance > 0 && (
<FormattedNumber
value={walletBalance}
// eslint-disable-next-line react/style-prop-object
style="currency"
currency="USD"
/>
)}
</div>
<div>
<NavArrowDown className="ml-2 text-xs" />
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onMouseLeave={() => {
setOpen(false);
}}
>
<ClickAwayListener
onClickAway={() => {
setOpen(false);
}}
>
<div>
<WalletPopup walletBalances={walletBalances} />
</div>
</ClickAwayListener>
</DropdownMenuContent>
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("bg-background text-foreground flex items-center justify-center rounded-md border px-4 py-2 text-sm")}
onMouseEnter={() => setOpen(true)}
onFocus={() => setOpen(true)}
onClick={() => setOpen((prev) => !prev)}
>
<div className="flex items-center space-x-2" aria-label="Connected wallet name and balance">
<Wallet className="text-xs" />
{walletName?.length > 20 ? (
<span className="text-xs">{getSplitText(walletName, 4, 4)}</span>
) : (
<span className="text-xs">{walletName}</span>
)}
</div>
{walletBalance > 0 && <div className="px-2">|</div>}
<div className="text-xs">
{walletBalance > 0 && (
<FormattedNumber
value={walletBalance}
// eslint-disable-next-line react/style-prop-object
style="currency"
currency="USD"
/>
)}
</div>
<div>
<NavArrowDown className="ml-2 text-xs" />
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onMouseLeave={() => {
setOpen(false);
}}
>
<ClickAwayListener
onClickAway={() => {
setOpen(false);
}}
>
<div>
<WalletPopup walletBalances={walletBalances} />
</div>
</ClickAwayListener>
</DropdownMenuContent>
🤖 Prompt for AI Agents
In apps/provider-console/src/components/layout/WalletStatus.tsx around lines 30
to 78, the dropdown trigger is a non-focusable div using onMouseOver which
breaks keyboard and touch accessibility; replace the div trigger with a
focusable element (e.g., a <button> via DropdownMenuTrigger asChild) or allow
Radix to manage state, and wire the DropdownMenu onOpenChange prop to update
local open state so pointer, keyboard (Enter/Space), and touch interactions
work; ensure the trigger remains styled the same, is focusable, and includes
appropriate aria-label/role for screen readers.

</DropdownMenu>
</div>
</div>
) : (
<ConnectWalletButton className="w-full md:w-auto" />
)
) : (
<div className="pl-2 pr-2">
<Spinner size="small" />
<div className="flex items-center justify-center p-4">
<Spinner size="medium" />
</div>
)}
</>
Expand Down
67 changes: 67 additions & 0 deletions apps/provider-console/src/components/wallet/WalletPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from "react";
import { FormattedNumber } from "react-intl";
import { Address, Button, Separator } from "@akashnetwork/ui/components";
import { LogOut } from "iconoir-react";

import { usePricing } from "@src/context/PricingProvider";
import { useWallet } from "@src/context/WalletProvider";
import { uAktDenom } from "@src/utils/constants";
import { udenomToDenom } from "@src/utils/mathHelpers";
import { uaktToAKT } from "@src/utils/priceUtils";

interface WalletPopupProps extends React.PropsWithChildren {
walletBalances: { uakt: number; usdc: number } | null;
}

export const WalletPopup: React.FC<WalletPopupProps> = ({ walletBalances }) => {
const { address, logout } = useWallet();
const { isLoaded, getPriceForDenom } = usePricing();

const aktAmount = walletBalances ? uaktToAKT(walletBalances.uakt, 2) : 0;
const aktPrice = getPriceForDenom(uAktDenom);
const aktUsdValue = isLoaded && walletBalances && aktPrice > 0 ? aktAmount * aktPrice : 0;
const usdcAmount = walletBalances ? udenomToDenom(walletBalances.usdc, 2) : 0;

return (
<div className="w-[300px] p-2">
<div className="mb-4">
<Address address={address} isCopyable disableTooltip className="text-foreground flex items-center justify-between text-sm font-bold" showIcon />
</div>

<div className="text-muted-foreground mb-1 text-xs">Wallet Balance</div>
<div className="border-success/10 bg-success/10 text-success dark:border-success/80 dark:bg-success/80 dark:text-foreground mb-4 rounded-md border p-2">
{walletBalances ? (
<>
<div className="flex items-center justify-between space-x-2">
<span className="text-xs">AKT</span>
<span className="flex items-center space-x-1">
{isLoaded && aktPrice ? <FormattedNumber value={aktUsdValue} style="currency" currency="USD" /> : <span className="text-xs">$0.00</span>}
<span className="font-light2 space-x-2 text-xs">({aktAmount} AKT)</span>
</span>
</div>

<Separator className="bg-success/10 my-2 dark:bg-white/20" />

<div className="flex items-center justify-between space-x-2">
<span className="text-xs">USDC</span>
<span>
<FormattedNumber value={usdcAmount} style="currency" currency="USD" />
</span>
</div>
</>
) : (
<div className="space-x-2 text-xs text-white">Wallet Balance is unknown because the blockchain is unavailable</div>
)}
</div>

<div className="text-muted-foreground text-xs">Wallet Actions</div>

<div className="flex flex-col items-center justify-end space-y-2 pt-2">
<Button onClick={logout} variant="outline" className="w-full space-x-2">
<LogOut />
<span>Disconnect Wallet</span>
</Button>
</div>
</div>
);
};
28 changes: 26 additions & 2 deletions apps/provider-console/src/queries/useMarketData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,32 @@ import { useQuery } from "@tanstack/react-query";
import type { MarketData } from "@src/types";
import { QueryKeys } from "./queryKeys";

async function getMarketData(): Promise<any> {
return {};
async function getMarketData(): Promise<MarketData> {
try {
const response = await fetch("https://api.coingecko.com/api/v3/coins/akash-network/tickers");
const data = await response.json();
const coinbasePrice = data.tickers.find((ticker: any) => ticker.market.name === "Coinbase Exchange");
const price = coinbasePrice ? parseFloat(coinbasePrice.converted_last.usd) : 0;

Comment on lines +11 to +13
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Replace any with typed CoinGecko response.

any is disallowed here and it hides the risk of dereferencing missing fields when Coinbase data is absent. Let’s type the response and normalise the USD value before using it so we stay type-safe and avoid toFixed on undefined.

 import type { QueryKey, UseQueryOptions } from "@tanstack/react-query";
 import { useQuery } from "@tanstack/react-query";

 import type { MarketData } from "@src/types";
 import { QueryKeys } from "./queryKeys";
+
+type CoinGeckoTicker = {
+  market: { name: string };
+  converted_last: { usd: number | string };
+};
+
+type CoinGeckoResponse = {
+  tickers: CoinGeckoTicker[];
+};
+
+function extractCoinbaseUsd(tickers: CoinGeckoTicker[]): number | null {
+  const usd = tickers.find((ticker) => ticker.market.name === "Coinbase Exchange")?.converted_last.usd;
+  const numericUsd = typeof usd === "string" ? Number.parseFloat(usd) : usd;
+  return typeof numericUsd === "number" && Number.isFinite(numericUsd) ? numericUsd : null;
+}
 
 async function getMarketData(): Promise<MarketData> {
   try {
-    const response = await fetch("https://api.coingecko.com/api/v3/coins/akash-network/tickers");
-    const data = await response.json();
-    const coinbasePrice = data.tickers.find((ticker: any) => ticker.market.name === "Coinbase Exchange");
-    const price = coinbasePrice ? parseFloat(coinbasePrice.converted_last.usd) : 0;
+    const response = await fetch("https://api.coingecko.com/api/v3/coins/akash-network/tickers");
+    const data: CoinGeckoResponse = await response.json();
+    const price = extractCoinbaseUsd(data.tickers) ?? 0;
 
     return {
       price,
@@
 async function getAKTPrice(): Promise<{ aktPrice: string }> {
   const response = await fetch("https://api.coingecko.com/api/v3/coins/akash-network/tickers");
-  const data = await response.json();
-  const coinbasePrice = data.tickers.find((ticker: any) => ticker.market.name === "Coinbase Exchange");
+  const data: CoinGeckoResponse = await response.json();
+  const usdPrice = extractCoinbaseUsd(data.tickers);
   return {
-    aktPrice: coinbasePrice ? coinbasePrice.converted_last.usd.toFixed(2) : "N/A"
+    aktPrice: usdPrice !== null ? usdPrice.toFixed(2) : "N/A"
   };
 }

As per coding guidelines

Also applies to: 46-48

🤖 Prompt for AI Agents
In apps/provider-console/src/queries/useMarketData.ts around lines 11-13 (and
similarly at lines 46-48), the code uses `any` for the CoinGecko response and
directly dereferences fields which can be absent; define a proper TypeScript
interface for the CoinGecko response (ticker, market.name, converted_last.usd)
and use it instead of `any`, then safely read the USD value using optional
chaining and a default (e.g., const usd = ticker?.converted_last?.usd ?? "0"),
parse/normalize that value to a number (parseFloat or Number) before any math or
toFixed, and finally use the normalized number (or 0) so toFixed is never called
on undefined; update the other occurrences at lines 46-48 the same way.

return {
price,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: 0,
priceChangePercentage24: 0
};
} catch (error) {
console.error("Failed to fetch market data:", error);
return {
price: 0,
volume: 0,
marketCap: 0,
marketCapRank: 0,
priceChange24h: 0,
priceChangePercentage24: 0
};
}
}

export function useMarketData(options?: Omit<UseQueryOptions<MarketData, Error, any, QueryKey>, "queryKey" | "queryFn">) {
Expand Down