diff --git a/public/assets/fiat.svg b/public/assets/fiat.svg new file mode 100644 index 000000000..9b5f0d491 --- /dev/null +++ b/public/assets/fiat.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/api/earn/history/route.ts b/src/app/api/earn/history/route.ts new file mode 100644 index 000000000..babaea73e --- /dev/null +++ b/src/app/api/earn/history/route.ts @@ -0,0 +1,121 @@ +import { + getTokenByChainAndAddress, + getTokenByChainAndSymbol, + isYieldToken, +} from '@indexcoop/tokenlists' +import { uniqBy } from 'lodash' +import mapKeys from 'lodash/mapKeys' +import { NextRequest, NextResponse } from 'next/server' +import { Address } from 'viem' + +import { calculateAverageEntryPrice } from '@/app/leverage/utils/fetch-leverage-token-prices' +import { + getApiV2PriceCoingeckoSimplePrice, + getApiV2UserAddressPositions, +} from '@/gen' + +type TokenTransferRequest = { + user: Address +} + +const fetchCoingeckoPrices = async ( + ids: string[], + vs_currencies: string[], +): Promise> => { + const response = await getApiV2PriceCoingeckoSimplePrice({ + ids: ids.join(','), + vs_currencies: vs_currencies.join(','), + }) + return response.data +} + +const mapCoingeckoIdToSymbol = (id: string) => { + switch (id) { + case 'ethereum': + return 'eth' + case 'bitcoin': + return 'btc' + case 'wrapped-solana-universal': + return 'sol' + case 'wrapped-sui-universal': + return 'sui' + default: + return id + } +} + +const wsteth = getTokenByChainAndSymbol(8453, 'wstETH') + +export async function POST(req: NextRequest) { + try { + const { user } = (await req.json()) as TokenTransferRequest + + const { data: positions } = await getApiV2UserAddressPositions({ + address: user, + }) + + const history = positions + .filter(({ rawContract }) => { + const token1 = getTokenByChainAndAddress(1, rawContract.address) + const token8453 = getTokenByChainAndAddress(8453, rawContract.address) + const token42161 = getTokenByChainAndAddress(42161, rawContract.address) + + return ( + isYieldToken(token1) || + isYieldToken(token8453) || + isYieldToken(token42161) + ) + }) + .sort( + (a, b) => + new Date(b.metadata.blockTimestamp).getTime() - + new Date(a.metadata.blockTimestamp).getTime(), + ) + + let prices: Record = {} + try { + prices = mapKeys( + await fetchCoingeckoPrices( + ['ethereum', 'bitcoin', wsteth?.extensions.coingeckoId].filter( + (str) => str !== undefined, + ), + ['btc', 'eth', 'usd'], + ), + (_, key) => mapCoingeckoIdToSymbol(key), + ) + } catch (error) { + console.log(JSON.stringify(error, null, 2)) + console.error('Failed to fetch coingecko prices', error) + } + + const openPositions = history.filter( + (position) => position.metrics?.positionStatus === 'open', + ) + + const averages = calculateAverageEntryPrice(openPositions) + + const open = uniqBy( + openPositions.map((position) => ({ + ...position, + trade: { + ...position.trade, + underlyingAssetUnitPrice: averages[position.metrics!.tokenAddress], + }, + })), + 'metrics.tokenAddress', + ) + + return NextResponse.json( + { open, history, prices }, + { + status: 200, + headers: { + 'Cache-Control': 'public, max-age=1, stale-while-revalidate=1', + }, + }, + ) + } catch (error) { + console.log(JSON.stringify(error, null, 2)) + return NextResponse.json(error, { status: 500 }) + } +} diff --git a/src/app/earn/components/balance-card.tsx b/src/app/earn/components/balance-card.tsx index 6fa21ca51..cbd58fa68 100644 --- a/src/app/earn/components/balance-card.tsx +++ b/src/app/earn/components/balance-card.tsx @@ -1,54 +1,128 @@ +import { Button } from '@headlessui/react' import { getTokenByChainAndAddress, isAddressEqual, } from '@indexcoop/tokenlists' -import { motion } from 'framer-motion' +import Image from 'next/image' import Link from 'next/link' -import { useMemo } from 'react' +import { FC, useMemo, useState } from 'react' import { formatUnits } from 'viem' -import { GetApiV2ProductsEarn200 } from '@/gen' +import { GetApiV2ProductsEarn200, GetApiV2UserAddressPositions200 } from '@/gen' import { TokenBalance } from '@/lib/hooks/use-balance' import { formatAmount } from '@/lib/utils' +import { SkeletonLoader } from '@/lib/utils/skeleton-loader' +import { cn } from '@/lib/utils/tailwind' -const BoxedData = ({ label, value }: { label: string; value: string }) => ( -
-

{label}

-
-

{value}

-
-
-) - -const Position = ({ - balance, +const calculateAccruedYield = ({ product, + position, + denominator, + prices = {}, }: { + product: GetApiV2ProductsEarn200[number] + prices: Record> + position: GetApiV2UserAddressPositions200[number] + denominator: 'fiat' | 'eth' +}) => { + const underlyingTokenId = + getTokenByChainAndAddress(product.chainId, product.tokenAddress)?.extensions + .coingeckoId ?? 'eth' + const currentRatio = + product.metrics.nav / (prices[underlyingTokenId]?.usd ?? 1) + + const yieldETH = + (position.metrics?.endingUnits ?? 0) * + (currentRatio - (position.metrics?.avgEntryUnderlyingPerToken ?? 0)) + const yieldUSD = yieldETH * (prices.eth?.usd ?? 0) + + return denominator === 'fiat' ? yieldUSD : yieldETH +} + +const DenominatorSwitch: FC<{ + selected: 'fiat' | 'eth' + onSelect: (denominator: 'fiat' | 'eth') => void +}> = ({ selected, onSelect }) => { + return ( + + ) +} + +const Position: FC<{ balance: TokenBalance product?: GetApiV2ProductsEarn200[number] -}) => { + position?: GetApiV2UserAddressPositions200[number] + isLoading?: boolean + denominator: 'fiat' | 'eth' + prices: Record> +}> = ({ balance, product, position, isLoading, denominator, prices = {} }) => { const token = useMemo( () => getTokenByChainAndAddress(product?.chainId, product?.tokenAddress), [product], ) + const accruedYield = useMemo(() => { + if (!product || !token) return 0 + + if (position && position.metrics) { + return calculateAccruedYield({ + product, + position, + denominator, + prices, + }) + } + return 0 + }, [product, token, position, denominator]) + return product && token ? ( - -

{product.name}

-

- $ +

+ + {product.name} + + + {denominator === 'fiat' ? '$' : 'Ξ'} {formatAmount( - Number(formatUnits(balance.value, token.decimals)) * - product.metrics.nav, + denominator === 'fiat' + ? Number(formatUnits(balance.value, token.decimals)) * + product.metrics.nav + : (Number(formatUnits(balance.value, token.decimals)) * + product.metrics.nav) / + product.metrics.nav, + denominator === 'fiat' ? 2 : 6, )} -

- +
+ {isLoading ? ( + + ) : ( + + {denominator === 'fiat' ? '$' : 'Ξ'} + {formatAmount(accruedYield, denominator === 'fiat' ? 2 : 6)} + + )} +
) : ( <> @@ -86,10 +160,20 @@ const calculateEffectiveAPY = ( export type BalanceCardProps = { products: GetApiV2ProductsEarn200 + positions: GetApiV2UserAddressPositions200 balances: TokenBalance[] + prices: Record> + isLoading: boolean } -export const BalanceCard = ({ products, balances }: BalanceCardProps) => { +export const BalanceCard = ({ + products, + positions, + balances, + prices, + isLoading, +}: BalanceCardProps) => { + const [denominator, setDenominator] = useState<'fiat' | 'eth'>('fiat') const deposits = useMemo( () => balances.reduce((acc, curr) => { @@ -105,53 +189,133 @@ export const BalanceCard = ({ products, balances }: BalanceCardProps) => { if (product && token) { return ( acc + - Number(formatUnits(curr.value, token.decimals)) * - product.metrics.nav + (denominator === 'fiat' + ? Number(formatUnits(curr.value, token.decimals)) * + product.metrics.nav + : (Number(formatUnits(curr.value, token.decimals)) * + product.metrics.nav) / + product.metrics.nav) ) } return acc }, 0), - [products, balances], + [products, balances, denominator, prices], + ) + + const accruedYield = useMemo( + () => + products.reduce((acc, curr) => { + const position = positions.find((p) => + isAddressEqual(p.metrics?.tokenAddress, curr.tokenAddress), + ) + + if (position && position.metrics) { + return calculateAccruedYield({ + product: curr, + position, + denominator, + prices, + }) + } + + return acc + }, 0), + [products, positions, denominator, prices], ) return ( - +

My Earn

-

- My positions -

+
+

+ My positions +

+

+ Total deposited +

+

+ Accrued yield +

+
{balances.map((balance) => (
isAddressEqual(p.tokenAddress, balance.token), )} balance={balance} + position={positions.find((p) => + isAddressEqual(p.metrics?.tokenAddress, balance.token), + )} + isLoading={isLoading} + denominator={denominator} />
))}
-
-
-

Total Deposits

+
+
+
+

+ Total Deposits +

+ +

- ${formatAmount(deposits)} + {denominator === 'fiat' ? '$' : 'Ξ'} + {formatAmount(deposits, denominator === 'fiat' ? 2 : 6)}

-
- - {/* */} +
+
+

+ Net APY +

+
+

+ {formatAmount(calculateEffectiveAPY(products, balances))}% +

+
+
+
+

+ Accrued Yield +

+ + {isLoading ? ( + + ) : ( +
+

+ {denominator === 'fiat' ? '$' : 'Ξ'} + {formatAmount(accruedYield, denominator === 'fiat' ? 2 : 6)} +

+
+ )} +
- +
) } diff --git a/src/app/earn/components/product-card.tsx b/src/app/earn/components/product-card.tsx index ad381574c..e9472ab94 100644 --- a/src/app/earn/components/product-card.tsx +++ b/src/app/earn/components/product-card.tsx @@ -5,6 +5,7 @@ import { FC, ReactNode } from 'react' import { ProductTitlePill } from '@/app/earn/components/product-pill' import { ProductTag } from '@/app/earn/components/product-tag' +import { LeveragePositions } from '@/app/store/positions-atom' import { GetApiV2ProductsEarn200 } from '@/gen' import { formatAmount } from '@/lib/utils' @@ -14,6 +15,7 @@ export type ProductCardProps = { icon: ReactNode } product: GetApiV2ProductsEarn200[number] + position?: LeveragePositions['open'][number] } export const ProductCard: FC = ({ product, pill }) => { diff --git a/src/app/earn/page.tsx b/src/app/earn/page.tsx index a9cdba4db..a8fb8c265 100644 --- a/src/app/earn/page.tsx +++ b/src/app/earn/page.tsx @@ -1,8 +1,11 @@ 'use client' import { ArrowPathIcon } from '@heroicons/react/20/solid' -import { AnimatePresence } from 'framer-motion' +import { useQuery } from '@tanstack/react-query' import { useEffect } from 'react' +import { useAccount } from 'wagmi' + +import { EarnPositions } from '@/app/store/positions-atom' import { BalanceCard } from './components/balance-card' import { ProductCard } from './components/product-card' @@ -11,6 +14,8 @@ import { useEarnContext } from './provider' export default function Page() { const { products, balances } = useEarnContext() + const { address: user } = useAccount() + useEffect(() => { document.body.classList.add('dark', 'bg-ic-black') return () => { @@ -18,15 +23,43 @@ export default function Page() { } }, []) + const { + data: { open, prices }, + isFetching, + } = useQuery({ + initialData: { + open: [], + history: [], + prices: {}, + }, + enabled: Boolean(user), + queryKey: ['earn-history', user], + queryFn: async () => { + const response = await fetch('/api/earn/history', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + user, + }), + }) + return response.json() as Promise + }, + }) + return (
- - {balances.length > 0 && ( - - )} - - + {balances.length > 0 && ( + + )}

Strategies @@ -36,6 +69,10 @@ export default function Page() { + position.metrics?.tokenAddress === p.tokenAddress, + )} pill={ ['wsteth15x', 'iceth'].includes(p.id) ? { diff --git a/src/app/leverage/components/portfolio-widget/portfolio-widget.tsx b/src/app/leverage/components/portfolio-widget/portfolio-widget.tsx index e66a18ad0..defedb73e 100644 --- a/src/app/leverage/components/portfolio-widget/portfolio-widget.tsx +++ b/src/app/leverage/components/portfolio-widget/portfolio-widget.tsx @@ -15,7 +15,7 @@ import { getLeverageTokens } from '@/app/leverage/constants' import { EnrichedToken } from '@/app/leverage/types' import { fetchLeverageTokenPrices } from '@/app/leverage/utils/fetch-leverage-token-prices' import { getLeverageType } from '@/app/leverage/utils/get-leverage-type' -import { fetchPositionsAtom } from '@/app/store/positions-atom' +import { fetchLeveragePositionsAtom } from '@/app/store/positions-atom' import { ETH } from '@/constants/tokens' import { useAnalytics } from '@/lib/hooks/use-analytics' import { useBalances } from '@/lib/hooks/use-balance' @@ -31,7 +31,7 @@ const OpenPositions = () => { undefined, ) const { queryParams, updateQueryParams } = useQueryParams() - const fetchPositions = useSetAtom(fetchPositionsAtom) + const fetchPositions = useSetAtom(fetchLeveragePositionsAtom) const { logEvent } = useAnalytics() const indexTokenAddresses = useMemo(() => { diff --git a/src/app/store/positions-atom.ts b/src/app/store/positions-atom.ts index d7d85d068..f97424218 100644 --- a/src/app/store/positions-atom.ts +++ b/src/app/store/positions-atom.ts @@ -2,7 +2,7 @@ import { atom } from 'jotai' import { GetApiV2UserAddressPositions200 } from '@/gen' -export type Positions = { +export type LeveragePositions = { open: GetApiV2UserAddressPositions200 history: GetApiV2UserAddressPositions200 stats: { @@ -10,15 +10,21 @@ export type Positions = { } } -const positionsAtomDefaultValue: Positions = { +export type EarnPositions = { + open: GetApiV2UserAddressPositions200 + history: GetApiV2UserAddressPositions200 + prices: Record> +} + +const positionsAtomDefaultValue: LeveragePositions = { open: [], history: [], stats: {}, } -export const positionsAtom = atom(positionsAtomDefaultValue) +export const positionsAtom = atom(positionsAtomDefaultValue) -export const fetchPositionsAtom = atom( +export const fetchLeveragePositionsAtom = atom( null, async (_, set, address: string, chainId: number) => { try { @@ -30,7 +36,7 @@ export const fetchPositionsAtom = atom( chainId, }), }) - ).json()) as Positions + ).json()) as LeveragePositions set(positionsAtom, positions)