diff --git a/app/[addressOrDomain]/page.tsx b/app/[addressOrDomain]/page.tsx index 534a8205..a41c9386 100644 --- a/app/[addressOrDomain]/page.tsx +++ b/app/[addressOrDomain]/page.tsx @@ -12,13 +12,13 @@ import { useAccount } from "@starknet-react/core"; import Blur from "@components/shapes/blur"; import { utils } from "starknetid.js"; import { StarknetIdJsContext } from "@context/StarknetIdJsProvider"; -import { hexToDecimal } from "@utils/feltService"; +import { hexToDecimal, tokenToDecimal } from "@utils/feltService"; import { isHexString, minifyAddress } from "@utils/stringService"; import ProfileCardSkeleton from "@components/skeletons/profileCardSkeleton"; import { getDataFromId } from "@services/starknetIdService"; import { usePathname, useRouter } from "next/navigation"; import ErrorScreen from "@components/UI/screens/errorScreen"; -import { CompletedQuests } from "../../types/backTypes"; +import { ArgentDappMap, ArgentTokenMap, ArgentUserDapp, ArgentUserToken, CompletedQuests } from "../../types/backTypes"; import QuestSkeleton from "@components/skeletons/questsSkeleton"; import QuestCardCustomised from "@components/dashboard/CustomisedQuestCard"; import QuestStyles from "@styles/Home.module.css"; @@ -31,6 +31,10 @@ import { TEXT_TYPE } from "@constants/typography"; import { a11yProps } from "@components/UI/tabs/a11y"; import { CustomTabPanel } from "@components/UI/tabs/customTab"; import SuggestedQuests from "@components/dashboard/SuggestedQuests"; +import PortfolioSummary from "@components/dashboard/PortfolioSummary"; +import { useNotification } from "@context/NotificationProvider"; +import { calculateTokenPrice, fetchDapps, fetchTokens, fetchUserDapps, fetchUserTokens } from "@services/argentPortfolioService"; +import PortfolioSummarySkeleton from "@components/skeletons/portfolioSummarySkeleton"; type AddressOrDomainProps = { params: { @@ -38,9 +42,23 @@ type AddressOrDomainProps = { }; }; +type ChartItemMap = { + [dappId: string]: ChartItem +}; + +type DebtStatus = { + hasDebt: boolean; + tokens: { + dappId: string, + tokenAddress: string, + tokenBalance: number + }[]; +}; + export default function Page({ params }: AddressOrDomainProps) { const router = useRouter(); const addressOrDomain = params.addressOrDomain; + const { showNotification } = useNotification(); const { address } = useAccount(); const { starknetIdNavigator } = useContext(StarknetIdJsContext); const [initProfile, setInitProfile] = useState(false); @@ -62,6 +80,9 @@ export default function Page({ params }: AddressOrDomainProps) { const [questsLoading, setQuestsLoading] = useState(true); const [tabIndex, setTabIndex] = React.useState(0); const [claimableQuests, setClaimableQuests] = useState([]); + const [portfolioAssets, setPortfolioAssets] = useState([]); + const [portfolioProtocols, setPortfolioProtocols] = useState([]); + const [loadingProtocols, setLoadingProtocols] = useState(true); const handleChangeTab = useCallback( (event: React.SyntheticEvent, newValue: number) => { @@ -168,10 +189,177 @@ export default function Page({ params }: AddressOrDomainProps) { setQuestsLoading(false); }, []); + const fetchPortfolioAssets = useCallback(async (addr: string) => { + + // TODO: Implement fetch from Argent API + const assets = [ + { color: "#1E2097", itemLabel: "USDC", itemValue: "46.68", itemValueSymbol: "%" }, + { color: "#637DEB", itemLabel: "USDT", itemValue: "27.94", itemValueSymbol: "%" }, + { color: "#2775CA", itemLabel: "STRK", itemValue: "22.78", itemValueSymbol: "%" }, + { color: "#5CE3FE", itemLabel: "ETH", itemValue: "0.36", itemValueSymbol: "%" }, + { color: "#F4FAFF", itemLabel: "Others", itemValue: "2.36", itemValueSymbol: "%" }, + ]; + setPortfolioAssets(assets); + + }, []); + + const userHasDebt = (userDapps: ArgentUserDapp[]) => { + let debt: DebtStatus = { hasDebt: false, tokens: [] }; + + for (const dapp of userDapps) { + if (!dapp.products[0]) { continue; } + for (const position of dapp.products[0].positions) { + for (const tokenAddress of Object.keys(position.totalBalances)) { + const tokenBalance = Number(position.totalBalances[tokenAddress]); + if (tokenBalance < 0) { + debt.hasDebt = true; + debt.tokens.push({dappId: dapp.dappId, tokenAddress, tokenBalance}); + } + } + } + } + return debt; + }; + + const handleDebt = async (protocolsMap: ChartItemMap, userDapps: ArgentUserDapp[], tokens: ArgentTokenMap) => { + const debtStatus = userHasDebt(userDapps); + if (!debtStatus || !debtStatus.hasDebt) { return; } + + for await (const debt of debtStatus.tokens) { + let value = Number(protocolsMap[debt.dappId].itemValue); + value += await calculateTokenPrice( + debt.tokenAddress, + tokenToDecimal(debt.tokenBalance.toString(), + tokens[debt.tokenAddress].decimals), + "USD" + ); + + protocolsMap[debt.dappId].itemValue = value.toFixed(2); + } + }; + + const getProtocolsFromTokens = async (protocolsMap: ChartItemMap, userTokens: ArgentUserToken[], tokens: ArgentTokenMap, dapps: ArgentDappMap) => { + for await (const token of userTokens) { + const tokenInfo = tokens[token.tokenAddress]; + if (tokenInfo.dappId && token.tokenBalance != "0") { + let itemValue = 0; + const currentTokenBalance = await calculateTokenPrice(token.tokenAddress, tokenToDecimal(token.tokenBalance, tokenInfo.decimals), "USD"); + + if (protocolsMap[tokenInfo.dappId]?.itemValue) { + itemValue = Number(protocolsMap[tokenInfo.dappId].itemValue) + currentTokenBalance; + } else { + itemValue = currentTokenBalance; + } + + protocolsMap[tokenInfo.dappId] = { + color: "", + itemLabel: dapps[tokenInfo.dappId].name, + itemValueSymbol: "$", + itemValue: itemValue.toFixed(2) + } + } + } + } + + const getProtocolsFromDapps = async (protocolsMap: ChartItemMap, userDapps: ArgentUserDapp[], tokens: ArgentTokenMap, dapps: ArgentDappMap) => { + for await (const userDapp of userDapps) { + if (protocolsMap[userDapp.dappId]) { continue; } // Ignore entry if already present in the map + + let protocolBalance = 0; + if (!userDapp.products[0]) { return; } + for await (const position of userDapp.products[0].positions) { + for await (const tokenAddress of Object.keys(position.totalBalances)) { + protocolBalance += await calculateTokenPrice( + tokenAddress, + tokenToDecimal(position.totalBalances[tokenAddress], tokens[tokenAddress].decimals), + "USD" + ); + } + } + + protocolsMap[userDapp.dappId] = { + color: "", + itemLabel: dapps[userDapp.dappId].name, + itemValueSymbol: "$", + itemValue: protocolBalance.toFixed(2) + } + } + } + + const sortProtocols = (protocolsMap: ChartItemMap) => { + return Object.values(protocolsMap).sort((a, b) => parseFloat(b.itemValue) - parseFloat(a.itemValue)); + } + + const handleExtraProtocols = (sortedProtocols: ChartItem[]) => { + let otherProtocols = sortedProtocols.length > 5 ? sortedProtocols.splice(4) : []; + if (otherProtocols.length === 0) { return;} + sortedProtocols.push({ + itemLabel: "Others", + itemValue: otherProtocols.reduce((valueSum, protocol) => valueSum + Number(protocol.itemValue), 0).toFixed(2), + itemValueSymbol: "$", + color: "" + }); + } + + const assignProtocolColors = (sortedProtocols: ChartItem[]) => { + const portfolioProtocolColors = [ + "#278015", + "#23F51F", + "#DEFE5C", + "#9EFABB", + "#F4FAFF" + ]; + sortedProtocols.forEach((protocol, index) => { + protocol.color = portfolioProtocolColors[index]; + }); + } + + const fetchPortfolioProtocols = useCallback(async (addr: string) => { + let dapps: ArgentDappMap = {}; + let tokens: ArgentTokenMap = {}; + let userTokens: ArgentUserToken[] = []; + let userDapps: ArgentUserDapp[] = []; + + setLoadingProtocols(true); + try { + [dapps, tokens, userTokens, userDapps] = await Promise.all([ + fetchDapps(), + fetchTokens(), + fetchUserTokens(addr), + fetchUserDapps(addr) + ]); + } catch (error) { + showNotification("Error while fetching address portfolio", "error"); + console.log("Error while fetching address portfolio", error); + } + + if (!dapps || !tokens || (!userTokens && !userDapps)) return; + let protocolsMap: ChartItemMap = {}; + + try { + await getProtocolsFromTokens(protocolsMap, userTokens, tokens, dapps); + await handleDebt(protocolsMap, userDapps, tokens); // Tokens show debt as balance 0, so need to handle it manually + await getProtocolsFromDapps(protocolsMap, userDapps, tokens, dapps); + + let sortedProtocols = sortProtocols(protocolsMap); + handleExtraProtocols(sortedProtocols); + assignProtocolColors(sortedProtocols); + + setPortfolioProtocols(sortedProtocols); + } catch (error) { + showNotification("Error while calculating address portfolio stats", "error"); + console.log("Error while calculating address portfolio stats", error); + } + + setLoadingProtocols(false); + }, []); + useEffect(() => { if (!identity) return; fetchQuestData(identity.owner); fetchPageData(identity.owner); + fetchPortfolioAssets(identity.owner); + fetchPortfolioProtocols(identity.owner); }, [identity]); useEffect(() => setNotFound(false), [dynamicRoute]); @@ -325,6 +513,30 @@ export default function Page({ params }: AddressOrDomainProps) { )} + {/* Portfolio charts */} +
+ {loadingProtocols ? ( // Change for corresponding state + + ) : ( + sum + Number(item.itemValue), 0)} + isProtocol={false} + /> + )} + {loadingProtocols ? ( + + ) : ( + sum + Number(item.itemValue), 0)} + isProtocol={true} + /> + )} +
+ {/* Completed Quests */}
diff --git a/components/dashboard/PortfolioSummary.tsx b/components/dashboard/PortfolioSummary.tsx new file mode 100644 index 00000000..733c4035 --- /dev/null +++ b/components/dashboard/PortfolioSummary.tsx @@ -0,0 +1,127 @@ +import React, { FunctionComponent, useMemo } from "react"; +import { TEXT_TYPE } from "@constants/typography"; +import Typography from "@components/UI/typography/typography"; +import { Doughnut } from "react-chartjs-2"; +import styles from "@styles/dashboard.module.css"; +import { Chart, ArcElement, DoughnutController, Tooltip, ChartEvent, ActiveElement, TooltipItem, ChartOptions } from 'chart.js'; +import { CDNImg } from "@components/cdn/image"; +import starknetIcon from "../../public/starknet/favicon.ico"; +import cursor from '../../public/icons/cursor.png'; +import cursorPointer from '../../public/icons/pointer-cursor.png'; + +Chart.register(ArcElement, DoughnutController, Tooltip); + +type PortfolioSummaryProps = { + title: string, + data: ChartItem[], + totalBalance: number, + isProtocol: boolean, + minSlicePercentage?: number +} + +const ChartEntry: FunctionComponent = ({ + color, + itemLabel, + itemValue, + itemValueSymbol +}) => { + const value = itemValueSymbol === '%' ? itemValue + itemValueSymbol : itemValueSymbol + itemValue; + return ( +
+
+ + + + {itemLabel} +
+ {value} +
+ ); +}; + +const PortfolioSummary: FunctionComponent = ({ title, data, totalBalance, isProtocol, minSlicePercentage = 0.05 }) => { + + const normalizeMinValue = (data: ChartItem[]) => { + return data.map(entry => + Number(entry.itemValue) < totalBalance * minSlicePercentage ? + (totalBalance * minSlicePercentage).toFixed(2) : + entry.itemValue + ); + } + + const chartOptions: ChartOptions<"doughnut"> = useMemo(() => ({ + elements: { + arc: { + borderRadius: 3, + spacing: 1, + hoverBorderColor: "white", + hoverBorderWidth: 1, + hoverOffset: 2 + } + }, + layout: { + padding: 5 + }, + responsive: true, + maintainAspectRatio: false, + cutout: '65%', + plugins: { + tooltip: { + callbacks: { + label: function (tooltipItem: TooltipItem<"doughnut">) { + return ` ${data[tooltipItem.dataIndex].itemValueSymbol}${data[tooltipItem.dataIndex].itemValue}`; + } + } + } + }, + onHover: (event: ChartEvent, element: ActiveElement[]) => { + let canvas = event.native?.target as HTMLCanvasElement; + canvas.style.cursor = element[0] ? `url(${cursorPointer.src}), pointer` : `url(${cursor.src}), auto`; + } + }), [data]); + + return data.length > 0 ? ( +
+
+
+ {title} +
+ {isProtocol && ( + + )} +
+
+
+ {data.map((item, id) => ( + + ))} +
+
+ entry.itemLabel), + datasets: [{ + label: '', + data: normalizeMinValue(data), + backgroundColor: data.map(entry => entry.color), + borderColor: data.map(entry => entry.color), + borderWidth: 1, + }], + }} + options={chartOptions} + /> +
+
+
+ ) : null; +} + +export default PortfolioSummary; \ No newline at end of file diff --git a/components/skeletons/portfolioSummarySkeleton.tsx b/components/skeletons/portfolioSummarySkeleton.tsx new file mode 100644 index 00000000..29224d9e --- /dev/null +++ b/components/skeletons/portfolioSummarySkeleton.tsx @@ -0,0 +1,24 @@ +import React, { FunctionComponent } from "react"; +import { Skeleton } from "@mui/material"; + +const PortfolioSummarySkeleton: FunctionComponent = () => { + return ( + <> +
+ + +
+ + ); +}; + +export default PortfolioSummarySkeleton; \ No newline at end of file diff --git a/services/argentPortfolioService.ts b/services/argentPortfolioService.ts new file mode 100644 index 00000000..cbff1719 --- /dev/null +++ b/services/argentPortfolioService.ts @@ -0,0 +1,66 @@ +import { + ArgentDapp, + ArgentDappMap, + ArgentUserDapp, + ArgentToken, + ArgentTokenValue, + ArgentTokenMap, + ArgentUserToken, +} from "types/backTypes"; + +const API_BASE = "https://cloud.argent-api.com"; +const API_VERSION = "v1"; +const API_HEADERS = { + "argent-client": "portfolio", + "argent-network": "mainnet", + "argent-version": "1.4.3", +}; + +const fetchData = async (endpoint: string): Promise => { + try { + const response = await fetch(endpoint, { headers: API_HEADERS }); + if (!response.ok) { + throw new Error( + `Error ${response.status}: ${await response.text()}` + ); + } + return await response.json(); + } catch (err) { + console.log("Error fetching data from Argent API", err); + throw err; + } +}; + +export const fetchDapps = async () => { + const data = await fetchData(`${API_BASE}/${API_VERSION}/tokens/dapps?chain=starknet`); + return Object.fromEntries(data.map((dapp) => [dapp.dappId, dapp])) as ArgentDappMap; +}; + +export const fetchTokens = async () => { + const data = await fetchData<{ tokens: ArgentToken[] }>(`${API_BASE}/${API_VERSION}/tokens/info?chain=starknet`); + return Object.fromEntries(data.tokens.map((token) => [token.address, token])) as ArgentTokenMap; +}; + +export const fetchUserTokens = async (walletAddress: string) => { + const data = await fetchData<{ balances: ArgentUserToken[], status: string }>(`${API_BASE}/${API_VERSION}/activity/starknet/mainnet/account/${walletAddress}/balance`); + return data.balances; +}; + +export const fetchUserDapps = async (walletAddress: string) => { + const data = await fetchData<{ dapps: ArgentUserDapp[] }>(`${API_BASE}/${API_VERSION}/tokens/defi/decomposition/${walletAddress}?chain=starknet`); + return data.dapps; +}; + +export const calculateTokenPrice = async ( + tokenAddress: string, + tokenAmount: string, + currency: "USD" | "EUR" | "GBP" = "USD" +) => { + const data = await fetchData(`${API_BASE}/${API_VERSION}/tokens/prices/${tokenAddress}?chain=starknet¤cy=${currency}`); + try { + return Number(tokenAmount) * Number(data.ccyValue); + } catch (err) { + console.log("Error while calculating token price", err); + throw err; + } +}; diff --git a/styles/dashboard.module.css b/styles/dashboard.module.css index 22fc8db7..0a89662c 100644 --- a/styles/dashboard.module.css +++ b/styles/dashboard.module.css @@ -12,7 +12,7 @@ width: 100%; justify-content: center; margin-top: 12vh; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 24px; } @@ -20,7 +20,7 @@ display: flex; width: 100%; margin-top: 12vh; - max-width: 950px; + max-width: var(--dashboard-max-width); } .profileCardSkeleton { @@ -30,7 +30,6 @@ width: 100%; } - .profileCardLoading { position: absolute; top: 0; @@ -42,7 +41,7 @@ height: 30vh; width: 100%; margin-top: 6vh; - max-width: 950px; + max-width: var(--dashboard-max-width); } .dashboardLoading { @@ -56,7 +55,7 @@ display: flex; width: 100%; margin-top: 6vh; - max-width: 950px; + max-width: var(--dashboard-max-width); } .questsCompletedTitleLoading { @@ -66,8 +65,6 @@ width: 2rem; } - - .blur1 { display: none; position: absolute; @@ -87,7 +84,6 @@ .dashboard_profile_card { display: flex; width: 100%; - max-width: 950px; padding: 1.5rem 2.5rem; background: linear-gradient(var(--background600), transparent); align-items: center; @@ -187,8 +183,6 @@ gap: 5px; } - - .right_middle { width: 100%; height: 100%; @@ -283,7 +277,7 @@ flex-direction: row; flex-wrap: wrap; gap: 2rem; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 3rem; justify-content: space-between; } @@ -293,7 +287,7 @@ flex-direction: column; width: 100%; margin-top: 6vh; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 24px; } @@ -318,6 +312,36 @@ text-align: center; } +.dashboard_portfolio_summary_container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-end; + width: 100%; + margin-top: 6vh; + max-width: var(--dashboard-max-width); + margin-bottom: 24px; +} + +.dashboard_portfolio_summary { + width: 47%; + margin-bottom: 6vh; +} + +.dashboard_portfolio_summary_info { + border-radius: 8px; + padding: 24px; + width: 100%; + height: 200px; + display: flex; + flex-direction: row; + background-color: var(--menu-background); + justify-content: space-between; + align-items: flex-start; + align-self: stretch; + border: solid 1px transparent; +} + @media (max-width: 768px) { .dashboard_profile_card { flex-direction: column; @@ -338,7 +362,6 @@ margin-bottom: 0; } - .dashboard_wrapper { padding: 20px; padding-bottom: 30px; @@ -359,6 +382,22 @@ padding-bottom: 10px; } + .dashboard_portfolio_summary_container { + flex-direction: column; + align-items: center; + width: var(--dashboard-max-width); + } + + .dashboard_portfolio_summary { + width: 90%; + } + + .dashboard_portfolio_summary_info { + flex-direction: column-reverse; + align-items: center; + height: fit-content; + } + .quests_container { display: flex; flex-direction: column; @@ -369,7 +408,7 @@ flex-direction: row; flex-wrap: wrap; gap: 2rem; - max-width: 950px; + max-width: var(--dashboard-max-width); margin-bottom: 3rem; margin-top: 1rem; margin-top: 3rem; @@ -380,15 +419,11 @@ margin-bottom: 10px; } - - .center { gap: 0; padding-left: unset; } - - .address_div { justify-content: center; gap: 0.5rem; diff --git a/styles/globals.css b/styles/globals.css index ab3e40da..0bba5350 100644 --- a/styles/globals.css +++ b/styles/globals.css @@ -34,6 +34,7 @@ --white: #fff; --textGray: #a6a5a7; --transparent: transparent; + --dashboard-max-width: clamp(320px, 90%, 1500px); } html, diff --git a/tests/utils/feltService.test.js b/tests/utils/feltService.test.js index 1df2a958..6b0d1898 100644 --- a/tests/utils/feltService.test.js +++ b/tests/utils/feltService.test.js @@ -3,6 +3,7 @@ import { decimalToHex, stringToHex, gweiToEth, + tokenToDecimal } from "@utils/feltService"; describe("Should test hexToDecimal function", () => { @@ -75,3 +76,22 @@ describe("Should test gweiToEth function", () => { expect(gweiToEth("")).toEqual("0"); }); }); + +describe("Should test tokenToDecimal function", () => { + it("Should return the right decimal value from a given token with dynamic decimals", () => { + expect(tokenToDecimal("113623892493328485", 18)).toEqual("0.11362"); + expect(tokenToDecimal("3477473", 6)).toEqual("3.47747"); + }); + + it("Should return 0 if the value is an empty string", () => { + expect(tokenToDecimal("", 6)).toEqual("0"); + }); + + it("Should return 0 if the decimal is not a valid number", () => { + expect(tokenToDecimal("8943032", 'hello')).toEqual("0"); + }); + + it("Should return 0 if the decimal is a negative number", () => { + expect(tokenToDecimal("22341256543", -5)).toEqual("0"); + }); +}); \ No newline at end of file diff --git a/types/backTypes.d.ts b/types/backTypes.d.ts index e6b431c7..cf5620ae 100644 --- a/types/backTypes.d.ts +++ b/types/backTypes.d.ts @@ -528,3 +528,101 @@ export type Call = { calldata: string[]; entrypoint: string; }; + +export type ArgentDappLink = { + name: string; + url: string; + position: number; +}; + +export type ArgentDappContract = { + address: string; + chain: string; +}; + +export type ArgentDapp = { + dappId: string; + name: string; + description: string; + logoUrl: string; + dappUrl?: string; + inAppBrowserCompatible: boolean; + argentVerified: boolean; + links: ArgentDappLink[]; + contracts: ArgentDappContract[]; + categories: string[]; + supportedApps?: string[]; + executeFromOutsideAllowed: boolean; +}; + +export type ArgentDappMap = { + [dappId: string]: ArgentDapp; +}; + +export type ArgentToken = { + id: number; + address: string; + name: string; + tradable: boolean; + symbol: string; + decimals: number; + sendable: boolean; + category: string; + refundable: boolean; + popular: boolean; + listed: boolean; + dappId?: string; +}; + +export type ArgentTokenMap = { + [address: string]: ArgentToken; +}; + +export type ArgentDappPositionTotalBalances = { + [address: string]: string; +}; + +export type ArgentDappPositionData = { + apy: string; + group?: number; + collateral: boolean; + debt: boolean; + lending: boolean; +}; + +export type ArgentDappPosition = { + id: string; + investmentId?: string; + tokenAddress: string; + tokenAmount: string; + totalBalances: ArgentDappPositionTotalBalances; + data: ArgentDappPositionData; +}; + +export type ArgentDappProduct = { + productId?: string; + name: string; + manageUrl: string; + groups?: { [key: string]: { healthRatio: string } }; + positions: ArgentDappPosition[]; + type: string; +}; + +export type ArgentUserDapp = { + dappId: string; + products: ArgentDappProduct[]; +}; + +export type ArgentTokenValue = { + pricingId: number; + date: number; + ethValue: string; + ccyValue: string; + ethDayChange: string; + ccyDayChange: string; +} + +export type ArgentUserToken = { + tokenAddress: string; + tokenBalance: string; +} \ No newline at end of file diff --git a/types/frontTypes.d.ts b/types/frontTypes.d.ts index 25492fa8..d0f9c6bf 100644 --- a/types/frontTypes.d.ts +++ b/types/frontTypes.d.ts @@ -347,3 +347,10 @@ type Call = { calldata: string[]; entrypoint: string; }; + +type ChartItem = { + color: string, + itemLabel: string, + itemValue: string, + itemValueSymbol: string +} \ No newline at end of file diff --git a/utils/feltService.ts b/utils/feltService.ts index dc0e243c..e4217b26 100644 --- a/utils/feltService.ts +++ b/utils/feltService.ts @@ -36,3 +36,16 @@ export function gweiToEth(gwei: string): string { return ethBigInt.toString(); } + +export function tokenToDecimal(tokenValue: string, decimals: number) { + if (!tokenValue || isNaN(Number(tokenValue)) || isNaN(Number(decimals)) || decimals < 0) { + return "0"; + } + + const tokenValueBigInt = new Big(tokenValue); + const scaleFactor = new Big(10 ** decimals); + + const tokenDecimal = tokenValueBigInt.div(scaleFactor).round(5); + + return tokenDecimal.toString(); +}