diff --git a/.changeset/fair-owls-impress.md b/.changeset/fair-owls-impress.md new file mode 100644 index 0000000000..12d4811f14 --- /dev/null +++ b/.changeset/fair-owls-impress.md @@ -0,0 +1,6 @@ +--- +"@talismn/solana": patch +"@talismn/util": patch +--- + +utilities for earn tab diff --git a/apps/extension/public/locales/en/common.json b/apps/extension/public/locales/en/common.json index 6ca862e675..5236ba568b 100644 --- a/apps/extension/public/locales/en/common.json +++ b/apps/extension/public/locales/en/common.json @@ -308,6 +308,8 @@ "Automatically assess risks of transactions via <2>Blockaid": "Automatically assess risks of transactions via <2>Blockaid", "Available": "Available", "Available Balance": "Available Balance", + "Available product": "Available product", + "Available products": "Available products", "Available Earning Opportunities for {{symbol}}": "Available Earning Opportunities for {{symbol}}", "Available to unstake": "Available to unstake", "Available:": "Available:", @@ -598,9 +600,9 @@ "Disconnect All": "Disconnect All", "Disconnect All Sites": "Disconnect All Sites", "Discover": "Discover", + "Discover opportunities": "Discover opportunities", "Discover Autonomys Quest": "Discover Autonomys Quest", "Discover how Talisman can elevate your web3 journey": "Discover how Talisman can elevate your web3 journey", - "Discover opportunities": "Discover opportunities", "Discover Tab": "Discover Tab", "Dismiss": "Dismiss", "Display balances": "Display balances", @@ -626,11 +628,11 @@ "Due to a recent update, users may be experiencing issues loading balances.": "Due to a recent update, users may be experiencing issues loading balances.", "Durable nonce": "Durable nonce", "Earn": "Earn", + "Earn on your assets": "Earn on your assets", "Earn ~15% yield on your {{symbol}} on the Portal ": "Earn ~15% yield on your {{symbol}} on the Portal ", "Earn $AI3 and participate in the Quest for a chance to win $2000 in prizes.": "Earn $AI3 and participate in the Quest for a chance to win $2000 in prizes.", "Earn Assets": "Earn Assets", "Earn fee discounts on Bittensor": "Earn fee discounts on Bittensor", - "Earn on your assets": "Earn on your assets", "Earn SEEK rewards": "Earn SEEK rewards", "Earn Staking Rewards": "Earn Staking Rewards", "Earning": "Earning", @@ -1220,6 +1222,7 @@ "No earning positions found in this folder.": "No earning positions found in this folder.", "No earning positions found.": "No earning positions found.", "No earning products available.": "No earning products available.", + "No yield products available for your tokens": "No yield products available for your tokens", "No mnemonic available": "No mnemonic available", "No Mnemonic Available": "No Mnemonic Available", "No network found": "No network found", @@ -1253,7 +1256,6 @@ "No tokens match your search": "No tokens match your search", "No transactions found": "No transactions found", "No validators found": "No validators found", - "No yield products available for your tokens": "No yield products available for your tokens", "Non-custodial, fully audited and open-source": "Non-custodial, fully audited and open-source", "Nonce": "Nonce", "None": "None", diff --git a/apps/extension/src/@talisman/components/PopupSizeModalContainer.tsx b/apps/extension/src/@talisman/components/PopupSizeModalContainer.tsx new file mode 100644 index 0000000000..d65f16725d --- /dev/null +++ b/apps/extension/src/@talisman/components/PopupSizeModalContainer.tsx @@ -0,0 +1,23 @@ +import { cn } from "@talismn/util" +import { FC, PropsWithChildren } from "react" + +import { IS_POPUP } from "@ui/util/constants" + +export const PopupSizeModalContainer: FC> = ({ + id, + className, + children, +}) => { + return ( +
+ {children} +
+ ) +} diff --git a/apps/extension/src/@talisman/hooks/createGlobalOpenClose.ts b/apps/extension/src/@talisman/hooks/createGlobalOpenClose.ts new file mode 100644 index 0000000000..2411881e86 --- /dev/null +++ b/apps/extension/src/@talisman/hooks/createGlobalOpenClose.ts @@ -0,0 +1,37 @@ +import { bind } from "@react-rxjs/core" +import { BehaviorSubject, map } from "rxjs" + +export type OpenCloseResult = + | { + isOpen: false + args: T | null // retains previous data when closed + open: (args: T) => void + close: () => void + } + | { + isOpen: true + args: T + open: (args: T) => void + close: () => void + } + +export const createGlobalOpenClose = () => { + const state$ = new BehaviorSubject<{ + isOpen: boolean + args: T | null + }>({ isOpen: false, args: null }) + + return bind(() => + state$.pipe( + map( + ({ isOpen, args }) => + ({ + isOpen, + open: (args: T) => state$.next({ isOpen: true, args }), + close: () => state$.next({ isOpen: false, args }), // retain args so it can still be displayed while closing + args, + }) as OpenCloseResult, + ), + ), + ) +} diff --git a/apps/extension/src/@talisman/hooks/useGlobalOpenClose.ts b/apps/extension/src/@talisman/hooks/useGlobalOpenClose.ts index 16a29983de..98c339109f 100644 --- a/apps/extension/src/@talisman/hooks/useGlobalOpenClose.ts +++ b/apps/extension/src/@talisman/hooks/useGlobalOpenClose.ts @@ -4,6 +4,7 @@ import { BehaviorSubject, distinctUntilChanged, map } from "rxjs" const allOpenCloseState$ = new BehaviorSubject<{ [key: string]: boolean }>({}) +// TODO existing consumers should use createGlobalOpenClose instead export const [useGlobalOpenCloseValue, getGlobalOpenCloseValue$] = bind((key: string) => allOpenCloseState$.pipe( map((state) => state[key] ?? false), diff --git a/apps/extension/src/inject/solana/util.ts b/apps/extension/src/inject/solana/util.ts index 4907900725..f1b7d18034 100644 --- a/apps/extension/src/inject/solana/util.ts +++ b/apps/extension/src/inject/solana/util.ts @@ -1,4 +1,4 @@ -import type { SolSerializedWalletAccount } from "extension-core/src/domains/solana/types.tabs" +import type { SolSerializedWalletAccount } from "extension-core" import { Transaction, VersionedTransaction } from "@solana/web3.js" import bs58 from "bs58" diff --git a/apps/extension/src/ui/api/analytics.ts b/apps/extension/src/ui/api/analytics.ts index d56869ab90..77788bb19c 100644 --- a/apps/extension/src/ui/api/analytics.ts +++ b/apps/extension/src/ui/api/analytics.ts @@ -14,6 +14,7 @@ export type AnalyticsFeature = | "Transactions" | "Asset Discovery" | "Quick Settings" + | "Earn" export type AnalyticsPage = { container: AnalyticsContainer diff --git a/apps/extension/src/ui/api/api.ts b/apps/extension/src/ui/api/api.ts index 05d62d02f7..1cb321ec17 100644 --- a/apps/extension/src/ui/api/api.ts +++ b/apps/extension/src/ui/api/api.ts @@ -297,6 +297,16 @@ export const api: MessageTypes = { defiPositionsSubscribe: (cb) => messageService.subscribe("pri(defi.positions.subscribe)", null, cb), + // yield + yieldxyzPositionsSubscribe: (cb) => + messageService.subscribe("pri(earn.yieldxyz.positions.subscribe)", null, cb), + yieldxyzProductsSubscribe: (cb) => + messageService.subscribe("pri(earn.yieldxyz.products.subscribe)", null, cb), + yieldxyzProvidersSubscribe: (cb) => + messageService.subscribe("pri(earn.yieldxyz.providers.subscribe)", null, cb), + yieldxyzPositionRefresh: (args) => + messageService.sendMessage("pri(earn.yieldxyz.positions.refresh)", args), + // bittensor bittensorValidatorsSubscribe: (cb) => messageService.subscribe("pri(bittensor.validators.subscribe)", null, cb), diff --git a/apps/extension/src/ui/api/types.ts b/apps/extension/src/ui/api/types.ts index 5c60a994ce..fa0077db82 100644 --- a/apps/extension/src/ui/api/types.ts +++ b/apps/extension/src/ui/api/types.ts @@ -57,6 +57,10 @@ import { ValidRequests, WalletTransactionInfo, WatchAssetRequestId, + YieldDto, + YieldxyzPosition, + YieldxyzPositionRefreshRequest, + YieldxyzProvider, } from "extension-core" import { MetadataDef } from "inject/substrate/types" import { TransactionRequest } from "viem" @@ -283,6 +287,15 @@ export default interface MessageTypes { defiPositionsSubscribe: (cb: (positions: Loadable) => void) => UnsubscribeFn + yieldxyzPositionsSubscribe: ( + cb: (positions: Loadable) => void, + ) => UnsubscribeFn + yieldxyzPositionRefresh: (args: YieldxyzPositionRefreshRequest) => Promise + yieldxyzProductsSubscribe: (cb: (positions: Loadable) => void) => UnsubscribeFn + yieldxyzProvidersSubscribe: ( + cb: (positions: Loadable) => void, + ) => UnsubscribeFn + bittensorValidatorsSubscribe: ( cb: (validators: Loadable) => void, ) => UnsubscribeFn diff --git a/apps/extension/src/ui/apps/dashboard/index.tsx b/apps/extension/src/ui/apps/dashboard/index.tsx index 46d2b32d4e..944d7e4185 100644 --- a/apps/extension/src/ui/apps/dashboard/index.tsx +++ b/apps/extension/src/ui/apps/dashboard/index.tsx @@ -22,6 +22,7 @@ import { AccountAddPrivateKeyDashboardPage } from "./routes/AccountAdd/AccountAd import { AccountAddQrDashboardWizard } from "./routes/AccountAdd/AccountAddQrWizard" import { AccountAddSignetDashboardWizard } from "./routes/AccountAdd/AccountAddSignetWizard" import { AccountAddWatchedPage } from "./routes/AccountAdd/AccountAddWatchedPage" +import { DashboardEarnRoutes } from "./routes/Earn" import { AddNetworkPage } from "./routes/Networks/AddNetworkPage" import { EditNetworkPage } from "./routes/Networks/EditNetworkPage" import { NetworksPage } from "./routes/Networks/NetworksPage" @@ -53,6 +54,7 @@ const DashboardInner = () => { }> } /> + } /> } /> diff --git a/apps/extension/src/ui/apps/dashboard/layout/DashboardLayout.tsx b/apps/extension/src/ui/apps/dashboard/layout/DashboardLayout.tsx index 91468c4cae..fbe6d42738 100644 --- a/apps/extension/src/ui/apps/dashboard/layout/DashboardLayout.tsx +++ b/apps/extension/src/ui/apps/dashboard/layout/DashboardLayout.tsx @@ -1,6 +1,5 @@ -import { HistoryIcon, SettingsIcon, TalismanHandIcon, ZapIcon } from "@talismn/icons" +import { HistoryIcon, SettingsIcon, TalismanHandIcon, TrendingUpIcon } from "@talismn/icons" import { classNames, isTruthy } from "@talismn/util" -import { TALISMAN_WEB_APP_STAKING_URL } from "extension-shared" import { FC, ReactNode, Suspense, useCallback, useMemo } from "react" import { useTranslation } from "react-i18next" import { matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom" @@ -118,14 +117,14 @@ const HorizontalNav = () => { navigate("/portfolio/tokens" + (searchParams.size ? `?${searchParams}` : "")) }, [navigate, searchParams]) - const handleStakingClick = useCallback(() => { + const handleEarnClick = useCallback(() => { sendAnalyticsEvent({ ...ANALYTICS_PAGE, name: "Goto", - action: "Staking button", + action: "Earn button", }) - window.open(TALISMAN_WEB_APP_STAKING_URL, "_blank") - }, []) + navigate("/earn" + (searchParams.size ? `?${searchParams}` : "")) + }, [navigate, searchParams]) const handleActivityClick = useCallback(() => { sendAnalyticsEvent({ @@ -153,7 +152,12 @@ const HorizontalNav = () => { icon={TalismanHandIcon} route="/portfolio/*" /> - + { + + + ) } diff --git a/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnDiscoverTab.tsx b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnDiscoverTab.tsx new file mode 100644 index 0000000000..a8d763b671 --- /dev/null +++ b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnDiscoverTab.tsx @@ -0,0 +1,18 @@ +import { FC } from "react" +import { useTranslation } from "react-i18next" + +import { EarnAvailableProducts } from "@ui/domains/Earn/components/EarnAvailableProducts" + +export const DashboardEarnDiscoverTab: FC<{ search: string }> = ({ search }) => { + const { t } = useTranslation() + + return ( +
+ {/* Earn on your assets section */} +
+

{t("Earn on your assets")}

+ +
+
+ ) +} diff --git a/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnPage.tsx b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnPage.tsx new file mode 100644 index 0000000000..c90c23e6fb --- /dev/null +++ b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnPage.tsx @@ -0,0 +1,142 @@ +import { Balances } from "@talismn/balances" +import { cn } from "@talismn/util" +import { FC, useCallback, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Outlet, useLocation, useOutletContext } from "react-router-dom" + +import { SearchInput } from "@talisman/components/SearchInput" +import { Fiat } from "@ui/domains/Asset/Fiat" +import { EarnTabs } from "@ui/domains/Earn/components/EarnTabs" +import { useYieldxyzOpportunitiesByTokenId } from "@ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesByTokenId" +import { useAnalyticsPageView } from "@ui/hooks/useAnalyticsPageView" +import { useNavigateWithQuery } from "@ui/hooks/useNavigateWithQuery" +import { useSelectedCurrency } from "@ui/state" + +import { DashboardEarnDiscoverTab } from "./DashboardEarnDiscoverTab" +import { DashboardEarnPositionsTab } from "./DashboardEarnPositionsTab" + +type EarnTabKey = "assets" | "discover" + +type DashboardEarnOutletContext = { + search: string +} + +const TAB_TO_PATH: Record = { + assets: "positions", + discover: "discover", +} + +const getTabFromPath = (pathname: string): EarnTabKey => + pathname.includes("/discover") ? "discover" : "assets" + +const useDashboardEarnOutletContext = () => useOutletContext() + +export const DashboardEarnPage: FC = () => { + const { t } = useTranslation() + const location = useLocation() + const navigate = useNavigateWithQuery() + const selectedTab = useMemo( + () => getTabFromPath(location.pathname), + [location.pathname], + ) + const [search, setSearch] = useState("") + + const handleTabChange = useCallback( + (tab: EarnTabKey) => { + if (tab === selectedTab) return + + navigate(TAB_TO_PATH[tab]) + }, + [navigate, selectedTab], + ) + + const outletContext = useMemo(() => ({ search }), [search]) + + return ( +
+ {/* Header with total balance - always show */} + + + {/* Tabs and Search in same row */} +
+
+ +
+
+ +
+
+ + {/* Tab Content */} +
+ +
+
+ ) +} + +export const DashboardEarnPositionsRoute: FC = () => { + const { search } = useDashboardEarnOutletContext() + + useAnalyticsPageView({ + container: "Fullscreen", + feature: "Earn", + featureVersion: 1, + page: "Earn Positions", + }) + + return +} + +export const DashboardEarnDiscoverRoute: FC = () => { + const { search } = useDashboardEarnOutletContext() + + useAnalyticsPageView({ + container: "Fullscreen", + feature: "Earn", + featureVersion: 1, + page: "Earn Discover", + }) + + return +} + +const EarnPageHeader = () => { + const { t } = useTranslation() + const currency = useSelectedCurrency() + + // this hook already filters selected accounts + const { status, data: tokenProducts } = useYieldxyzOpportunitiesByTokenId() + + const eligibleTotal = useMemo(() => { + if (!tokenProducts) return null + + const allBalances = new Balances(tokenProducts?.flatMap((to) => to.balances.each) || []) + return allBalances.sum.fiat(currency).transferable + }, [currency, tokenProducts]) + + return ( +
+
+
{t("Yield-Eligible Capital")}
+
+ {!eligibleTotal && status === "loading" ? ( +
$0.00
+ ) : ( + + )} +
+
+
+ ) +} diff --git a/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnPositionsTab.tsx b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnPositionsTab.tsx new file mode 100644 index 0000000000..b571519442 --- /dev/null +++ b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardEarnPositionsTab.tsx @@ -0,0 +1,48 @@ +import { ExternalLinkIcon, ZapIcon } from "@talismn/icons" +import { TALISMAN_WEB_APP_STAKING_URL } from "extension-shared" +import { FC } from "react" +import { useTranslation } from "react-i18next" + +import { EarnPositionsList } from "@ui/domains/Earn/components/EarnPositionsList" + +const StakingTile = () => { + const { t } = useTranslation() + + const handleStakingClick = () => { + window.open(TALISMAN_WEB_APP_STAKING_URL, "_blank") + } + + return ( + + ) +} + +export const DashboardEarnPositionsTab: FC<{ search: string }> = ({ search }) => { + const { t } = useTranslation() + + return ( +
+
+

{t("Staking")}

+ +
+ + +
+ ) +} diff --git a/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardYieldxyzYieldPositionsPage.tsx b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardYieldxyzYieldPositionsPage.tsx new file mode 100644 index 0000000000..252d617d3a --- /dev/null +++ b/apps/extension/src/ui/apps/dashboard/routes/Earn/DashboardYieldxyzYieldPositionsPage.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react" +import { Navigate, useParams } from "react-router-dom" + +import { YieldxyzYieldPositions } from "@ui/domains/Earn/yieldxyz/positions/YieldxyzYieldPositions" +import { useAnalytics } from "@ui/hooks/useAnalytics" + +export const DashboardYieldxyzYieldPositionsPage = () => { + const { pageOpenEvent } = useAnalytics() + const { yieldId, address } = useParams() + + useEffect(() => { + pageOpenEvent("earn yieldxyz position", { yieldId }) + }, [pageOpenEvent, yieldId]) + + if (!yieldId || !address) return + + return +} diff --git a/apps/extension/src/ui/apps/dashboard/routes/Earn/index.tsx b/apps/extension/src/ui/apps/dashboard/routes/Earn/index.tsx new file mode 100644 index 0000000000..cd9c166dd5 --- /dev/null +++ b/apps/extension/src/ui/apps/dashboard/routes/Earn/index.tsx @@ -0,0 +1,32 @@ +import { FC } from "react" +import { Navigate, Route, Routes } from "react-router-dom" + +import { PortfolioContainer } from "@ui/domains/Portfolio/PortfolioContainer" + +import { DashboardLayout } from "../../layout/DashboardLayout" +import { + DashboardEarnDiscoverRoute, + DashboardEarnPage, + DashboardEarnPositionsRoute, +} from "./DashboardEarnPage" +import { DashboardYieldxyzYieldPositionsPage } from "./DashboardYieldxyzYieldPositionsPage" + +export const DashboardEarnRoutes: FC = () => { + return ( + + + + }> + } /> + } /> + } /> + + } + /> + + + + ) +} diff --git a/apps/extension/src/ui/apps/popup/components/Navigation/BottomNav.tsx b/apps/extension/src/ui/apps/popup/components/Navigation/BottomNav.tsx index 8331359c58..a1969a9991 100644 --- a/apps/extension/src/ui/apps/popup/components/Navigation/BottomNav.tsx +++ b/apps/extension/src/ui/apps/popup/components/Navigation/BottomNav.tsx @@ -4,10 +4,9 @@ import { HistoryIcon, MenuIcon, TalismanHandIcon, - ZapIcon, + TrendingUpIcon, } from "@talismn/icons" import { classNames } from "@talismn/util" -import { TALISMAN_WEB_APP_STAKING_URL } from "extension-shared" import { FC, ReactNode, useCallback } from "react" import { useTranslation } from "react-i18next" import { useLocation, useMatch, useNavigate } from "react-router-dom" @@ -56,15 +55,15 @@ export const BottomNav = () => { closeQuickSettings() }, [closeQuickSettings, navigate]) - const handleStakingClick = useCallback(() => { + const handleEarnClick = useCallback(() => { sendAnalyticsEvent({ ...ANALYTICS_PAGE, name: "Goto", - action: "Staking button", + action: "Earn button", }) - window.open(TALISMAN_WEB_APP_STAKING_URL, "_blank") - window.close() - }, []) + navigate("/earn") + closeQuickSettings() + }, [closeQuickSettings, navigate]) const handleExpandClick = useCallback(() => { sendAnalyticsEvent({ @@ -109,7 +108,12 @@ export const BottomNav = () => { onClick={handleHomeClick} route="/portfolio/*" /> - + { }> } /> + } /> } /> } /> } /> @@ -110,6 +115,9 @@ const Popup = () => { + + + {/* Render outside of suspense or it will never show in case of migration error */} diff --git a/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnDiscoverTab.tsx b/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnDiscoverTab.tsx new file mode 100644 index 0000000000..1954ea9dca --- /dev/null +++ b/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnDiscoverTab.tsx @@ -0,0 +1,18 @@ +import { FC } from "react" +import { useTranslation } from "react-i18next" + +import { EarnAvailableProducts } from "@ui/domains/Earn/components/EarnAvailableProducts" + +export const PopupEarnDiscoverTab: FC<{ search: string }> = ({ search }) => { + const { t } = useTranslation() + + return ( +
+ {/* Earn on your assets section */} +
+

{t("Earn on your assets")}

+ +
+
+ ) +} diff --git a/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnPage.tsx b/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnPage.tsx new file mode 100644 index 0000000000..f9b00acca8 --- /dev/null +++ b/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnPage.tsx @@ -0,0 +1,173 @@ +import { Balances } from "@talismn/balances" +import { cn } from "@talismn/util" +import { FC, PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { Outlet, useLocation, useOutletContext } from "react-router-dom" + +import { ScrollContainer } from "@talisman/components/ScrollContainer" +import { SearchInput } from "@talisman/components/SearchInput" +import { Fiat } from "@ui/domains/Asset/Fiat" +import { EarnTabs } from "@ui/domains/Earn/components/EarnTabs" +import { useYieldxyzOpportunitiesByTokenId } from "@ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesByTokenId" +import { useAnalyticsPageView } from "@ui/hooks/useAnalyticsPageView" +import { useNavigateWithQuery } from "@ui/hooks/useNavigateWithQuery" +import { useSelectedCurrency } from "@ui/state" + +import { BottomNav } from "../../components/Navigation/BottomNav" +import { NavigationDrawer } from "../../components/Navigation/NavigationDrawer" +import { PopupEarnDiscoverTab } from "./PopupEarnDiscoverTab" +import { PopupEarnPositionsTab } from "./PopupEarnPositionsTab" + +type EarnTabKey = "assets" | "discover" + +type DashboardEarnOutletContext = { + search: string +} + +const TAB_TO_PATH: Record = { + assets: "positions", + discover: "discover", +} + +const getTabFromPath = (pathname: string): EarnTabKey => + pathname.includes("/discover") ? "discover" : "assets" + +const useDashboardEarnOutletContext = () => useOutletContext() + +const PageHeader = () => { + const { t } = useTranslation() + + return ( +
+
{t("Earn")}
+
+ ) +} + +const PopupEarnHeader = () => { + const { t } = useTranslation() + const currency = useSelectedCurrency() + + // this hook already filters selected accounts + const { status, data: tokenProducts } = useYieldxyzOpportunitiesByTokenId() + + const eligibleTotal = useMemo(() => { + if (!tokenProducts) return null + + const allBalances = new Balances(tokenProducts?.flatMap((to) => to.balances.each) || []) + return allBalances.sum.fiat(currency).transferable + }, [currency, tokenProducts]) + + return ( +
+
+
{t("Yield-Eligible Capital")}
+
+ {!eligibleTotal && status === "loading" ? ( +
$0.00
+ ) : ( + + )} +
+
+
+ ) +} + +export const PopupEarnPage: FC = () => { + const { t } = useTranslation() + const location = useLocation() + const navigate = useNavigateWithQuery() + const selectedTab = useMemo( + () => getTabFromPath(location.pathname), + [location.pathname], + ) + const [search, setSearch] = useState("") + + const handleTabChange = useCallback( + (tab: EarnTabKey) => { + if (tab === selectedTab) return + + navigate(TAB_TO_PATH[tab]) + }, + [navigate, selectedTab], + ) + + const outletContext = useMemo(() => ({ search }), [search]) + return ( + <> + +
+ {/* Page Header */} + + + {/* Header with total balance */} + + + {/* Tabs and Search */} +
+ +
+ +
+
+ + {/* Tab Content */} +
+ +
+
+ +
+ + + ) +} + +const Content: FC = ({ children }) => { + //scrollToTop on location change + const scrollableRef = useRef(null) + const location = useLocation() + + useEffect(() => { + scrollableRef.current?.scrollTo(0, 0) + }, [location.pathname]) + + return ( + + {children} + + ) +} + +export const PopupEarnPositionsRoute: FC = () => { + const { search } = useDashboardEarnOutletContext() + + useAnalyticsPageView({ + container: "Popup", + feature: "Earn", + featureVersion: 1, + page: "Earn Positions", + }) + + return +} + +export const PopupEarnDiscoverRoute: FC = () => { + const { search } = useDashboardEarnOutletContext() + + useAnalyticsPageView({ + container: "Popup", + feature: "Earn", + featureVersion: 1, + page: "Earn Discover", + }) + + return +} diff --git a/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnPositionsTab.tsx b/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnPositionsTab.tsx new file mode 100644 index 0000000000..e320c405f1 --- /dev/null +++ b/apps/extension/src/ui/apps/popup/pages/Earn/PopupEarnPositionsTab.tsx @@ -0,0 +1,48 @@ +import { ExternalLinkIcon, ZapIcon } from "@talismn/icons" +import { TALISMAN_WEB_APP_STAKING_URL } from "extension-shared" +import { FC } from "react" +import { useTranslation } from "react-i18next" + +import { EarnPositionsList } from "@ui/domains/Earn/components/EarnPositionsList" + +const PopupStakingTile = () => { + const { t } = useTranslation() + + const handleStakingClick = () => { + window.open(TALISMAN_WEB_APP_STAKING_URL, "_blank") + } + + return ( + + ) +} + +export const PopupEarnPositionsTab: FC<{ search: string }> = ({ search }) => { + const { t } = useTranslation() + + return ( +
+ {/* Staking Section */} +
+

{t("Staking")}

+ +
+ +
+ ) +} diff --git a/apps/extension/src/ui/apps/popup/pages/Earn/PopupYieldxyzYieldPositionsPage.tsx b/apps/extension/src/ui/apps/popup/pages/Earn/PopupYieldxyzYieldPositionsPage.tsx new file mode 100644 index 0000000000..6e26142eab --- /dev/null +++ b/apps/extension/src/ui/apps/popup/pages/Earn/PopupYieldxyzYieldPositionsPage.tsx @@ -0,0 +1,23 @@ +import { useEffect } from "react" +import { Navigate, useParams } from "react-router-dom" + +import { ScrollContainer } from "@talisman/components/ScrollContainer" +import { YieldxyzYieldPositions } from "@ui/domains/Earn/yieldxyz/positions/YieldxyzYieldPositions" +import { useAnalytics } from "@ui/hooks/useAnalytics" + +export const PopupYieldxyzYieldPositionsPage = () => { + const { pageOpenEvent } = useAnalytics() + const { yieldId, address } = useParams() + + useEffect(() => { + pageOpenEvent("earn yieldxyz position", { yieldId }) + }, [pageOpenEvent, yieldId]) + + if (!yieldId || !address) return + + return ( + + + + ) +} diff --git a/apps/extension/src/ui/apps/popup/pages/Earn/index.tsx b/apps/extension/src/ui/apps/popup/pages/Earn/index.tsx new file mode 100644 index 0000000000..097228579c --- /dev/null +++ b/apps/extension/src/ui/apps/popup/pages/Earn/index.tsx @@ -0,0 +1,28 @@ +import { FC } from "react" +import { Navigate, Route, Routes } from "react-router-dom" + +import { PortfolioContainer } from "@ui/domains/Portfolio/PortfolioContainer" + +import { PopupLayout } from "../../Layout/PopupLayout" +import { PopupEarnDiscoverRoute, PopupEarnPage, PopupEarnPositionsRoute } from "./PopupEarnPage" +import { PopupYieldxyzYieldPositionsPage } from "./PopupYieldxyzYieldPositionsPage" + +export const PopupEarnRoutes: FC = () => { + return ( + + + + }> + } /> + } /> + } /> + + } + /> + + + + ) +} diff --git a/apps/extension/src/ui/domains/Account/AccountPillButton.tsx b/apps/extension/src/ui/domains/Account/AccountPillButton.tsx new file mode 100644 index 0000000000..c7e6dbd9cf --- /dev/null +++ b/apps/extension/src/ui/domains/Account/AccountPillButton.tsx @@ -0,0 +1,64 @@ +import { classNames } from "@talismn/util" +import { getAccountGenesisHash } from "extension-core" +import { FC, useMemo } from "react" +import { PillButton, Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui" + +import { useFormattedAddress } from "@ui/hooks/useFormattedAddress" +import { useAccountByAddress } from "@ui/state" + +import { AccountIcon } from "./AccountIcon" +import { AccountTypeIcon } from "./AccountTypeIcon" +import { Address } from "./Address" + +type AccountPillButtonProps = { + address?: string | null + genesisHash?: `0x${string}` | null + className?: string + onClick?: () => void +} + +export const AccountPillButton: FC = ({ + address, + genesisHash: tokenGenesisHash, // used for address format + className, + onClick, +}) => { + const account = useAccountByAddress(address as string) + + const { name, genesisHash: accountGenesisHash } = useMemo(() => { + if (account) return { name: account.name, genesisHash: getAccountGenesisHash(account) } + return { name: undefined, genesisHash: undefined } + }, [account]) + + const formattedAddress = useFormattedAddress( + address ?? undefined, + tokenGenesisHash ?? accountGenesisHash, + ) + const displayAddress = useMemo( + () => (account ? formattedAddress : address) ?? undefined, + [account, address, formattedAddress], + ) + + if (!address) return null + + return ( + +
+ +
+ {name ? ( + + + {name} + + {displayAddress} + + ) : ( +
+ )} +
+ +
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Asset/GenericTokensAndFiat.tsx b/apps/extension/src/ui/domains/Asset/GenericTokensAndFiat.tsx new file mode 100644 index 0000000000..c48bb0fa70 --- /dev/null +++ b/apps/extension/src/ui/domains/Asset/GenericTokensAndFiat.tsx @@ -0,0 +1,100 @@ +import { BalanceFormatter } from "@talismn/balances" +import { classNames } from "@talismn/util" +import { FC, Suspense, useMemo } from "react" + +import { useSelectedCurrency, useTokenRatesFromUsd } from "@ui/state" + +import { AssetLogo } from "./AssetLogo" +import { Fiat } from "./Fiat" +import { Tokens } from "./Tokens" + +type GenericTokensAndFiatProps = { + planck?: string | bigint + symbol: string + decimals: number + logo?: string | null + priceUsd?: number | null + className?: string + as?: "span" | "div" + noTooltip?: boolean + noCountUp?: boolean + isBalance?: boolean + noFiat?: boolean + withLogo?: boolean + logoClassName?: string + tokensClassName?: string + fiatClassName?: string +} + +const GenericTokensAndFiatInner: FC = ({ + planck, + symbol, + decimals, + priceUsd, + logo, + className, + noTooltip, + noCountUp, + isBalance, + noFiat, + tokensClassName, + fiatClassName, + withLogo, + logoClassName, +}) => { + const tokenRates = useTokenRatesFromUsd(priceUsd) + + const balance = useMemo( + () => + typeof decimals === "number" && (typeof planck === "bigint" || typeof planck === "string") + ? new BalanceFormatter(planck, decimals, tokenRates) + : null, + [decimals, planck, tokenRates], + ) + const currency = useSelectedCurrency() + + if (!balance) return null + + return ( + + {withLogo ? ( + + ) : null} + + {/* warning : some tokens (ex: EQ) have a fiatRates object, but with null values for all fiat currencies */} + {balance.fiat(currency) !== null && !noFiat ? ( + <> + {" "} + ( + + ) + + ) : null} + + ) +} + +export const GenericTokensAndFiat: FC = (props) => ( + + + +) diff --git a/apps/extension/src/ui/domains/Earn/components/EarnAvailableProducts.tsx b/apps/extension/src/ui/domains/Earn/components/EarnAvailableProducts.tsx new file mode 100644 index 0000000000..fc2a5b3dc4 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/components/EarnAvailableProducts.tsx @@ -0,0 +1,232 @@ +import { Balances } from "@talismn/balances" +import { TokenId } from "@talismn/chaindata-provider" +import { ChevronRightIcon, LockIcon, UsersIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { YieldDto } from "extension-core" +import { FC, PropsWithChildren, ReactNode, useMemo } from "react" +import { Trans, useTranslation } from "react-i18next" +import { Tooltip, TooltipContent, TooltipTrigger, useOpenClose } from "talisman-ui" + +import { TokenDisplaySymbol } from "@ui/domains/Asset/TokenDisplaySymbol" +import { TokenLogo } from "@ui/domains/Asset/TokenLogo" +import { TokensAndFiat } from "@ui/domains/Asset/TokensAndFiat" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { usePortfolioNavigation } from "@ui/domains/Portfolio/usePortfolioNavigation" +import { + useNetworkById, + useNetworksMapById, + useToken, + useTokensMap, + useYieldxyzProviders, +} from "@ui/state" +import { IS_POPUP } from "@ui/util/constants" + +import { YieldxyzProviderLogo } from "../yieldxyz/components/YieldxyzProviderLogo" +import { useYieldxyzEnterModal } from "../yieldxyz/enter/useYieldxyzEnterModal" +import { useYieldxyzOpportunitiesByTokenId } from "../yieldxyz/hooks/useYieldxyzOpportunitiesByTokenId" +import { EarnTypeBadge } from "./EarnTypeBadge" + +export const EarnAvailableProducts: FC<{ + search: string +}> = ({ search }) => { + useYieldxyzProviders() // preload providers (so their names and logos are available when expanding token rows) + const { t } = useTranslation() + const tokensMap = useTokensMap() + const networksMap = useNetworksMapById() + + const { status, data: products } = useYieldxyzOpportunitiesByTokenId() + + const displayProducts = useMemo(() => { + if (!search) return products + + const lowerSearch = search.toLowerCase() + return products?.filter((p) => { + const token = tokensMap[p.tokenId] + const network = token ? networksMap[token.networkId] : null + const searcheable = [token?.symbol ?? "", token.name ?? "", network?.name ?? ""] + .join(" ") + .toLowerCase() + return searcheable.includes(lowerSearch) + }) + }, [products, search, tokensMap, networksMap]) + + return ( +
+ {displayProducts?.map(({ tokenId, products, bestApr, balances }) => ( + + ))} + {status === "loading" && } + {status === "success" && !products?.length && ( +
+ {t("No opportunities found for your assets")} +
+ )} +
+ ) +} + +const TokenProducts: FC<{ + tokenId: TokenId + products: YieldDto[] + bestApr: number + balances: Balances + isLoading?: boolean +}> = ({ tokenId, products, bestApr, balances, isLoading }) => { + const { t } = useTranslation() + const token = useToken(tokenId) + const network = useNetworkById(token?.networkId) + const { isOpen, toggle } = useOpenClose() + + if (!token || !network) return null + + return ( +
+ +
+ {isOpen && products.map((product) => )} +
+
+ ) +} + +const ProductRow: FC<{ product: YieldDto }> = ({ product }) => { + const { t } = useTranslation() + const { selectedAccount } = usePortfolioNavigation() + const { open } = useYieldxyzEnterModal() + + return ( + + ) +} + +const Metric: FC< + PropsWithChildren<{ icon: ReactNode; tooltip: ReactNode; className?: string }> +> = ({ children, icon, tooltip, className }) => { + const { t } = useTranslation() + return ( + + +
+
{icon}
+
{children ?? t("N/A")}
+
+
+ {tooltip} +
+ ) +} + +const TokenProductsShimmer = () => ( +
+
+
+
+
+ XXXX Token Name +
+
+
+
+
+
+ Network Name +
+
+
+
+
+
0.0000 XXX ($0.00)
+
APY up to 00.00%
+
+ +
+) diff --git a/apps/extension/src/ui/domains/Earn/components/EarnPositionsList.tsx b/apps/extension/src/ui/domains/Earn/components/EarnPositionsList.tsx new file mode 100644 index 0000000000..79d2b437d4 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/components/EarnPositionsList.tsx @@ -0,0 +1,330 @@ +import { Token, TokenId } from "@talismn/chaindata-provider" +import { isAddressEqual, normalizeAddress } from "@talismn/crypto" +import { ChevronDownIcon, ChevronRightIcon } from "@talismn/icons" +import { classNames, cn, isNotNil, LoadableStatus } from "@talismn/util" +import { isNil, toPairs, uniq } from "lodash-es" +import { FC, Fragment, useCallback, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { useOpenClose } from "talisman-ui" + +import { FiatFromUsd } from "@ui/domains/Asset/Fiat" +import { TokenDisplaySymbol } from "@ui/domains/Asset/TokenDisplaySymbol" +import { TokenLogo } from "@ui/domains/Asset/TokenLogo" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { usePortfolioNavigation } from "@ui/domains/Portfolio/usePortfolioNavigation" +import { useNavigateWithQuery } from "@ui/hooks/useNavigateWithQuery" +import { + usePortfolioGlobalData, + useTokensMap, + useYieldxyzPositionsEnhanced, + YieldxyzPositionEnhanced, +} from "@ui/state" +import { IS_POPUP } from "@ui/util/constants" + +import { AccountDisplay } from "../shared/AccountDisplay" +import { YieldxyzProviderLogo } from "../yieldxyz/components/YieldxyzProviderLogo" +import { useGetYieldxyzToken } from "../yieldxyz/hooks/useGetYieldxyzToken" +import { EarnTypeBadge } from "./EarnTypeBadge" + +const YieldPositionRow: FC<{ + position: YieldxyzPositionEnhanced + status: LoadableStatus +}> = ({ position, status }) => { + const navigate = useNavigateWithQuery() + + return ( + + ) +} + +const TokensList: FC<{ position: YieldxyzPositionEnhanced; className?: string }> = ({ + position, + className, +}) => { + const { getYieldxyzTokenId } = useGetYieldxyzToken() + const tokensMap = useTokensMap() + const tokenIds = useMemo(() => { + return uniq([ + ...position.product.inputTokens.map((token) => getYieldxyzTokenId(token)), + ...(position.product.outputToken ? [getYieldxyzTokenId(position.product.outputToken)] : []), + ...position.balances.map((balance) => getYieldxyzTokenId(balance.token)).filter(isNotNil), + ]) + .filter(isNotNil) + .filter((tokenId) => !!tokensMap[tokenId]) // only known tokens + .sort((a, b) => a.localeCompare(b)) + }, [getYieldxyzTokenId, position, tokensMap]) + + return ( +
+ {tokenIds.map((tokenId, i, arr) => ( + + + + + + {i < arr.length - 1 && /} + + ))} +
+ ) +} + +const TokenRow: FC<{ + status: LoadableStatus + token: Token + positions: YieldxyzPositionEnhanced[] + totalUsd: number +}> = ({ token, positions, totalUsd, status }) => { + const [isCollapsed, setIsCollapsed] = useState(false) + + return ( +
+ +
+ {!isCollapsed && + positions.map((position, i) => ( + + ))} +
+
+ ) +} +const EarnTokenRowSkeleton: FC<{ className?: string }> = ({ className }) => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + +export const EarnPositionsList: FC<{ search: string }> = ({ search }) => { + const { t } = useTranslation() + const { isInitialising } = usePortfolioGlobalData() + const { selectedAccount, selectedFolder, selectedAccounts } = usePortfolioNavigation() + const { status, data: positions } = useYieldxyzPositionsEnhanced() + const isLoading = status === "loading" + + const { getYieldxyzToken } = useGetYieldxyzToken() + const tokensMay = useTokensMap() + + const positionsByTokenIdMap = useMemo(() => { + return positions + ?.map((position) => { + const tokens = position.product.inputTokens.map((token) => getYieldxyzToken(token)) + if (tokens.some(isNil)) return null // ignore positions with unknown tokens + return { position, tokenIds: tokens.filter(isNotNil).map((t) => t.id) } + }) + .filter(isNotNil) + .reduce>((acc, { position, tokenIds }) => { + for (const tokenId of tokenIds) { + if (!acc[tokenId]) acc[tokenId] = [] + acc[tokenId].push(position) + } + return acc + }, {}) + }, [positions, getYieldxyzToken]) + + const selectedAccountsPositions = useMemo(() => { + const accountAddresses = selectedAccounts.map((acc) => normalizeAddress(acc.address)) + return toPairs(positionsByTokenIdMap) + .map(([tokenId, allPositions]) => { + const positions = allPositions.filter((position) => + accountAddresses.some((address) => isAddressEqual(address, position.address)), + ) + const totalUsd = positions.reduce((sum, pos) => { + return ( + sum + pos.balances.reduce((bSum, bal) => bSum + parseFloat(bal.amountUsd || "0"), 0) + ) + }, 0) + return { + token: tokensMay[tokenId], + positions, + totalUsd, + } + }) + .filter(({ token, positions }) => !!token && !!positions.length) + .sort((p1, p2) => p2.totalUsd - p1.totalUsd) + }, [positionsByTokenIdMap, selectedAccounts, tokensMay]) + + const displayPositions = useMemo(() => { + const lowerSearch = (search || "").toLowerCase().trim() + if (!lowerSearch) return selectedAccountsPositions + + return selectedAccountsPositions.filter(({ token, positions }) => { + const search = [token.symbol, token.name] + for (const position of positions) { + search.push( + position.product.metadata.name, + position.product.providerId, + ...(position.product.tags ?? []), + ) + for (const balance of position.balances) + search.push(balance.token.symbol, balance.token.name) + } + + return search.join(" ").toLowerCase().includes(lowerSearch) + }) + }, [search, selectedAccountsPositions]) + + // Toggle handlers + const { isOpen: isDefiExpanded, toggle: toggleDefiExpanded } = useOpenClose(true) + const handleDefiToggle = useCallback(() => { + toggleDefiExpanded() + }, [toggleDefiExpanded]) + + // Calculate total fiat value from all Defi positions + const totalDefiAmountUsd = useMemo( + () => displayPositions.reduce((sum, { totalUsd }) => sum + totalUsd, 0), + [displayPositions], + ) + + if (!displayPositions.length && !isInitialising && !isLoading) { + return ( +
+ {selectedAccount + ? t("No earning positions found for this account.") + : selectedFolder + ? t("No earning positions found in this folder.") + : t("No earning positions found.")} +
+ ) + } + + return ( +
+ + {isDefiExpanded && ( +
+ {displayPositions.map(({ token, positions, totalUsd }) => ( + + ))} + {(isInitialising || isLoading) && } +
+ )} +
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/components/EarnTabs.tsx b/apps/extension/src/ui/domains/Earn/components/EarnTabs.tsx new file mode 100644 index 0000000000..a45ee5d64d --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/components/EarnTabs.tsx @@ -0,0 +1,31 @@ +import { FC, useCallback, useMemo } from "react" +import { useTranslation } from "react-i18next" + +import { Tabs } from "@talisman/components/Tabs" + +interface EarnTabsProps { + className?: string + onTabChange: (tab: "assets" | "discover") => void + value: "assets" | "discover" +} + +export const EarnTabs: FC = ({ className, onTabChange, value = "assets" }) => { + const { t } = useTranslation() + + const tabs = useMemo(() => { + const resTabs = [{ label: t("Positions"), value: "assets" }] + resTabs.push({ label: t("Discover"), value: "discover" }) + + return resTabs + }, [t]) + + const handleChange = useCallback( + (value: string) => { + if (value !== "assets" && value !== "discover") return + onTabChange?.(value) + }, + [onTabChange], + ) + + return +} diff --git a/apps/extension/src/ui/domains/Earn/components/EarnTypeBadge.tsx b/apps/extension/src/ui/domains/Earn/components/EarnTypeBadge.tsx new file mode 100644 index 0000000000..50197ef88d --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/components/EarnTypeBadge.tsx @@ -0,0 +1,17 @@ +import { cn } from "@talismn/util" +import { FC, PropsWithChildren } from "react" + +export const EarnTypeBadge: FC> = ({ + children, + className, +}) => ( + + {children} + +) diff --git a/apps/extension/src/ui/domains/Earn/shared/AccountDisplay.tsx b/apps/extension/src/ui/domains/Earn/shared/AccountDisplay.tsx new file mode 100644 index 0000000000..56efe7f96b --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/shared/AccountDisplay.tsx @@ -0,0 +1,49 @@ +import { encodeAnyAddress } from "@talismn/crypto" +import { cn } from "@talismn/util" +import { getAccountGenesisHash } from "extension-core" +import { FC, useMemo } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui" + +import { AccountIcon } from "@ui/domains/Account/AccountIcon" +import { AccountTypeIcon } from "@ui/domains/Account/AccountTypeIcon" +import { Address } from "@ui/domains/Account/Address" +import { useAccountByAddress } from "@ui/state" + +export const AccountDisplay: FC<{ + address: string + ss58Format?: number + className?: string + iconClassName?: string + textClassName?: string +}> = ({ address, ss58Format, className, iconClassName, textClassName }) => { + const formattedAddress = useMemo( + () => encodeAnyAddress(address, { ss58Format }), + [address, ss58Format], + ) + + const account = useAccountByAddress(address) + + return ( + + + + + + {account?.name ??
} + + + + + {formattedAddress} + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/shared/AmountEdit.tsx b/apps/extension/src/ui/domains/Earn/shared/AmountEdit.tsx new file mode 100644 index 0000000000..75c9bf598f --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/shared/AmountEdit.tsx @@ -0,0 +1,313 @@ +import { BalanceFormatter } from "@talismn/balances" +import { Token } from "@talismn/chaindata-provider" +import { AlertCircleIcon, SwapIcon } from "@talismn/icons" +import { TokenRates } from "@talismn/token-rates" +import { classNames, cn, tokensToPlanck } from "@talismn/util" +import { + ChangeEventHandler, + FC, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" +import { PillButton } from "talisman-ui" + +import { TokenDisplaySymbol } from "@ui/domains/Asset/TokenDisplaySymbol" +import { TokenLogo } from "@ui/domains/Asset/TokenLogo" +import { useInputAutoWidth } from "@ui/hooks/useInputAutoWidth" +import { useSelectedCurrency, useToken, useTokenRates } from "@ui/state" + +import { currencyConfig } from "../../Asset/currencyConfig" +import { Fiat } from "../../Asset/Fiat" +import { Tokens } from "../../Asset/Tokens" + +const TokenInput: FC<{ + token: Token + value: bigint | null + onValueChanged: (value: bigint | null) => void + onTokenClick?: () => void +}> = ({ token, value, onValueChanged, onTokenClick }) => { + const formatter = useMemo( + () => (value !== null ? new BalanceFormatter(value, token.decimals) : null), + [token.decimals, value], + ) + + const formattedValue = useMemo(() => formatter?.tokens ?? "", [formatter?.tokens]) + + const [inputValue, setInputValue] = useState(formattedValue) + const refSkipSync = useRef(false) + + useEffect(() => { + if (refSkipSync.current) { + refSkipSync.current = false + return + } + setInputValue(formattedValue) + }, [formattedValue]) + + const handleChange: ChangeEventHandler = useCallback( + (e) => { + refSkipSync.current = true + const nextValue = e.target.value + setInputValue(nextValue) + + if (!token || !nextValue.trim()) return onValueChanged(null) + + try { + const plancks = tokensToPlanck(nextValue, token.decimals) + onValueChanged(BigInt(plancks)) + } catch (err) { + // invalid input, ignore + onValueChanged(null) + } + }, + [onValueChanged, token], + ) + + const refTokensInput = useRef(null) + + // auto focus if empty + const refInitialized = useRef(false) + useEffect(() => { + if (refInitialized.current) return + refInitialized.current = true + if (value === null) refTokensInput.current?.focus() + }, [refTokensInput, value]) + + // resize input to keep content centered + useInputAutoWidth(refTokensInput) + + return ( +
+ + +
+ ) +} + +const FiatInput: FC<{ + token: Token + value: bigint | null + tokenRates: TokenRates + onValueChanged: (value: bigint | null) => void +}> = ({ token, value, tokenRates, onValueChanged }) => { + const currency = useSelectedCurrency() + + const formatter = useMemo( + () => (value === null ? null : new BalanceFormatter(value, token.decimals, tokenRates)), + [token.decimals, tokenRates, value], + ) + + const formattedValue = useMemo( + () => formatter?.fiat(currency)?.toString() ?? "", + [currency, formatter], + ) + + const [inputValue, setInputValue] = useState(formattedValue) + const refSkipSync = useRef(false) + + useEffect(() => { + if (refSkipSync.current) { + refSkipSync.current = false + return + } + setInputValue(formattedValue) + }, [formattedValue]) + + const handleChange: ChangeEventHandler = useCallback( + (e) => { + refSkipSync.current = true + const nextValue = e.target.value + setInputValue(nextValue) + + if (token && tokenRates?.[currency]?.price && nextValue) { + try { + const fiat = parseFloat(nextValue) + const tokens = (fiat / tokenRates[currency].price).toFixed(Math.ceil(token.decimals)) + const plancks = tokensToPlanck(tokens, token.decimals) + return onValueChanged(BigInt(plancks)) + } catch (err) { + // invalid input, ignore + } + } + + return onValueChanged(null) + }, + + [token, tokenRates, currency, onValueChanged], + ) + + const refFiatInput = useRef(null) + + // auto focus if empty + const refInitialized = useRef(false) + useEffect(() => { + if (refInitialized.current) return + refInitialized.current = true + if (value === null) refFiatInput.current?.focus() + }, [value, refFiatInput]) + + // resize input to keep content centered + useInputAutoWidth(refFiatInput) + + if (!tokenRates) return null + + return ( +
+ +
{currencyConfig[currency]?.symbol}
+
+ ) +} + +const DisplayContainer: FC = ({ children }) => { + return
{children}
+} + +const FiatDisplay: FC<{ token: Token; value: bigint | null; tokenRates: TokenRates | null }> = ({ + token, + value, + tokenRates, +}) => { + const currency = useSelectedCurrency() + const formatter = useMemo( + () => (tokenRates ? new BalanceFormatter(value ?? 0n, token.decimals, tokenRates) : null), + [token.decimals, value, tokenRates], + ) + + if (!formatter) return null + + return ( + + + + ) +} + +const TokenDisplay: FC<{ token: Token; value: bigint | null }> = ({ token, value }) => { + const formatter = useMemo( + () => new BalanceFormatter(value ?? 0n, token.decimals), + [token.decimals, value], + ) + + if (!token || !value) return null + + return ( + + + + ) +} + +export type AmountEditErrorProps = { + message: string + details?: string +} + +export const AmountEdit: FC<{ + value: bigint | null + tokenId: string + error?: string | null + onValueChanged: (value: bigint | null) => void + onMaxClick: () => void + onTokenClick?: () => void +}> = ({ tokenId, value, error, onValueChanged, onTokenClick, onMaxClick }) => { + const { t } = useTranslation() + const token = useToken(tokenId) + const tokenRates = useTokenRates(tokenId) + const [isTokenEdit, setIsTokenEdit] = useState(true) + + const toggleIsTokenEdit = useCallback(() => { + setIsTokenEdit((prev) => !prev) + }, []) + + if (!token) return null + + return ( +
+
+ {isTokenEdit || !tokenRates ? ( + + ) : ( + + )} +
+
+ {tokenRates && ( + <> + {!isTokenEdit ? ( + + ) : ( + + )} + + + + + )} + + {t("Max")} + +
+
+ {error} +
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/shared/FormFieldSet.tsx b/apps/extension/src/ui/domains/Earn/shared/FormFieldSet.tsx new file mode 100644 index 0000000000..5b633b88ef --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/shared/FormFieldSet.tsx @@ -0,0 +1,67 @@ +import { InfoIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { FC, PropsWithChildren, ReactNode } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui" + +export const FormFieldSet: FC> = ({ + children, + className, +}) => { + return ( +
+ {children} +
+ ) +} + +export const FormFieldSetRow: FC< + PropsWithChildren<{ + variant?: "xs" | "small" | "default" + label: ReactNode + description?: string + className?: string + labelClassName?: string + valueClassName?: string + }> +> = ({ + variant = "default", + label, + description, + children, + className, + labelClassName, + valueClassName, +}) => { + return ( +
+ + +
+ {label} + {!!description && ( + + )} +
+
+ {!!description && {description}} +
+
{children}
+
+ ) +} + +export const FormFieldSetSeparator: FC<{ className?: string }> = ({ className }) => { + return
+} diff --git a/apps/extension/src/ui/domains/Earn/shared/GenericAmountEdit.tsx b/apps/extension/src/ui/domains/Earn/shared/GenericAmountEdit.tsx new file mode 100644 index 0000000000..4731cd8970 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/shared/GenericAmountEdit.tsx @@ -0,0 +1,324 @@ +import { BalanceFormatter } from "@talismn/balances" +import { AlertCircleIcon, SwapIcon } from "@talismn/icons" +import { classNames, cn, tokensToPlanck } from "@talismn/util" +import { + ChangeEventHandler, + FC, + PropsWithChildren, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { useTranslation } from "react-i18next" +import { PillButton } from "talisman-ui" + +import { AssetLogo } from "@ui/domains/Asset/AssetLogo" +import { useInputAutoWidth } from "@ui/hooks/useInputAutoWidth" +import { useSelectedCurrency, useTokenRatesFromUsd } from "@ui/state" + +import { currencyConfig } from "../../Asset/currencyConfig" +import { Fiat } from "../../Asset/Fiat" +import { Tokens } from "../../Asset/Tokens" + +const TokenInput: FC<{ + symbol: string + decimals: number + logo: string | null | undefined + value: bigint | null + onValueChanged: (value: bigint | null) => void + onTokenClick?: () => void +}> = ({ symbol, decimals, logo, value, onValueChanged, onTokenClick }) => { + const formatter = useMemo( + () => (value !== null ? new BalanceFormatter(value, decimals) : null), + [decimals, value], + ) + + const formattedValue = useMemo(() => formatter?.tokens ?? "", [formatter?.tokens]) + + const [inputValue, setInputValue] = useState(formattedValue) + const refSkipSync = useRef(false) + + useEffect(() => { + if (refSkipSync.current) { + refSkipSync.current = false + return + } + setInputValue(formattedValue) + }, [formattedValue]) + + const handleChange: ChangeEventHandler = useCallback( + (e) => { + refSkipSync.current = true + const nextValue = e.target.value + setInputValue(nextValue) + + if (!nextValue.trim()) return onValueChanged(null) + + try { + const plancks = tokensToPlanck(nextValue, decimals) + onValueChanged(BigInt(plancks)) + } catch (err) { + // invalid input, ignore + onValueChanged(null) + } + }, + [onValueChanged, decimals], + ) + + const refTokensInput = useRef(null) + + // auto focus if empty + const refInitialized = useRef(false) + useEffect(() => { + if (refInitialized.current) return + refInitialized.current = true + if (value === null) refTokensInput.current?.focus() + }, [refTokensInput, value]) + + // resize input to keep content centered + useInputAutoWidth(refTokensInput) + + return ( +
+ + +
+ ) +} + +const FiatInput: FC<{ + decimals: number + priceUsd: number | null + value: bigint | null + onValueChanged: (value: bigint | null) => void +}> = ({ decimals, priceUsd, value, onValueChanged }) => { + const tokenRates = useTokenRatesFromUsd(priceUsd) + const currency = useSelectedCurrency() + + const formatter = useMemo( + () => (value === null ? null : new BalanceFormatter(value, decimals, tokenRates)), + [decimals, tokenRates, value], + ) + + const formattedValue = useMemo( + () => formatter?.fiat(currency)?.toString() ?? "", + [currency, formatter], + ) + + const [inputValue, setInputValue] = useState(formattedValue) + const refSkipSync = useRef(false) + + useEffect(() => { + if (refSkipSync.current) { + refSkipSync.current = false + return + } + setInputValue(formattedValue) + }, [formattedValue]) + + const handleChange: ChangeEventHandler = useCallback( + (e) => { + refSkipSync.current = true + const nextValue = e.target.value + setInputValue(nextValue) + + if (tokenRates?.[currency]?.price && nextValue) { + try { + const fiat = parseFloat(nextValue) + const tokens = (fiat / tokenRates[currency].price).toFixed(Math.ceil(decimals)) + const plancks = tokensToPlanck(tokens, decimals) + return onValueChanged(BigInt(plancks)) + } catch (err) { + // invalid input, ignore + } + } + + return onValueChanged(null) + }, + + [tokenRates, currency, onValueChanged, decimals], + ) + + const refFiatInput = useRef(null) + + // auto focus if empty + const refInitialized = useRef(false) + useEffect(() => { + if (refInitialized.current) return + refInitialized.current = true + if (value === null) refFiatInput.current?.focus() + }, [value, refFiatInput]) + + // resize input to keep content centered + useInputAutoWidth(refFiatInput) + + if (!tokenRates) return null + + return ( +
+ +
{currencyConfig[currency]?.symbol}
+
+ ) +} + +const DisplayContainer: FC = ({ children }) => { + return
{children}
+} + +const FiatDisplay: FC<{ decimals: number; value: bigint | null; priceUsd: number | null }> = ({ + decimals, + value, + priceUsd, +}) => { + const tokenRates = useTokenRatesFromUsd(priceUsd) + const currency = useSelectedCurrency() + const formatter = useMemo( + () => (tokenRates ? new BalanceFormatter(value ?? 0n, decimals, tokenRates) : null), + [tokenRates, value, decimals], + ) + + if (!formatter) return null + + return ( + + + + ) +} + +const TokenDisplay: FC<{ symbol: string; decimals: number; value: bigint | null }> = ({ + symbol, + decimals, + value, +}) => { + const formatter = useMemo(() => new BalanceFormatter(value ?? 0n, decimals), [decimals, value]) + + if (!value) return null + + return ( + + + + ) +} + +export type AmountEditErrorProps = { + message: string + details?: string +} + +export const GenericAmountEdit: FC<{ + value: bigint | null + decimals: number + symbol: string + logo: string | null | undefined + priceUsd: number | null + error?: string | null + onValueChanged: (value: bigint | null) => void + onMaxClick: () => void + onTokenClick?: () => void +}> = ({ + decimals, + symbol, + logo, + priceUsd, + value, + error, + onValueChanged, + onTokenClick, + onMaxClick, +}) => { + const { t } = useTranslation() + const [isTokenEdit, setIsTokenEdit] = useState(true) + + const toggleIsTokenEdit = useCallback(() => { + setIsTokenEdit((prev) => !prev) + }, []) + + return ( +
+
+ {isTokenEdit || !priceUsd ? ( + + ) : ( + + )} +
+
+ {priceUsd && ( + <> + {!isTokenEdit ? ( + + ) : ( + + )} + + + + + )} + + {t("Max")} + +
+
+ {error} +
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/shared/SenderAccountPicker.tsx b/apps/extension/src/ui/domains/Earn/shared/SenderAccountPicker.tsx new file mode 100644 index 0000000000..be54163485 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/shared/SenderAccountPicker.tsx @@ -0,0 +1,201 @@ +import { Balance, Balances } from "@talismn/balances" +import { getNetworkGenesisHash, Network, Token } from "@talismn/chaindata-provider" +import { CheckCircleIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { Account, getAccountGenesisHash, isAccountCompatibleWithNetwork } from "extension-core" +import { FC, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { ScrollContainer } from "@talisman/components/ScrollContainer" +import { SearchInput } from "@talisman/components/SearchInput" +import { AccountIcon } from "@ui/domains/Account/AccountIcon" +import { AccountTypeIcon } from "@ui/domains/Account/AccountTypeIcon" +import { Address } from "@ui/domains/Account/Address" +import { Fiat } from "@ui/domains/Asset/Fiat" +import { Tokens } from "@ui/domains/Asset/Tokens" +import { useFormattedAddress } from "@ui/hooks/useFormattedAddress" +import { useAccounts, useBalances, useNetworkById, useSelectedCurrency, useToken } from "@ui/state" + +type AccountOption = Account & { + disabled: boolean + balances: Balances +} + +export const SenderAccountPicker: FC<{ + tokenId: string + address: string | null + onSelect: (address: string) => void +}> = ({ address, tokenId, onSelect }) => { + const { t } = useTranslation() + const [search, setSearch] = useState("") + const allAccounts = useAccounts("owned") + const allBalances = useBalances("owned") + const token = useToken(tokenId) + const network = useNetworkById(token?.networkId) + + const accountOptions = useMemo(() => { + if (!token || !network) return [] + + return allAccounts + .filter((account) => isAccountCompatibleWithNetwork(network, account)) + .map((account): AccountOption => { + const balances = allBalances.find({ address: account.address, tokenId }) + const disabled = !balances.sum.planck["transferable"] + return { + ...account, + balances, + disabled, + } + }) + .sort((a, b) => { + const fiat1 = a.balances.sum.fiat("usd")["transferable"] || 0n + const fiat2 = b.balances.sum.fiat("usd")["transferable"] || 0n + if (fiat1 > fiat2) return -1 + if (fiat1 < fiat2) return 1 + + const planck1 = a.balances.sum.fiat("usd")["transferable"] || 0n + const planck2 = b.balances.sum.fiat("usd")["transferable"] || 0n + if (planck1 > planck2) return -1 + if (planck1 < planck2) return 1 + + return a.name?.localeCompare(b.name || "") || a.address.localeCompare(b.address) + }) + }, [token, network, allAccounts, allBalances, tokenId]) + + const filteredAccounts = useMemo(() => { + const ls = search.trim().toLowerCase() + if (!ls) return accountOptions + return accountOptions.filter((account) => { + return account.name?.toLowerCase().includes(ls) || account.address.toLowerCase().includes(ls) + }) + }, [accountOptions, search]) + + if (!token || !network) return null + + return ( +
+
+
+ +
+
+ + + +
+ ) +} + +const AccountsList: FC<{ + accounts: AccountOption[] + selected: string | null + onSelect: (address: string) => void + network: Network + token: Token +}> = ({ accounts, selected, onSelect, network, token }) => { + const { t } = useTranslation() + return ( +
+ {accounts?.map((account) => ( + onSelect(account.address)} + /> + ))} + {!accounts?.length && ( +
+ {t("No account matches your search")} +
+ )} +
+ ) +} + +const AccountRow: FC<{ + account: AccountOption + selected: boolean + network: Network + token: Token + onClick: () => void +}> = ({ account, selected, network, token, onClick }) => { + const address = useFormattedAddress(account.address, getNetworkGenesisHash(network)) + + const balance = useMemo(() => { + return account.balances.find({ + tokenId: token?.id, + }).each[0] + }, [account, token]) + + return ( + + ) +} + +const AccountTokenBalance: FC<{ token: Token; balance?: Balance }> = ({ token, balance }) => { + const currency = useSelectedCurrency() + + if (!balance || !token) return null + + return ( +
+
+ +
+
+ +
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzBalanceTypeDisplay.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzBalanceTypeDisplay.tsx new file mode 100644 index 0000000000..53828cbfe0 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzBalanceTypeDisplay.tsx @@ -0,0 +1,21 @@ +import { BalanceDto } from "extension-core" +import { FC } from "react" +import { useTranslation } from "react-i18next" + +export const YieldxyzBalanceTypeDisplay: FC<{ balance: BalanceDto }> = ({ balance }) => { + const { t } = useTranslation() + switch (balance.type) { + case "active": + return t("Supplied") + case "claimable": + return t("Claimable") + case "entering": + return t("Entering") + case "exiting": + return t("Exiting") + case "locked": + return t("Locked") + case "withdrawable": + return t("Withdrawable") + } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProductTitleDisplay.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProductTitleDisplay.tsx new file mode 100644 index 0000000000..a8d289b88e --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProductTitleDisplay.tsx @@ -0,0 +1,19 @@ +import { YieldDto } from "extension-core" +import { FC } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui" + +export const YieldxyzProductTitleDisplay: FC<{ product: YieldDto; className?: string }> = ({ + product, + className, +}) => { + return ( + + + {product.metadata.name} + + {!!product.metadata.description && ( + {product.metadata.description} + )} + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProductYieldDisplay.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProductYieldDisplay.tsx new file mode 100644 index 0000000000..ced33fc8d4 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProductYieldDisplay.tsx @@ -0,0 +1,80 @@ +import { InfoIcon } from "@talismn/icons" +import { YieldDto } from "extension-core" +import { FC, useMemo } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui" + +import { AssetLogo } from "@ui/domains/Asset/AssetLogo" +import { TokenDisplaySymbol } from "@ui/domains/Asset/TokenDisplaySymbol" +import { TokenLogo } from "@ui/domains/Asset/TokenLogo" + +import { useGetYieldxyzToken } from "../hooks/useGetYieldxyzToken" + +export const YieldxyzProductYieldDisplay: FC<{ product: YieldDto }> = ({ product }) => { + const text = useMemo(() => { + if (!product) return null + + const percent = Intl.NumberFormat(undefined, { + style: "percent", + maximumFractionDigits: 1, + }).format(product.rewardRate.total) + + return `${percent} ${product.rewardRate.rateType}` + }, [product]) + + const { getYieldxyzToken } = useGetYieldxyzToken() + + const rewards = useMemo(() => { + return ( + product?.rewardRate.components.map((component) => ({ + ...component, + talismanToken: getYieldxyzToken(component.token), + })) ?? [] + ) + }, [product, getYieldxyzToken]) + + if (!text) return null + + return ( + + +
+ + {text} +
+
+ {!!rewards.length && ( + +
+ {rewards.map((reward, idx) => ( +
+
+ {reward.talismanToken ? ( + + ) : ( + + )} + {reward.talismanToken ? ( + + ) : ( +
{reward.token.symbol}
+ )} +
+
+ {Intl.NumberFormat(undefined, { + style: "percent", + maximumFractionDigits: 1, + }).format(reward.rate)}{" "} + {reward.rateType} +
+
+
+ ))} +
+
+ )} +
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProviderLogo.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProviderLogo.tsx new file mode 100644 index 0000000000..b1c5abcfda --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzProviderLogo.tsx @@ -0,0 +1,61 @@ +import { cn } from "@talismn/util" +import { FC } from "react" +import { Tooltip, TooltipContent, TooltipTrigger } from "talisman-ui" + +import { AssetLogo } from "@ui/domains/Asset/AssetLogo" +import { useYieldxyzProvider } from "@ui/state" + +export const YieldxyzProviderLogo: FC<{ + providerId: string | null | undefined + className?: string +}> = ({ providerId, className }) => { + const { data: provider } = useYieldxyzProvider(providerId) + + return ( + + +
+ +
+
+ {!!provider && ( + +
+
{provider.name}
+ {!!provider.description &&

{provider.description}

} + {typeof provider.tvlUsd === "number" &&
TVL: {provider.tvlUsd}
} +
+
+ )} +
+ ) +} + +export const YieldxyzProviderDisplay: FC<{ + providerId: string | null | undefined + className?: string + logoClassName?: string +}> = ({ providerId, className }) => { + const { data: provider } = useYieldxyzProvider(providerId) + + return ( + + +
+ +
{provider?.name ?? providerId}
+
+
+ {!!provider && ( + +
+
{provider.name}
+ {!!provider.description &&

{provider.description}

} + {typeof provider.tvlUsd === "number" &&
TVL: {provider.tvlUsd}
} + {!!provider.website &&
{provider.website}
} +
+
+ )} +
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzTokensAndFiat.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzTokensAndFiat.tsx new file mode 100644 index 0000000000..4d14089fe1 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzTokensAndFiat.tsx @@ -0,0 +1,48 @@ +import { TokenDto } from "extension-core" +import { FC, useMemo } from "react" + +import { GenericTokensAndFiat } from "@ui/domains/Asset/GenericTokensAndFiat" +import { TokensAndFiat } from "@ui/domains/Asset/TokensAndFiat" + +import { useGetYieldxyzToken } from "../hooks/useGetYieldxyzToken" + +export const YieldxyzTokensAndFiat: FC<{ + token: TokenDto + amountRaw: bigint | string + amountUsd?: string | number | null + + // shared with both TokensAndFiat and GenericTokensAndFiat + className?: string + as?: "span" | "div" + noTooltip?: boolean + noCountUp?: boolean + isBalance?: boolean + noFiat?: boolean + withLogo?: boolean + logoClassName?: string + tokensClassName?: string + fiatClassName?: string +}> = ({ token, amountRaw, amountUsd, ...props }) => { + const { getYieldxyzTokenId } = useGetYieldxyzToken() + + const tokenId = useMemo(() => getYieldxyzTokenId(token), [getYieldxyzTokenId, token]) + + const priceUsd = useMemo(() => { + if (!amountUsd || !Number(amountUsd) || !BigInt(amountRaw)) return undefined + const amount = Number(amountRaw) / 10 ** token.decimals + return Number(amountUsd) / amount + }, [token, amountRaw, amountUsd]) + + if (tokenId) return + + return ( + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzTransactionsStepper.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzTransactionsStepper.tsx new file mode 100644 index 0000000000..c5942e45d2 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/components/YieldxyzTransactionsStepper.tsx @@ -0,0 +1,102 @@ +import { LoaderIcon } from "@talismn/icons" +import { classNames } from "@talismn/util" +import { TransactionDto } from "extension-core" +import { FC } from "react" + +export const YieldxyzTransactionsStepper: FC<{ + transactions: TransactionDto[] + stepIndex: number + isProcessing?: boolean +}> = ({ transactions, stepIndex, isProcessing: isSubmitting }) => { + if (!transactions?.length) return null + + const clampedStepIndex = Math.min(Math.max(stepIndex, 0), transactions.length - 1) + const columns = `repeat(${transactions.length}, minmax(0, 1fr))` + const lineLeftPct = 50 / transactions.length + const lineWidthPct = (100 * (transactions.length - 1)) / transactions.length + const activeLineWidthPct = + transactions.length > 1 ? (clampedStepIndex / (transactions.length - 1)) * lineWidthPct : 0 + + return ( +
+
+ {transactions.length > 1 && ( + <> +
+
+ + )} + +
+ {transactions.map((transaction, index) => { + const isActive = index <= clampedStepIndex + const isProcessing = + transaction.status === "BROADCASTED" || (isSubmitting && index === clampedStepIndex) + + return ( +
+
+ {isProcessing ? ( + + ) : ( + + {index + 1} + + )} +
+
+ ) + })} +
+
+ +
+ {transactions.map((transaction, index) => { + const isActive = index <= clampedStepIndex + const label = transaction.type.replaceAll("_", " ").toLowerCase() + + return ( +
+ {label} +
+ ) + })} +
+
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/YieldxyzEnterPositionModal.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/YieldxyzEnterPositionModal.tsx new file mode 100644 index 0000000000..4910dbb2d7 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/YieldxyzEnterPositionModal.tsx @@ -0,0 +1,25 @@ +import { FC, Suspense } from "react" +import { Modal } from "talisman-ui" + +import { PopupSizeModalContainer } from "@talisman/components/PopupSizeModalContainer" +import { SuspenseTracker } from "@talisman/components/SuspenseTracker" + +import { useYieldxyzEnterModal } from "./useYieldxyzEnterModal" +import { YieldxyzEnterWizardProvider } from "./useYieldxyzEnterWizard" +import { YieldxyzEnterPositionWizard } from "./YieldxyzEnterPositionWizard" + +export const YieldxyzEnterPositionModal: FC = () => { + const { isOpen, close, args: stateInit } = useYieldxyzEnterModal() + + return ( + + + }> + + + + + + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/YieldxyzEnterPositionWizard.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/YieldxyzEnterPositionWizard.tsx new file mode 100644 index 0000000000..abac2fc963 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/YieldxyzEnterPositionWizard.tsx @@ -0,0 +1,27 @@ +import { FC } from "react" + +import { YieldxyzEnterStepAccount } from "./steps/YieldxyzEnterStepAccount" +import { YieldxyzEnterStepAmount } from "./steps/YieldxyzEnterStepAmount" +import { YieldxyzEnterStepConfirm } from "./steps/YieldxyzEnterStepConfirm" +import { YieldxyzEnterStepProduct } from "./steps/YieldxyzEnterStepProduct" +import { YieldxyzEnterStepToken } from "./steps/YieldxyzEnterStepToken" +import { useYieldxyzEnterWizard } from "./useYieldxyzEnterWizard" + +export const YieldxyzEnterPositionWizard: FC = () => { + const { step, isLoadingProduct } = useYieldxyzEnterWizard() + + if (isLoadingProduct) return null + + switch (step) { + case "token": + return + case "product": + return + case "account": + return + case "amount": + return + case "confirm": + return + } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepAccount.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepAccount.tsx new file mode 100644 index 0000000000..8289459e8b --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepAccount.tsx @@ -0,0 +1,26 @@ +import { FC } from "react" +import { useTranslation } from "react-i18next" +import { WizardModalDialog } from "talisman-ui" + +import { SenderAccountPicker } from "../../../shared/SenderAccountPicker" +import { useYieldxyzEnterModal } from "../useYieldxyzEnterModal" +import { useYieldxyzEnterWizard } from "../useYieldxyzEnterWizard" + +export const YieldxyzEnterStepAccount: FC = () => { + const { t } = useTranslation() + const { close } = useYieldxyzEnterModal() + const { address, tokenIn, onAccountChanged } = useYieldxyzEnterWizard() + + if (!tokenIn) throw new Error("TokenIn is not defined") + + return ( + + + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepAmount.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepAmount.tsx new file mode 100644 index 0000000000..30e000be29 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepAmount.tsx @@ -0,0 +1,209 @@ +import { cn } from "@talismn/util" +import { formatDuration, intervalToDuration } from "date-fns" +import { TimePeriodDto } from "extension-core" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Button, WizardModalDialog } from "talisman-ui" + +import { AccountPillButton } from "@ui/domains/Account/AccountPillButton" +import { TokensAndFiat } from "@ui/domains/Asset/TokensAndFiat" +import { AmountEdit } from "@ui/domains/Earn/shared/AmountEdit" +import { YieldxyzProviderDisplay } from "@ui/domains/Earn/yieldxyz/components/YieldxyzProviderLogo" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { useDateFnsLocale } from "@ui/hooks/useDateFnsLocale" + +import { FormFieldSet, FormFieldSetRow } from "../../../shared/FormFieldSet" +import { YieldxyzProductTitleDisplay } from "../../components/YieldxyzProductTitleDisplay" +import { YieldxyzProductYieldDisplay } from "../../components/YieldxyzProductYieldDisplay" +import { useYieldxyzEnterModal } from "../useYieldxyzEnterModal" +import { useYieldxyzEnterWizard } from "../useYieldxyzEnterWizard" + +export const YieldxyzEnterStepAmount = () => { + const { t } = useTranslation() + const { close } = useYieldxyzEnterModal() + const { address, goTo, canCreateAction, createAction, product } = useYieldxyzEnterWizard() + + const [processing, setProcessing] = useState(false) + + const handleSubmit = async () => { + setProcessing(true) + try { + await createAction() + goTo("confirm") + } finally { + setProcessing(false) + } + } + + if (!product) return null + + return ( + +
+ + + goTo("account")} + /> + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ ) +} + +const PeriodDisplay = ({ period }: { period: TimePeriodDto | undefined }) => { + const { t } = useTranslation() + const locale = useDateFnsLocale() + + return useMemo(() => { + if (!period?.seconds) return t("None") + const duration = intervalToDuration({ start: 0, end: period.seconds * 1000 }) + return formatDuration(duration, { locale }) + }, [locale, period?.seconds, t]) +} + +const ClaimMechanismDisplay = () => { + const { t } = useTranslation() + + const { product } = useYieldxyzEnterWizard() + + return useMemo(() => { + if (!product) return null + + const mode = product.mechanics.rewardClaiming === "auto" ? t("Automatic") : t("Manual") + + switch (product.mechanics.rewardSchedule) { + case "block": + return t(`{{mode}} every block`, { mode }) + case "campaign": + return t(`{{mode}} every campaign`, { mode }) + case "day": + return t(`{{mode}} daily`, { mode }) + case "week": + return t(`{{mode}} weekly`, { mode }) + case "month": + return t(`{{mode}} monthly`, { mode }) + case "epoch": + return t(`{{mode}} every epoch`, { mode }) + case "era": + return t(`{{mode}} every era`, { mode }) + case "hour": + return t(`{{mode}} hourly`, { mode }) + } + + return + }, [product, t]) +} + +const NetworkDisplay = () => { + const { tokenIn } = useYieldxyzEnterWizard() + + if (!tokenIn) return null + + return ( +
+ + +
+ ) +} + +const DepositAmountEdit = () => { + const { tokenIn, amountIn, validationError, onAmountInChanged, setMaxAmountIn } = + useYieldxyzEnterWizard() + + if (!tokenIn) throw new Error("TokenIn is not defined") + + return ( + + ) +} + +const AvailableBalance = () => { + const { balance, tokenIn, isLoadingBalance } = useYieldxyzEnterWizard() + + if (!tokenIn) return null + + if (!balance?.transferable.planck && isLoadingBalance) + return ( +
+ 0 TNK ($0.00) +
+ ) + + return ( + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepConfirm.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepConfirm.tsx new file mode 100644 index 0000000000..447f5cb13b --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepConfirm.tsx @@ -0,0 +1,242 @@ +import { AlertCircleIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Tooltip, TooltipContent, TooltipTrigger, WizardModalDialog } from "talisman-ui" +import { TransactionRequest } from "viem" + +import { TokensAndFiat } from "@ui/domains/Asset/TokensAndFiat" +import { EthFeeSelect } from "@ui/domains/Ethereum/GasSettings/EthFeeSelect" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { RiskAnalysisProvider } from "@ui/domains/Sign/risk-analysis/context" +import { RiskAnalysisPillButton } from "@ui/domains/Sign/risk-analysis/RiskAnalysisPillButton" +import { TxSubmitButton } from "@ui/domains/Sign/TxSubmitButton/TxSignButton" +import { TxSubmitButtonTransaction } from "@ui/domains/Sign/TxSubmitButton/types" + +import { AccountDisplay } from "../../../shared/AccountDisplay" +import { FormFieldSet, FormFieldSetRow, FormFieldSetSeparator } from "../../../shared/FormFieldSet" +import { YieldxyzProductTitleDisplay } from "../../components/YieldxyzProductTitleDisplay" +import { YieldxyzProductYieldDisplay } from "../../components/YieldxyzProductYieldDisplay" +import { YieldxyzProviderDisplay } from "../../components/YieldxyzProviderLogo" +import { YieldxyzTransactionsStepper } from "../../components/YieldxyzTransactionsStepper" +import { useYieldxyzEnterModal } from "../useYieldxyzEnterModal" +import { useYieldxyzEnterWizard } from "../useYieldxyzEnterWizard" + +export const YieldxyzEnterStepConfirm = () => { + const { t } = useTranslation() + const { close } = useYieldxyzEnterModal() + const { tokenIn, amountIn, address, action, network, product, transaction, goTo } = + useYieldxyzEnterWizard() + + if (!tokenIn || !amountIn || !address || !product || !action) return null + + return ( + + goTo("amount")} + onCloseClick={close} + > +
+
+ {action.transactions.length > 1 + ? t("Approve {{count}} transactions", { count: action.transactions.length }) + : t("Approve transaction")} +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} + +const RiskAnalysisButton = () => { + const { transaction } = useYieldxyzEnterWizard() + + if (transaction?.platform !== "ethereum") return null + + return ( +
+ +
+ ) +} + +const TransactionError = () => { + const { transaction, isProcessing } = useYieldxyzEnterWizard() + + return ( + + +
+ {transaction?.error} +
+
+ {!!transaction?.errorDetails && {transaction?.errorDetails}} +
+ ) +} + +const StepsProgressDisplay = () => { + const { action, stepIndex, isProcessing } = useYieldxyzEnterWizard() + + if (!action || stepIndex === null) return null + + return ( + + ) +} + +const SubmitButton = () => { + const { t } = useTranslation() + const { + transaction, + isProcessing, + onSubmit, + stepIndex: txIndex, + action, + } = useYieldxyzEnterWizard() + + const tx = useMemo(() => { + if (!transaction?.transaction) return null + switch (transaction.platform) { + case "ethereum": + return { + platform: "ethereum", + payload: transaction.transaction as TransactionRequest, + networkId: transaction.networkId, + } + default: + return null + } + }, [transaction]) + + return ( + + ) +} + +const NetworkDisplay = () => { + const { tokenIn } = useYieldxyzEnterWizard() + + if (!tokenIn) return null + + return ( +
+ + +
+ ) +} + +const NetworkFeeRow = () => { + const { network } = useYieldxyzEnterWizard() + + switch (network?.platform) { + case "ethereum": + return + default: + return null + } +} + +const NetworkFeeRowEth = () => { + const { t } = useTranslation() + const { transaction } = useYieldxyzEnterWizard() + + // keep the latest valid tx in state so we still have content to display after tx is submitted. + // without this we'd be getting a lot of flickering and bad UX + const [tx, setTx] = useState(transaction) + useEffect(() => { + if (transaction?.platform === "ethereum" && transaction.transaction && transaction.txDetails) + setTx(transaction) + }, [transaction]) + + return ( + <> + + {!!tx?.transaction && !!tx.txDetails && ( + + )} + + + {!!tx?.txDetails && ( + + )} + + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepProduct.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepProduct.tsx new file mode 100644 index 0000000000..1b73cd86f0 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepProduct.tsx @@ -0,0 +1,158 @@ +import { TokenId } from "@talismn/chaindata-provider" +import { CheckCircleIcon, LockIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { YieldDto } from "extension-core" +import { FC, PropsWithChildren, ReactNode, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Tooltip, TooltipContent, TooltipTrigger, WizardModalDialog } from "talisman-ui" + +import { ScrollContainer } from "@talisman/components/ScrollContainer" +import { SearchInput } from "@talisman/components/SearchInput" + +import { YieldxyzProductYieldDisplay } from "../../components/YieldxyzProductYieldDisplay" +import { YieldxyzProviderLogo } from "../../components/YieldxyzProviderLogo" +import { useYieldxyzOpportunitiesForTokenId } from "../../hooks/useYieldxyzOpportunitiesForTokenId" +import { useYieldxyzEnterModal } from "../useYieldxyzEnterModal" +import { useYieldxyzEnterWizard } from "../useYieldxyzEnterWizard" + +export const YieldxyzEnterStepProduct: FC = () => { + const { t } = useTranslation() + const { close } = useYieldxyzEnterModal() + const { pickerTokenId, onProductChanged, productId, goTo } = useYieldxyzEnterWizard() + + if (!pickerTokenId) throw new Error("PickerTokenId is not defined") + + return ( + goTo("token")} + > + + + ) +} + +const YieldxyzProductPicker: FC<{ + tokenId: TokenId + productId?: string | null + onSelect: (productId: string) => void +}> = ({ tokenId, productId, onSelect }) => { + const { t } = useTranslation() + const [search, setSearch] = useState("") + + const products = useYieldxyzOpportunitiesForTokenId(tokenId) // hypothetical hook to get available products + + const displayProducts = useMemo(() => { + if (!search) return products + + return products?.filter((p) => { + return [p.id, p.metadata.name, ...(p.tags ?? [])] + .join(" ") + .toLowerCase() + .includes(search.toLowerCase()) + }) + }, [products, search]) + + return ( +
+
+ +
+ + + +
+ ) +} + +const ProductsList: FC<{ + products: YieldDto[] + selected: string | null + onSelect: (productId: string) => void +}> = ({ products, selected, onSelect }) => { + const { t } = useTranslation() + return ( +
+ {products?.map((product) => ( + onSelect(product.id)} + /> + ))} + {!products?.length && ( +
+ {t("No product matches your search")} +
+ )} +
+ ) +} + +const ProductRow: FC<{ + product: YieldDto + selected: boolean + onClick: () => void +}> = ({ product, selected, onClick }) => { + const { t } = useTranslation() + return ( + + ) +} + +const Metric: FC< + PropsWithChildren<{ icon: ReactNode; tooltip: ReactNode; className?: string }> +> = ({ children, icon, tooltip, className }) => { + const { t } = useTranslation() + return ( + + +
+
{icon}
+
{children ?? t("N/A")}
+
+
+ {tooltip} +
+ ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepToken.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepToken.tsx new file mode 100644 index 0000000000..4d8fceea2f --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/steps/YieldxyzEnterStepToken.tsx @@ -0,0 +1,42 @@ +import { Token } from "@talismn/chaindata-provider" +import { FC, useCallback } from "react" +import { useTranslation } from "react-i18next" +import { WizardModalDialog } from "talisman-ui" + +import { TokenPicker } from "@ui/domains/Asset/TokenPicker" + +import { useYieldxyzEnterModal } from "../useYieldxyzEnterModal" +import { useYieldxyzEnterWizard } from "../useYieldxyzEnterWizard" + +export const YieldxyzEnterStepToken: FC = () => { + const { t } = useTranslation() + const { close } = useYieldxyzEnterModal() + const { pickerTokenIds, pickerTokenId, onPickerTokenChanged } = useYieldxyzEnterWizard() + + const tokenFilter = useCallback( + (token: Token): boolean => { + if (!pickerTokenIds) return false // safety check to block user, this should not happen + return pickerTokenIds.includes(token.id) + }, + [pickerTokenIds], + ) + + if (!pickerTokenIds) throw new Error("PickerTokenIds is not defined") + + return ( + + + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/useYieldxyzEnterModal.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/useYieldxyzEnterModal.ts new file mode 100644 index 0000000000..1cdec794be --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/useYieldxyzEnterModal.ts @@ -0,0 +1,5 @@ +import { createGlobalOpenClose } from "@talisman/hooks/createGlobalOpenClose" + +import { YieldxyzEnterWizardInit } from "./useYieldxyzEnterWizard" + +export const [useYieldxyzEnterModal] = createGlobalOpenClose() diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/enter/useYieldxyzEnterWizard.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/useYieldxyzEnterWizard.ts new file mode 100644 index 0000000000..ac121bac7d --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/enter/useYieldxyzEnterWizard.ts @@ -0,0 +1,230 @@ +import { Balance } from "@talismn/balances" +import { isTokenInTypes, TokenId } from "@talismn/chaindata-provider" +import { isNotNil, planckToTokens } from "@talismn/util" +import { isAccountOwned } from "extension-core" +import { log } from "extension-shared" +import { useCallback, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { provideContext } from "@talisman/util/provideContext" +import { api } from "@ui/api" +import { BalanceByParamsProps, useBalancesByParams } from "@ui/hooks/useBalancesByParams" +import { useDummyTransaction } from "@ui/hooks/useDummyTransaction" +import { useAccountByAddress, useNetworkById, useYieldxyzProduct } from "@ui/state" + +import { useGetYieldxyzToken } from "../hooks/useGetYieldxyzToken" +import { useYieldxyzAction } from "../hooks/useYieldxyzAction" +import { useYieldxyzTransactionManager } from "../hooks/useYieldxyzActionManager" +import { useYieldxyzActionValidation } from "../hooks/useYieldxyzActionValidation" +import { useYieldxyzEnterModal } from "./useYieldxyzEnterModal" + +export type YieldxyzEnterWizardInit = { + address?: string + pickerTokenIds?: TokenId[] // used to restrict token selection when opening the wizard from portfolio + productId?: string +} + +export type YieldxyzEnterWizardState = { + step: "token" | "product" | "account" | "amount" | "confirm" + pickerTokenId?: TokenId | null // only used when opening the wizard with a specific token selected + address: string | null + productId: string | null + amountIn: bigint | null +} + +const advanceStep = (state: YieldxyzEnterWizardState): YieldxyzEnterWizardState => { + const selectStep = (state: YieldxyzEnterWizardState) => { + if (!state.productId) return state.pickerTokenId ? "product" : "token" + + if (!state.address) return "account" + return state.step + } + + const step = selectStep(state) + return { ...state, step } +} + +const initializeState = (init: YieldxyzEnterWizardInit | null): YieldxyzEnterWizardState => + advanceStep({ + step: "amount", + address: init?.address ?? null, + productId: init?.productId ?? null, + amountIn: null, + }) + +const useYieldxyzEnterWizardProvider = ({ + stateInit, +}: { + stateInit: YieldxyzEnterWizardInit | null +}) => { + const { t } = useTranslation() + const { close, isOpen } = useYieldxyzEnterModal() + const [state, setState] = useState(() => initializeState(stateInit)) + const { status, data: product } = useYieldxyzProduct(state.productId) + const { getYieldxyzToken } = useGetYieldxyzToken() + + const tokenIn = useMemo(() => { + if (!product?.inputTokens.length) return null + + const tokens = product.inputTokens.map(getYieldxyzToken).filter(isNotNil) + if (tokens.length !== product.inputTokens.length) return null + + if (tokens.length > 1) { + // some products support both ETH and WETH as inputs. allow those but force native token as input + const natives = tokens.filter((t) => + isTokenInTypes(t, ["evm-native", "substrate-native", "sol-native"]), + ) + if (natives.length === 1) return natives[0]! + + log.error("Product has multiple different input tokens, which is not supported", { + product, + tokens, + }) + return null + } + + return tokens[0]! + }, [product, getYieldxyzToken]) + + const account = useAccountByAddress(state.address) + const network = useNetworkById(tokenIn?.networkId) + + const balanceParams = useMemo(() => { + if (!state.address || !tokenIn) return {} + return { + addressesAndTokens: { + addresses: [state.address], + tokenIds: [tokenIn.id], + }, + } + }, [state.address, tokenIn]) + const { status: balancesStatus, balances } = useBalancesByParams(balanceParams) + const balance = useMemo(() => { + return balances.each[0] as Balance | undefined + }, [balances]) + + const dummyTx = useDummyTransaction({ + address: state.address ?? undefined, + tokenId: tokenIn?.id ?? undefined, + }) + + const [inputs, talismanValidationError] = useMemo(() => { + if (!state.amountIn || !tokenIn || !balance) return [null, null] + if (!isAccountOwned(account)) return [null, t("Unable to transact with external accounts")] + if (state.amountIn > balance.transferable.planck) return [null, t("Insufficient balance")] + + const inputs = { amount: planckToTokens(state.amountIn.toString(), tokenIn.decimals) } + return [inputs, null] + }, [state.amountIn, tokenIn, balance, account, t]) + + const { args, error: yieldxyzValidationError } = useYieldxyzActionValidation({ + schema: product?.mechanics.arguments?.enter, + inputs, + }) + + const { + canCreateAction, + action, // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + isLoading: isLoadingAction, + error: errorAction, + createAction, + refreshAction, + submitActionTransaction, + } = useYieldxyzAction({ + type: "enter", + address: state.address, + yieldId: state.productId, + args, + }) + + const onAmountInChanged = useCallback((amountIn: bigint | null) => { + setState((state) => ({ ...state, amountIn })) + }, []) + + const onAccountChanged = useCallback((address: string | null) => { + setState((state) => advanceStep({ ...state, address, step: "amount" })) + }, []) + + const onPickerTokenChanged = useCallback((pickerTokenId: string | null) => { + setState((state) => advanceStep({ ...state, pickerTokenId, step: "product" })) + }, []) + + const onProductChanged = useCallback((productId: string | null) => { + setState((state) => advanceStep({ ...state, productId, step: "amount" })) + }, []) + + const goTo = useCallback((step: YieldxyzEnterWizardState["step"]) => { + setState((state) => ({ ...state, step })) + }, []) + + const onCompleted = useCallback(() => { + // do not await the refresh or UI will flicker + if (state.address && state.productId) + api.yieldxyzPositionRefresh({ + address: state.address, + yieldId: state.productId, + }) + if (isOpen) close() + }, [close, isOpen, state.address, state.productId]) + + const setMaxAmountIn = useCallback(() => { + if (!tokenIn || !balance) return + + // fee margin 10x the cost of a transfer tx + const feeMargin = (dummyTx?.estimatedFee ? BigInt(dummyTx.estimatedFee) : 0n) * 10n + + // for native tokens, we need to keep some amount available for fees + // however we do not have access to the payloads here to estimate fees accurately, + // so we just leave a fixed buffer for now. this should be improved in the future + const maxAmmount = isTokenInTypes(tokenIn, ["evm-native", "substrate-native", "sol-native"]) + ? balance.transferable.planck - feeMargin > 0n + ? balance.transferable.planck - feeMargin + : 0n + : balance.transferable.planck + + setState((state) => ({ + ...state, + amountIn: maxAmmount, + })) + }, [tokenIn, balance, dummyTx?.estimatedFee]) + + const { stepIndex, transaction, isProcessing, onSubmit } = useYieldxyzTransactionManager({ + action, + address: state.address, + networkId: tokenIn?.networkId ?? null, + refreshAction, + submitActionTransaction, + onCompleted, + }) + + return { + ...state, + tokenIn, + network, + balance, + product, + validationError: talismanValidationError ?? yieldxyzValidationError, + goTo, + onAmountInChanged, + setMaxAmountIn, + onAccountChanged, + onProductChanged, + onPickerTokenChanged, + onSubmit, + isLoadingBalance: balancesStatus === "initialising", + isLoadingProduct: status === "loading" && !product, + isLoadingAction, + isProcessing, + action, + errorAction, + stepIndex, + transaction, + canCreateAction, + createAction, + pickerTokenIds: stateInit?.pickerTokenIds, + } +} + +export const [YieldxyzEnterWizardProvider, useYieldxyzEnterWizard] = provideContext( + useYieldxyzEnterWizardProvider, +) diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/exit/YieldxyzExitPositionModal.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/YieldxyzExitPositionModal.tsx new file mode 100644 index 0000000000..1b0e159683 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/YieldxyzExitPositionModal.tsx @@ -0,0 +1,25 @@ +import { FC, Suspense } from "react" +import { Modal } from "talisman-ui" + +import { PopupSizeModalContainer } from "@talisman/components/PopupSizeModalContainer" +import { SuspenseTracker } from "@talisman/components/SuspenseTracker" + +import { useYieldxyzExitModal } from "./useYieldxyzExitModal" +import { YieldxyzExitWizardProvider } from "./useYieldxyzExitWizard" +import { YieldxyzExitPositionWizard } from "./YieldxyzExitPositionWizard" + +export const YieldxyzExitPositionModal: FC = () => { + const { isOpen, close, args: position } = useYieldxyzExitModal() + + return ( + + + }> + + + + + + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/exit/YieldxyzExitPositionWizard.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/YieldxyzExitPositionWizard.tsx new file mode 100644 index 0000000000..672b6c02ee --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/YieldxyzExitPositionWizard.tsx @@ -0,0 +1,16 @@ +import { FC } from "react" + +import { YieldxyzExitStepAmount } from "./steps/YieldxyzExitStepAmount" +import { YieldxyzExitStepConfirm } from "./steps/YieldxyzExitStepConfirm" +import { useYieldxyzExitWizard } from "./useYieldxyzExitWizard" + +export const YieldxyzExitPositionWizard: FC = () => { + const { step } = useYieldxyzExitWizard() + + switch (step) { + case "confirm": + return + default: + return + } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/exit/steps/YieldxyzExitStepAmount.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/steps/YieldxyzExitStepAmount.tsx new file mode 100644 index 0000000000..ea0a6111b4 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/steps/YieldxyzExitStepAmount.tsx @@ -0,0 +1,225 @@ +import { formatDuration, intervalToDuration } from "date-fns" +import { TimePeriodDto } from "extension-core" +import { isEqual } from "lodash-es" +import { useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Button, WizardModalDialog } from "talisman-ui" + +import { FiatFromUsd } from "@ui/domains/Asset/Fiat" +import { Tokens } from "@ui/domains/Asset/Tokens" +import { AccountDisplay } from "@ui/domains/Earn/shared/AccountDisplay" +import { GenericAmountEdit } from "@ui/domains/Earn/shared/GenericAmountEdit" +import { YieldxyzProviderDisplay } from "@ui/domains/Earn/yieldxyz/components/YieldxyzProviderLogo" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { useDateFnsLocale } from "@ui/hooks/useDateFnsLocale" + +import { FormFieldSet, FormFieldSetRow } from "../../../shared/FormFieldSet" +import { YieldxyzProductTitleDisplay } from "../../components/YieldxyzProductTitleDisplay" +import { YieldxyzProductYieldDisplay } from "../../components/YieldxyzProductYieldDisplay" +import { useYieldxyzExitModal } from "../useYieldxyzExitModal" +import { useYieldxyzExitWizard } from "../useYieldxyzExitWizard" + +export const YieldxyzExitStepAmount = () => { + const { t } = useTranslation() + const { close } = useYieldxyzExitModal() + const { position, goTo, canCreateAction, createAction, network } = useYieldxyzExitWizard() + + const [processing, setProcessing] = useState(false) + + const handleSubmit = async () => { + setProcessing(true) + try { + await createAction() + goTo("confirm") + } finally { + setProcessing(false) + } + } + + if (!position?.product) return null + + return ( + +
+ + + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ ) +} + +const PeriodDisplay = ({ period }: { period: TimePeriodDto | undefined }) => { + const { t } = useTranslation() + const locale = useDateFnsLocale() + + return useMemo(() => { + if (!period?.seconds) return t("None") + const duration = intervalToDuration({ start: 0, end: period.seconds * 1000 }) + return formatDuration(duration, { locale }) + }, [locale, period?.seconds, t]) +} + +const ClaimMechanismDisplay = () => { + const { t } = useTranslation() + + const { position } = useYieldxyzExitWizard() + + return useMemo(() => { + if (!position?.product) return null + + const mode = position.product.mechanics.rewardClaiming === "auto" ? t("Automatic") : t("Manual") + + switch (position.product.mechanics.rewardSchedule) { + case "block": + return t(`{{mode}} every block`, { mode }) + case "campaign": + return t(`{{mode}} every campaign`, { mode }) + case "day": + return t(`{{mode}} daily`, { mode }) + case "week": + return t(`{{mode}} weekly`, { mode }) + case "month": + return t(`{{mode}} monthly`, { mode }) + case "epoch": + return t(`{{mode}} every epoch`, { mode }) + case "era": + return t(`{{mode}} every era`, { mode }) + case "hour": + return t(`{{mode}} hourly`, { mode }) + } + + return + }, [position, t]) +} + +const NetworkDisplay = () => { + const { position } = useYieldxyzExitWizard() + + if (!position) return null + + return ( +
+ + +
+ ) +} + +const ExitAmountEdit = () => { + const { position, amountOut, validationError, onAmountOutChanged, setMaxAmountOut } = + useYieldxyzExitWizard() + + const priceUsd = useMemo(() => { + try { + if (!position) return null + const anyBalance = position?.balances.find((b) => isEqual(b.token, position.product.token)) + if (!anyBalance?.amountUsd || !Number(anyBalance.amount)) return null + return Number(anyBalance.amountUsd) / Number(anyBalance.amount) + } catch { + // if anything goes wrong, just return null instead of crashing the whole component + return null + } + }, [position]) + + if (!position) throw new Error("TokenIn is not defined") + + return ( + + ) +} + +const PositionBalance = () => { + const { balance } = useYieldxyzExitWizard() + + if (!balance) return null + + return ( + <> + + {!!balance.amountUsd && ( + + {" "} + () + + )} + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/exit/steps/YieldxyzExitStepConfirm.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/steps/YieldxyzExitStepConfirm.tsx new file mode 100644 index 0000000000..6b8d02bd02 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/steps/YieldxyzExitStepConfirm.tsx @@ -0,0 +1,248 @@ +import { AlertCircleIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Tooltip, TooltipContent, TooltipTrigger, WizardModalDialog } from "talisman-ui" +import { TransactionRequest } from "viem" + +import { TokensAndFiat } from "@ui/domains/Asset/TokensAndFiat" +import { EthFeeSelect } from "@ui/domains/Ethereum/GasSettings/EthFeeSelect" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { RiskAnalysisProvider } from "@ui/domains/Sign/risk-analysis/context" +import { RiskAnalysisPillButton } from "@ui/domains/Sign/risk-analysis/RiskAnalysisPillButton" +import { TxSubmitButton } from "@ui/domains/Sign/TxSubmitButton/TxSignButton" +import { TxSubmitButtonTransaction } from "@ui/domains/Sign/TxSubmitButton/types" + +import { AccountDisplay } from "../../../shared/AccountDisplay" +import { FormFieldSet, FormFieldSetRow, FormFieldSetSeparator } from "../../../shared/FormFieldSet" +import { YieldxyzProductTitleDisplay } from "../../components/YieldxyzProductTitleDisplay" +import { YieldxyzProductYieldDisplay } from "../../components/YieldxyzProductYieldDisplay" +import { YieldxyzProviderDisplay } from "../../components/YieldxyzProviderLogo" +import { YieldxyzTokensAndFiat } from "../../components/YieldxyzTokensAndFiat" +import { YieldxyzTransactionsStepper } from "../../components/YieldxyzTransactionsStepper" +import { useYieldxyzExitModal } from "../useYieldxyzExitModal" +import { useYieldxyzExitWizard } from "../useYieldxyzExitWizard" + +export const YieldxyzExitStepConfirm = () => { + const { t } = useTranslation() + const { close } = useYieldxyzExitModal() + const { position, action, network, transaction, amountOut, goTo } = useYieldxyzExitWizard() + + if (!position || !action || !amountOut) return null + + return ( + + goTo("amount")} + onCloseClick={close} + > +
+
+ {action.transactions.length > 1 + ? t("Approve {{count}} transactions", { count: action.transactions.length }) + : t("Approve transaction")} +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} + +const RiskAnalysisButton = () => { + const { transaction } = useYieldxyzExitWizard() + + if (transaction?.platform !== "ethereum") return null + + return ( +
+ +
+ ) +} + +const TransactionError = () => { + const { transaction, isProcessing } = useYieldxyzExitWizard() + + return ( + + +
+ {transaction?.error} +
+
+ {!!transaction?.errorDetails && {transaction?.errorDetails}} +
+ ) +} + +const StepsProgressDisplay = () => { + const { action, stepIndex, isProcessing } = useYieldxyzExitWizard() + + if (!action || stepIndex === null) return null + + return ( + + ) +} + +const SubmitButton = () => { + const { t } = useTranslation() + const { + transaction, + isProcessing, + onSubmit, + stepIndex: txIndex, + action, + } = useYieldxyzExitWizard() + + const tx = useMemo(() => { + if (!transaction?.transaction) return null + switch (transaction.platform) { + case "ethereum": + return { + platform: "ethereum", + payload: transaction.transaction as TransactionRequest, + networkId: transaction.networkId, + } + default: + return null + } + }, [transaction]) + + return ( + + ) +} + +const NetworkDisplay = () => { + const { position } = useYieldxyzExitWizard() + + if (!position) return null + + return ( +
+ + +
+ ) +} + +const NetworkFeeRow = () => { + const { network } = useYieldxyzExitWizard() + + switch (network?.platform) { + case "ethereum": + return + default: + return null + } +} + +const NetworkFeeRowEth = () => { + const { t } = useTranslation() + const { transaction } = useYieldxyzExitWizard() + + // keep the latest valid tx in state so we still have content to display after tx is submitted. + // without this we'd be getting a lot of flickering and bad UX + const [tx, setTx] = useState(transaction) + useEffect(() => { + if (transaction?.platform === "ethereum" && transaction.transaction && transaction.txDetails) + setTx(transaction) + }, [transaction]) + + return ( + <> + + {!!tx?.transaction && !!tx.txDetails && ( + + )} + + + {!!tx?.txDetails && ( + + )} + + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/exit/useYieldxyzExitModal.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/useYieldxyzExitModal.ts new file mode 100644 index 0000000000..08066b9a6b --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/useYieldxyzExitModal.ts @@ -0,0 +1,5 @@ +import { createGlobalOpenClose } from "@talisman/hooks/createGlobalOpenClose" + +import { YieldxyzExitWizardInit } from "./useYieldxyzExitWizard" + +export const [useYieldxyzExitModal] = createGlobalOpenClose() diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/exit/useYieldxyzExitWizard.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/useYieldxyzExitWizard.ts new file mode 100644 index 0000000000..cca3995e62 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/exit/useYieldxyzExitWizard.ts @@ -0,0 +1,147 @@ +import { planckToTokens } from "@talismn/util" +import { isAccountOwned } from "extension-core" +import { log } from "extension-shared" +import { isEqual } from "lodash-es" +import { useCallback, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { provideContext } from "@talisman/util/provideContext" +import { api } from "@ui/api" +import { useAccountByAddress, useNetworkById, YieldxyzPositionEnhanced } from "@ui/state" + +import { useYieldxyzAction } from "../hooks/useYieldxyzAction" +import { useYieldxyzTransactionManager } from "../hooks/useYieldxyzActionManager" +import { useYieldxyzActionValidation } from "../hooks/useYieldxyzActionValidation" +import { useYieldxyzExitModal } from "./useYieldxyzExitModal" + +export type YieldxyzExitWizardInit = YieldxyzPositionEnhanced + +export type YieldxyzExitWizardState = { + step: "amount" | "confirm" + position: YieldxyzExitWizardInit | null + amountOut: bigint | null +} + +const useYieldxyzExitWizardProvider = ({ + position, +}: { + position: YieldxyzPositionEnhanced | null +}) => { + const { t } = useTranslation() + const { close, isOpen } = useYieldxyzExitModal() + const [state, setState] = useState(() => { + const balance = position ? getExitableBalance(position) : undefined + return { + step: "amount", + position, + amountOut: balance?.amountRaw ? BigInt(balance.amountRaw) : null, + } + }) + + const account = useAccountByAddress(position?.address) + const network = useNetworkById(state.position?.networkId) + + const balance = useMemo(() => getExitableBalance(state.position), [state.position]) + + const [inputs, talismanValidationError] = useMemo(() => { + if (!state.amountOut || !position?.product.token || !balance) return [null, null] + if (!isAccountOwned(account)) return [null, t("Unable to transact with external accounts")] + if (state.amountOut > BigInt(balance.amountRaw)) return [null, t("Insufficient balance")] + + const inputs = { + amount: planckToTokens(state.amountOut.toString(), balance.token.decimals), + // ⚠️ on products that do not support useMaxAmount, if rewards are per block, we will always leave some dust in the vault. + useMaxAmount: state.amountOut === BigInt(balance.amountRaw), + } + return [inputs, null] + }, [state.amountOut, position?.product.token, balance, account, t]) + + const { args, error: yieldxyzValidationError } = useYieldxyzActionValidation({ + schema: state.position?.product?.mechanics.arguments?.exit, + inputs, + }) + + const { + canCreateAction, + action, // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + isLoading: isLoadingAction, + error: errorAction, + createAction, + refreshAction, + submitActionTransaction, + } = useYieldxyzAction({ + type: "exit", + address: state.position?.address, + yieldId: state.position?.yieldId, + args, + }) + + const onAmountOutChanged = useCallback((amountOut: bigint | null) => { + setState((state) => ({ ...state, amountOut })) + }, []) + + const goTo = useCallback((step: YieldxyzExitWizardState["step"]) => { + setState((state) => ({ ...state, step })) + }, []) + + const onCompleted = useCallback(() => { + // do not await the refresh or UI will flicker + if (state.position) api.yieldxyzPositionRefresh(state.position) + if (isOpen) close() + }, [close, isOpen, state.position]) + + const setMaxAmountOut = useCallback(() => { + if (!balance) return + setState((state) => ({ ...state, amountOut: BigInt(balance.amountRaw) })) + }, [balance]) + + const { stepIndex, transaction, isProcessing, onSubmit } = useYieldxyzTransactionManager({ + action, + address: state.position?.address, + networkId: state.position?.networkId, + refreshAction, + submitActionTransaction, + onCompleted, + }) + + return { + ...state, + network, + balance, + validationError: talismanValidationError ?? yieldxyzValidationError, + goTo, + onAmountOutChanged, + setMaxAmountOut, + onSubmit, + isLoadingAction, + isProcessing, + action, + errorAction, + stepIndex, + transaction, + canCreateAction, + createAction, + } +} + +export const [YieldxyzExitWizardProvider, useYieldxyzExitWizard] = provideContext( + useYieldxyzExitWizardProvider, +) + +const getExitableBalance = (position: YieldxyzPositionEnhanced | null) => { + const activeBalances = position?.balances.filter((b) => b.type === "active") + if (!activeBalances?.length) return undefined + if (activeBalances.length > 1) { + // if one matches the main product token, prefer that one + const mainTokenBalance = activeBalances.find((b) => isEqual(b.token, position?.product.token)) + if (mainTokenBalance) return mainTokenBalance + + log.warn("Position has multiple active balances, which is not supported", { + position, + activeBalances, + }) + return undefined + } + + return activeBalances[0] +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/types.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/types.ts new file mode 100644 index 0000000000..5bcc2327d5 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/types.ts @@ -0,0 +1,8 @@ +import { TransactionDto } from "extension-core" + +export type UseYieldxyzTransactionProps = { + address: string + networkId: string + transaction: TransactionDto + lockTransaction?: boolean +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useGetYieldxyzToken.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useGetYieldxyzToken.ts new file mode 100644 index 0000000000..bfa0a51055 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useGetYieldxyzToken.ts @@ -0,0 +1,33 @@ +import { Token, TokenId } from "@talismn/chaindata-provider" +import { TokenDto } from "extension-core" +import { useCallback } from "react" + +import { + getYieldxyzTokenId as getYieldxyzTokenIdInner, + useNetworksMapById, + useTokensMap, + useYieldNetworkIdToTalismanNetworkIdMap, +} from "@ui/state" + +export const useGetYieldxyzToken = () => { + const networksMap = useNetworksMapById() + const tokensMap = useTokensMap() + const mapToTalismanNetworkId = useYieldNetworkIdToTalismanNetworkIdMap() + + const getYieldxyzTokenId = useCallback( + (token: TokenDto): TokenId | null => + getYieldxyzTokenIdInner(token, mapToTalismanNetworkId, networksMap), + [mapToTalismanNetworkId, networksMap], + ) + + const getYieldxyzToken = useCallback( + (token: TokenDto): Token | null => { + const tokenId = getYieldxyzTokenId(token) + if (!tokenId) return null + return tokensMap[tokenId] ?? null + }, + [getYieldxyzTokenId, tokensMap], + ) + + return { getYieldxyzTokenId, getYieldxyzToken } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzAction.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzAction.ts new file mode 100644 index 0000000000..eb46ab8c33 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzAction.ts @@ -0,0 +1,175 @@ +import { ActionArgumentsDto, ActionDto, TransactionDto } from "extension-core" +import { log, YIELD_API_BASE_URL } from "extension-shared" +import { useCallback, useMemo, useState } from "react" + +import { notify } from "@talisman/components/Notifications" + +type YieldxyzActionType = "enter" | "exit" + +type UseYieldxyzActionProps = { + type: YieldxyzActionType + address: string | null | undefined + yieldId: string | null | undefined + args: ActionArgumentsDto | null | undefined +} + +export const useYieldxyzAction = (props: UseYieldxyzActionProps) => { + const [state, setState] = useState<{ + isLoading: boolean + error: Error | null + action: ActionDto | null + }>({ + isLoading: false, + error: null, + action: null, + }) + + const canCreateAction = useMemo(() => { + return !!(props.address && props.yieldId && props.args) + }, [props.address, props.yieldId, props.args]) + + // fetching the action needs to be a manual operation, it must be done using useQuery. + // this is because every fetch creates a new action in yield.xyz backend. + // additionally once created, we need to be able to refresh it on demand (eg. after user executes a tx) + const createAction = useCallback(async () => { + if (!props.address || !props.yieldId || !props.args) return + try { + setState({ isLoading: true, error: null, action: null }) + const fetchedAction = await fetchYieldxyzCreateAction( + props.type, + props.yieldId, + props.address, + props.args, + ) + // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + fetchedAction.transactions.sort(sortTransactionsByStepIndex) + + setState({ isLoading: false, error: null, action: fetchedAction }) + } catch (err) { + log.error("Failed to fetch Yieldxyz enter action", err) + notify({ + type: "error", + title: "Error", + subtitle: (err as Error).message ?? err?.toString(), + }) + setState({ isLoading: false, error: err as Error, action: null }) + throw err + } + }, [props]) + + const refreshAction = useCallback(async () => { + try { + if (!state.action) return + setState((state) => ({ ...state, isLoading: true, error: null })) + const refreshedAction = await fetchYieldxyzAction(state.action.id) + // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + refreshedAction.transactions.sort(sortTransactionsByStepIndex) + + setState({ isLoading: false, error: null, action: refreshedAction }) + } catch (err) { + log.error("Failed to refresh Yieldxyz action", err) + setState((state) => ({ ...state, isLoading: false, error: err as Error })) + throw err + } + }, [state.action]) + + const submitActionTransaction = useCallback( + async (transactionId: string, hash: string) => { + try { + if (!state.action) return + setState((state) => ({ ...state, isLoading: true, error: null })) + const transaction = await submitYieldxyzTransactionHash(transactionId, hash) + // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + const updatedAction = { + ...state.action, + transactions: state.action.transactions + .map((tx) => (tx.id === transaction.id ? transaction : tx)) + .sort(sortTransactionsByStepIndex), + } + setState({ isLoading: false, error: null, action: updatedAction }) + } catch (err) { + log.error("Failed to submit Yieldxyz transaction", err) + notify({ + type: "error", + title: "Error", + subtitle: (err as Error).message ?? err?.toString(), + }) + setState((state) => ({ ...state, isLoading: false, error: err as Error })) + throw err + } + }, + [state.action], + ) + + return { ...state, canCreateAction, createAction, refreshAction, submitActionTransaction } +} + +const fetchYieldxyzCreateAction = async ( + type: YieldxyzActionType, + yieldId: string, + address: string, + args: ActionArgumentsDto, + signal?: AbortSignal, +): Promise => { + const req = await fetch(`${YIELD_API_BASE_URL}/v1/actions/${type}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + yieldId, + address, + arguments: args, + }), + signal, + }) + + if (!req.ok) throw new Error(await getErrorMessage(req)) + + return req.json() +} + +const fetchYieldxyzAction = async (actionId: string, signal?: AbortSignal): Promise => { + const req = await fetch(`${YIELD_API_BASE_URL}/v1/actions/${actionId}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + signal, + }) + + if (!req.ok) throw new Error(await getErrorMessage(req)) + + return req.json() +} + +const submitYieldxyzTransactionHash = async ( + transactionId: string, + hash: string, + signal?: AbortSignal, +): Promise => { + const req = await fetch(`${YIELD_API_BASE_URL}/v1/transactions/${transactionId}/submit-hash`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hash }), + signal, + }) + + if (!req.ok) throw new Error(await getErrorMessage(req)) + + return req.json() +} + +const getErrorMessage = async (response: Response): Promise => { + try { + const errorBody = await response.json() + return errorBody.message || `Yield.xyz API error: ${response.status} ${response.statusText}` + } catch (err) { + return `Yield.xyz API error: ${response.status} ${response.statusText}` + } +} + +const sortTransactionsByStepIndex = (a: TransactionDto, b: TransactionDto) => { + if (a.stepIndex === undefined || b.stepIndex === undefined) { + log.warn("sortTransactionsByStepIndex: transaction missing stepIndex", { a, b }) + return 0 + } + + return a.stepIndex - b.stepIndex +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzActionManager.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzActionManager.ts new file mode 100644 index 0000000000..77076d30f2 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzActionManager.ts @@ -0,0 +1,127 @@ +import { NetworkId } from "@talismn/chaindata-provider" +import { useQuery } from "@tanstack/react-query" +import { ActionDto } from "extension-core" +import { log } from "extension-shared" +import { useCallback, useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" + +import { notify } from "@talisman/components/Notifications" + +import { UseYieldxyzTransactionProps } from "./types" +import { useYieldxyzTransaction } from "./useYieldxyzTransaction" + +type UseYieldxyzTransactionManagerProps = { + action: ActionDto | null + address: string | null | undefined + networkId: NetworkId | null | undefined + refreshAction: () => Promise + submitActionTransaction: (transactionId: string, hash: string) => Promise + onCompleted: () => void +} + +/** + * This hook is designed to be called only by a wizard context such as useEarnDepositWizardProvider + * It manages the execution of transactions defined in the given action + * @param props + */ +export const useYieldxyzTransactionManager = ({ + action, + address, + networkId, + refreshAction, + submitActionTransaction, + onCompleted, +}: UseYieldxyzTransactionManagerProps) => { + const { t } = useTranslation() + const [isSubmitting, setIsSubmitting] = useState(false) + const [pendingTxId, setPendingTxId] = useState(null) + + const nextTransaction = useMemo(() => { + return action?.transactions.find((tx) => !["CONFIRMED", "SKIPPED"].includes(tx.status)) ?? null + }, [action]) + + const stepIndex = useMemo(() => nextTransaction?.stepIndex ?? null, [nextTransaction]) + + const txInputs = useMemo(() => { + if (!address || !networkId || !nextTransaction) return null + return { address, networkId, transaction: nextTransaction } + }, [address, networkId, nextTransaction]) + + const transaction = useYieldxyzTransaction(txInputs) + + const pendingTx = useMemo( + () => action?.transactions.find((tx) => tx.id === pendingTxId) ?? null, + [action, pendingTxId], + ) + + const onSubmit = useCallback( + async (txId: string) => { + setIsSubmitting(true) + try { + if (stepIndex === null) return + const transactionId = action?.transactions[stepIndex]?.id + if (!transactionId) return + await submitActionTransaction(transactionId, txId) + setPendingTxId(transactionId) + } finally { + setIsSubmitting(false) + } + }, + [action, stepIndex, submitActionTransaction], + ) + + // simple polling to refresh action while a tx is pending + useQuery({ + queryKey: ["yieldxyz", "follow-up", pendingTx], + enabled: ["BROADCASTED", "PENDING"].includes(pendingTx?.status ?? ""), + queryFn: async () => { + if (!pendingTx) return null + await refreshAction() + return null + }, + refetchInterval: 2000, + }) + + // maintain pendingTxId state + useEffect(() => { + if (!pendingTx?.status || ["BROADCASTED", "PENDING"].includes(pendingTx.status ?? "")) return + + switch (pendingTx.status) { + case "CONFIRMED": + notify({ + type: "success", + title: t("Success"), + subtitle: t("Transaction confirmed"), + }) + setPendingTxId(null) + // setStepIndex((index) => (index ?? 0) + 1) + break + case "BLOCKED": + case "NOT_FOUND": + case "FAILED": + notify({ + type: "error", + title: t("Error"), + subtitle: t("Transaction failed"), + }) + setPendingTxId(null) + break + + default: + log.warn("Unhandled pendingTx status in EarnDepositWizard", { status: pendingTx.status }) + break + } + }, [pendingTx?.status, refreshAction, t]) + + // signal parent wizard that all transactions are done + useEffect(() => { + if (action && !nextTransaction) onCompleted() + }, [action, nextTransaction, onCompleted]) + + return { + stepIndex, + transaction, + isProcessing: isSubmitting || !!pendingTx, + onSubmit, + } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzActionValidation.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzActionValidation.ts new file mode 100644 index 0000000000..686ea2dd3e --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzActionValidation.ts @@ -0,0 +1,56 @@ +import { ActionArgumentsDto, ArgumentSchemaDto } from "extension-core" +import { log } from "extension-shared" +import { useMemo } from "react" + +type UseYieldxyzEnterTransactionProps = { + schema: ArgumentSchemaDto | null | undefined + inputs: ActionArgumentsDto | null +} + +export const useYieldxyzActionValidation = ({ + schema, + inputs, +}: UseYieldxyzEnterTransactionProps) => { + return useMemo(() => { + if (!schema || !inputs) return { args: null, error: null } + + const result = { args: {} as ActionArgumentsDto | null, error: null as string | null } + + try { + for (const field of schema.fields) { + if (field.required && !inputs[field.name]) { + result.error = `${field.name} is required` + result.args = null + break + } + + if (inputs[field.name]) { + const value = inputs[field.name as keyof ActionArgumentsDto] + + if (field.minimum && Number(value) < Number(field.minimum)) { + result.error = `Minimum ${field.name} is ${field.minimum}` + result.args = null + break + } + + if (field.maximum && Number(value) > Number(field.maximum)) { + result.error = `Maximum ${field.name} is ${field.maximum}` + result.args = null + break + } + + const args = result.args as Record + args[field.name] = value + } + } + + return result + } catch (err) { + log.error("useYieldxyzActionValidation: error during validation", { err, schema, inputs }) + return { + args: null, + error: "Invalid arguments", + } + } + }, [inputs, schema]) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesByTokenId.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesByTokenId.ts new file mode 100644 index 0000000000..fc5f3c95e7 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesByTokenId.ts @@ -0,0 +1,113 @@ +import { Balances } from "@talismn/balances" +import { parseTokenId, TokenId } from "@talismn/chaindata-provider" +import { normalizeAddress } from "@talismn/crypto" +import { isNotNil, Loadable } from "@talismn/util" +import { YieldDto } from "extension-core" +import { uniq } from "lodash-es" +import { useMemo } from "react" + +import { usePortfolioNavigation } from "@ui/domains/Portfolio/usePortfolioNavigation" +import { useBalances, useSelectedCurrency, useYieldxyzProducts } from "@ui/state" + +import { useGetYieldxyzToken } from "./useGetYieldxyzToken" + +const MIN_REWARD_RATE = 0.01 +const ALLOW_NO_STATISTICS = true + +export const useYieldxyzOpportunitiesByTokenId = (): Loadable< + { + tokenId: string + products: YieldDto[] + bestApr: number + balances: Balances + }[] +> => { + const { selectedAccounts } = usePortfolioNavigation() + const balances = useBalances() + const products = useYieldxyzProducts() + + const accountBalances = useMemo(() => { + const accountIds = new Set(selectedAccounts.map((acc) => normalizeAddress(acc.address))) + return balances.find((b) => accountIds.has(normalizeAddress(b.address))) + }, [balances, selectedAccounts]) + + // all token ids where the selected accounts have any balance + const availableTokenIds = useMemo(() => { + return uniq(accountBalances.each.map((b) => b.tokenId)).sort() + }, [accountBalances]) + + const { getYieldxyzTokenId } = useGetYieldxyzToken() + + const productsByTokenId = useMemo((): Record => { + // keep only products for which we have one the input tokens (or the native token if multiple input tokens) + const oppsByTokenId = + products.data + ?.filter( + (p) => + // filter out products that cannot be entered + p.status.enter && + // filter out products with too low reward rate or unknown TVL + p.rewardRate.total >= MIN_REWARD_RATE && + (ALLOW_NO_STATISTICS || p.statistics?.tvl) && + // exclude products that require a field other than the amount + !p.mechanics.arguments?.enter?.fields?.some((f) => f.required && f.name !== "amount"), + ) + .reduce>((acc, product) => { + // here we consider a product can only have one input token id. + // if technically it has multiple, then pick the native token from the list (we ensure at the store level that if multiple input tokens, one is a native token) + const inputTokenIds = product.inputTokens + ?.map((inputToken) => getYieldxyzTokenId(inputToken)) + .filter(isNotNil) as TokenId[] + + const inputTokenId = + inputTokenIds.length === 1 + ? inputTokenIds[0] + : inputTokenIds.find((tokenId) => + ["evm-native", "substrate-native", "sol-native"].includes( + parseTokenId(tokenId).type, + ), + ) + + // ensure we have balance for it + if (inputTokenId && availableTokenIds.includes(inputTokenId)) { + if (!acc[inputTokenId]) acc[inputTokenId] = [] + acc[inputTokenId].push(product) + } + + return acc + }, {}) || {} + + // for each token, sort products by reward rate descending + return Object.entries(oppsByTokenId).reduce( + (acc, [tokenId, opps]) => { + acc[tokenId as TokenId] = opps.sort( + (a, b) => (b.rewardRate?.total || 0) - (a.rewardRate?.total || 0), + ) + return acc + }, + {} as Record, + ) + }, [products.data, getYieldxyzTokenId, availableTokenIds]) + + const currency = useSelectedCurrency() + + const data = useMemo(() => { + return Object.entries(productsByTokenId) + .map(([tokenId, products]) => ({ + tokenId, + products, + bestApr: Math.max(...products.map((opp) => opp.rewardRate.total * 100)), + balances: accountBalances.find({ tokenId }), + })) + .sort((a, b) => { + const balance1 = a.balances.sum.fiat(currency).transferable + const balance2 = b.balances.sum.fiat(currency).transferable + return (balance2 || 0) - (balance1 || 0) + }) + }, [productsByTokenId, accountBalances, currency]) + + return { + ...products, + data, + } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesForTokenId.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesForTokenId.ts new file mode 100644 index 0000000000..a8f85fe5de --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzOpportunitiesForTokenId.ts @@ -0,0 +1,38 @@ +import { parseTokenId, TokenId } from "@talismn/chaindata-provider" +import { isNotNil } from "@talismn/util" +import { useMemo } from "react" + +import { useYieldxyzProducts } from "@ui/state" + +import { useGetYieldxyzToken } from "./useGetYieldxyzToken" + +export const useYieldxyzOpportunitiesForTokenId = (tokenId: TokenId) => { + const { data: allProducts } = useYieldxyzProducts() + + const { getYieldxyzTokenId } = useGetYieldxyzToken() + + return useMemo(() => { + return ( + allProducts + ?.filter((product) => { + // here we consider a product can only have one input token id. + // if technically it has multiple, then pick the native token from the list (we ensure at the store level that if multiple input tokens, one is a native token) + const inputTokenIds = product.inputTokens + ?.map((inputToken) => getYieldxyzTokenId(inputToken)) + .filter(isNotNil) as TokenId[] + + const inputTokenId = + inputTokenIds.length === 1 + ? inputTokenIds[0] + : inputTokenIds.find((tokenId) => + ["evm-native", "substrate-native", "sol-native"].includes( + parseTokenId(tokenId).type, + ), + ) + + return inputTokenId === tokenId + }) + .sort((a, b) => (a.rewardRate.total < b.rewardRate.total ? 1 : -1)) ?? [] + ) + }, [allProducts, getYieldxyzTokenId, tokenId]) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzPendingAction.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzPendingAction.ts new file mode 100644 index 0000000000..6c5afa2b88 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzPendingAction.ts @@ -0,0 +1,172 @@ +import { ActionDto, PendingActionDto, TransactionDto } from "extension-core" +import { log, YIELD_API_BASE_URL } from "extension-shared" +import { useCallback, useMemo, useState } from "react" + +import { notify } from "@talisman/components/Notifications" + +type UseYieldxyzPendingActionProps = { + address: string | null | undefined + yieldId: string | null | undefined + pendingAction: PendingActionDto | null | undefined +} + +export const useYieldxyzPendingAction = (props: UseYieldxyzPendingActionProps) => { + const [state, setState] = useState<{ + isLoading: boolean + error: Error | null + action: ActionDto | null + }>({ + isLoading: false, + error: null, + action: null, + }) + + const canCreateAction = useMemo(() => { + return !!(props.address && props.yieldId && props.pendingAction) + }, [props.address, props.yieldId, props.pendingAction]) + + // fetching the action needs to be a manual operation, it must be done using useQuery. + // this is because every fetch creates a new action in yield.xyz backend. + // additionally once created, we need to be able to refresh it on demand (eg. after user executes a tx) + const createAction = useCallback(async () => { + if (!props.address || !props.yieldId || !props.pendingAction) return + try { + setState({ isLoading: true, error: null, action: null }) + const fetchedAction = await fetchYieldxyzCreatePendingAction( + props.yieldId, + props.address, + props.pendingAction, + ) + // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + fetchedAction.transactions.sort(sortTransactionsByStepIndex) + + setState({ isLoading: false, error: null, action: fetchedAction }) + } catch (err) { + log.error("Failed to fetch Yieldxyz enter action", err) + notify({ + type: "error", + title: "Error", + subtitle: (err as Error).message ?? err?.toString(), + }) + setState({ isLoading: false, error: err as Error, action: null }) + throw err + } + }, [props]) + + const refreshAction = useCallback(async () => { + try { + if (!state.action) return + setState((state) => ({ ...state, isLoading: true, error: null })) + const refreshedAction = await fetchYieldxyzAction(state.action.id) + // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + refreshedAction.transactions.sort(sortTransactionsByStepIndex) + + setState({ isLoading: false, error: null, action: refreshedAction }) + } catch (err) { + log.error("Failed to refresh Yieldxyz action", err) + setState((state) => ({ ...state, isLoading: false, error: err as Error })) + throw err + } + }, [state.action]) + + const submitActionTransaction = useCallback( + async (transactionId: string, hash: string) => { + try { + if (!state.action) return + setState((state) => ({ ...state, isLoading: true, error: null })) + const transaction = await submitYieldxyzTransactionHash(transactionId, hash) + // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + const updatedAction = { + ...state.action, + transactions: state.action.transactions + .map((tx) => (tx.id === transaction.id ? transaction : tx)) + .sort(sortTransactionsByStepIndex), + } + setState({ isLoading: false, error: null, action: updatedAction }) + } catch (err) { + log.error("Failed to submit Yieldxyz transaction", err) + notify({ + type: "error", + title: "Error", + subtitle: (err as Error).message ?? err?.toString(), + }) + setState((state) => ({ ...state, isLoading: false, error: err as Error })) + throw err + } + }, + [state.action], + ) + + return { ...state, canCreateAction, createAction, refreshAction, submitActionTransaction } +} + +const fetchYieldxyzCreatePendingAction = async ( + yieldId: string, + address: string, + pendingAction: PendingActionDto, + signal?: AbortSignal, +): Promise => { + const req = await fetch(`${YIELD_API_BASE_URL}/v1/actions/manage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + yieldId, + address, + arguments: pendingAction.arguments, + action: pendingAction.type, + passthrough: pendingAction.passthrough, + }), + signal, + }) + + if (!req.ok) throw new Error(await getErrorMessage(req)) + + return req.json() +} + +const fetchYieldxyzAction = async (actionId: string, signal?: AbortSignal): Promise => { + const req = await fetch(`${YIELD_API_BASE_URL}/v1/actions/${actionId}`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + signal, + }) + + if (!req.ok) throw new Error(await getErrorMessage(req)) + + return req.json() +} + +const submitYieldxyzTransactionHash = async ( + transactionId: string, + hash: string, + signal?: AbortSignal, +): Promise => { + const req = await fetch(`${YIELD_API_BASE_URL}/v1/transactions/${transactionId}/submit-hash`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hash }), + signal, + }) + + if (!req.ok) throw new Error(await getErrorMessage(req)) + + return req.json() +} + +const getErrorMessage = async (response: Response): Promise => { + try { + const errorBody = await response.json() + return errorBody.message || `Yield.xyz API error: ${response.status} ${response.statusText}` + } catch (err) { + return `Yield.xyz API error: ${response.status} ${response.statusText}` + } +} + +const sortTransactionsByStepIndex = (a: TransactionDto, b: TransactionDto) => { + if (a.stepIndex === undefined || b.stepIndex === undefined) { + log.warn("sortTransactionsByStepIndex: transaction missing stepIndex", { a, b }) + return 0 + } + + return a.stepIndex - b.stepIndex +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransaction.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransaction.ts new file mode 100644 index 0000000000..0713cd70a3 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransaction.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react" + +import { useNetworkById } from "@ui/state" + +import { UseYieldxyzTransactionProps } from "./types" +import { useYieldxyzTransactionDot } from "./useYieldxyzTransactionDot" +import { useYieldxyzTransactionEth } from "./useYieldxyzTransactionEth" +import { useYieldxyzTransactionSol } from "./useYieldxyzTransactionSol" + +export const useYieldxyzTransaction = (props: UseYieldxyzTransactionProps | null) => { + const network = useNetworkById(props?.networkId) + + const txEth = useYieldxyzTransactionEth(props) + const txDot = useYieldxyzTransactionDot(props) + const txSol = useYieldxyzTransactionSol(props) + + return useMemo(() => { + switch (network?.platform) { + case "polkadot": + return txDot + case "ethereum": + return txEth + case "solana": + return txSol + default: + return null + } + }, [network?.platform, txDot, txEth, txSol]) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionDot.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionDot.ts new file mode 100644 index 0000000000..51d6aa6a18 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionDot.ts @@ -0,0 +1,6 @@ +import { UseYieldxyzTransactionProps } from "./types" + +export const useYieldxyzTransactionDot = (_props: UseYieldxyzTransactionProps | null) => { + // atm none of the substrate based yields are interesting for us, they are already implemented in portfolio + return null +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionEth.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionEth.ts new file mode 100644 index 0000000000..47f49b5b1f --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionEth.ts @@ -0,0 +1,86 @@ +import { isEthereumAddress } from "@talismn/crypto" +import { useQuery } from "@tanstack/react-query" +import { TransactionDto } from "extension-core" +import { log } from "extension-shared" +import { useMemo } from "react" +import { TransactionRequest } from "viem" + +import { useEthTransaction } from "@ui/domains/Ethereum/useEthTransaction" +import { usePublicClient } from "@ui/domains/Ethereum/usePublicClient" +import { useEvmTransactionRiskAnalysis } from "@ui/domains/Sign/risk-analysis/ethereum/useEvmTransactionRiskAnalysis" +import { useNetworkById } from "@ui/state" + +import { UseYieldxyzTransactionProps } from "./types" + +type YieldxyzEthTransaction = { + type: number + chainId: number + from: `0x${string}` + to: `0x${string}` + nonce: number + value?: `0x${string}` + data?: `0x${string}` + gasLimit?: `0x${string}` + maxFeePerGas?: `0x${string}` + maxPriorityFeePerGas?: `0x${string}` +} + +const deserializeYieldxyzEthTransaction = ( + tx: TransactionDto, + nonce: number | undefined, +): TransactionRequest | null => { + try { + const parsedTx = JSON.parse(tx.unsignedTransaction as string) as YieldxyzEthTransaction + return { + from: parsedTx.from, + to: parsedTx.to, + value: parsedTx.value ? BigInt(parsedTx.value) : undefined, + data: parsedTx.data, + nonce, + } + } catch (error) { + log.error("Failed to deserialize Yieldxyz ETH transaction", error) + return null + } +} + +export const useYieldxyzTransactionEth = (props: UseYieldxyzTransactionProps | null) => { + const publicClient = usePublicClient(props?.networkId) + const network = useNetworkById(props?.networkId, "ethereum") + + // we need to refresh nonce every time the transaction changes, because useEthTransaction wont do it + const { data: nonce } = useQuery({ + queryKey: ["nonce", props, publicClient?.uid], + queryFn: () => { + if (!publicClient || !props?.address || !isEthereumAddress(props.address)) return null + + return publicClient.getTransactionCount({ + address: props.address, + blockTag: "pending", + }) + }, + }) + + const tx = useMemo(() => { + if (!props?.transaction) return null + return deserializeYieldxyzEthTransaction(props.transaction, nonce ?? undefined) + }, [props?.transaction, nonce]) + + const result = useEthTransaction(tx ?? undefined, props?.networkId, props?.lockTransaction, true) // mark as replacement so we can force the nonce + + const riskAnalysis = useEvmTransactionRiskAnalysis({ + networkId: props?.networkId, + tx: tx ?? undefined, + disableCriticalPane: true, + }) + + if (!network) return null + + return { + platform: "ethereum" as const, + networkId: network.id, + feeTokenId: network.nativeTokenId, + riskAnalysis, + ...result, + } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionSol.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionSol.ts new file mode 100644 index 0000000000..b299021f21 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzTransactionSol.ts @@ -0,0 +1,6 @@ +import { UseYieldxyzTransactionProps } from "./types" + +export const useYieldxyzTransactionSol = (_props: UseYieldxyzTransactionProps | null) => { + // atm none of the solana based yields are working correctly (all throw "Drift Lending User Not Found" errors), so we don't have any implementation here yet + return null +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzYieldPositions.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzYieldPositions.ts new file mode 100644 index 0000000000..ed4a36e853 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/hooks/useYieldxyzYieldPositions.ts @@ -0,0 +1,20 @@ +import { useMemo } from "react" + +import { useYieldxyzPositionsEnhanced } from "@ui/state" + +export const useYieldxyzYieldPositions = ( + yieldId: string | null | undefined, + address: string | null | undefined, +) => { + const positionsEnhanced = useYieldxyzPositionsEnhanced() + + const data = useMemo(() => { + if (!yieldId || !address || !positionsEnhanced.data) return undefined + + return positionsEnhanced.data.filter( + (pos) => pos.yieldId === yieldId && pos.address === address, + ) + }, [yieldId, address, positionsEnhanced.data]) + + return { ...positionsEnhanced, data } +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/manage/YieldxyzManagePositionModal.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/YieldxyzManagePositionModal.tsx new file mode 100644 index 0000000000..79d411abff --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/YieldxyzManagePositionModal.tsx @@ -0,0 +1,29 @@ +import { FC, Suspense } from "react" +import { Modal } from "talisman-ui" + +import { PopupSizeModalContainer } from "@talisman/components/PopupSizeModalContainer" +import { SuspenseTracker } from "@talisman/components/SuspenseTracker" + +import { useYieldxyzManageModal } from "./useYieldxyzManageModal" +import { YieldxyzManageWizardProvider } from "./useYieldxyzManageWizard" +import { YieldxyzManagePositionWizard } from "./YieldxyzManagePositionWizard" + +export const YieldxyzManagePositionModal: FC = () => { + const { isOpen, close, args } = useYieldxyzManageModal() + + return ( + + + }> + + + + + + + ) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/manage/YieldxyzManagePositionWizard.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/YieldxyzManagePositionWizard.tsx new file mode 100644 index 0000000000..944a2f7ca0 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/YieldxyzManagePositionWizard.tsx @@ -0,0 +1,9 @@ +import { FC } from "react" + +import { YieldxyzManageStepConfirm } from "./steps/YieldxyzManageStepConfirm" + +export const YieldxyzManagePositionWizard: FC = () => { + // for now the wizard only has one step: confirm + + return +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/manage/steps/YieldxyzManageStepConfirm.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/steps/YieldxyzManageStepConfirm.tsx new file mode 100644 index 0000000000..d92172223c --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/steps/YieldxyzManageStepConfirm.tsx @@ -0,0 +1,311 @@ +import { AlertCircleIcon, LoaderIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { ActionDto } from "extension-core" +import { useEffect, useMemo, useState } from "react" +import { useTranslation } from "react-i18next" +import { Tooltip, TooltipContent, TooltipTrigger, WizardModalDialog } from "talisman-ui" +import { TransactionRequest } from "viem" + +import { TokensAndFiat } from "@ui/domains/Asset/TokensAndFiat" +import { EthFeeSelect } from "@ui/domains/Ethereum/GasSettings/EthFeeSelect" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { RiskAnalysisProvider } from "@ui/domains/Sign/risk-analysis/context" +import { RiskAnalysisPillButton } from "@ui/domains/Sign/risk-analysis/RiskAnalysisPillButton" +import { TxSubmitButton } from "@ui/domains/Sign/TxSubmitButton/TxSignButton" +import { TxSubmitButtonTransaction } from "@ui/domains/Sign/TxSubmitButton/types" + +import { AccountDisplay } from "../../../shared/AccountDisplay" +import { FormFieldSet, FormFieldSetRow, FormFieldSetSeparator } from "../../../shared/FormFieldSet" +import { YieldxyzProductTitleDisplay } from "../../components/YieldxyzProductTitleDisplay" +import { YieldxyzProviderDisplay } from "../../components/YieldxyzProviderLogo" +import { YieldxyzTokensAndFiat } from "../../components/YieldxyzTokensAndFiat" +import { YieldxyzTransactionsStepper } from "../../components/YieldxyzTransactionsStepper" +import { useYieldxyzManageModal } from "../useYieldxyzManageModal" +import { useYieldxyzManageWizard } from "../useYieldxyzManageWizard" + +export const YieldxyzManageStepConfirm = () => { + const { t } = useTranslation() + const { close } = useYieldxyzManageModal() + const { position, action, network, transaction, balance, isLoadingAction } = + useYieldxyzManageWizard() + const actionTitle = useActionTitle(action) + + if (!action && isLoadingAction) return + + if (!position || !action) return null + + return ( + + +
+
+ {action.transactions.length > 1 + ? t("Approve {{count}} transactions", { count: action.transactions.length }) + : t("Approve transaction")} +
+
+ + + +
+ + {!!balance && ( + + + + )} + + + + + + + + + + + + + + + + + +
+
+
+ ) +} + +const RiskAnalysisButton = () => { + const { transaction } = useYieldxyzManageWizard() + + if (transaction?.platform !== "ethereum") return null + + return ( +
+ +
+ ) +} + +const ActionCreatingShimmer = () => { + { + const { t } = useTranslation() + + return ( +
+ +
+ {t("Preparing operation")} +
+
{t("This shouldn't take long...")}
+
+ ) + } +} + +const TransactionError = () => { + const { transaction, isProcessing } = useYieldxyzManageWizard() + + return ( + + +
+ {transaction?.error} +
+
+ {!!transaction?.errorDetails && {transaction?.errorDetails}} +
+ ) +} + +const StepsProgressDisplay = () => { + const { action, stepIndex, isProcessing } = useYieldxyzManageWizard() + + if (!action || stepIndex === null) return null + + return ( + + ) +} + +const SubmitButton = () => { + const { t } = useTranslation() + const { + transaction, + isProcessing, + onSubmit, + stepIndex: txIndex, + action, + } = useYieldxyzManageWizard() + + const tx = useMemo(() => { + if (!transaction?.transaction) return null + switch (transaction.platform) { + case "ethereum": + return { + platform: "ethereum", + payload: transaction.transaction as TransactionRequest, + networkId: transaction.networkId, + } + default: + return null + } + }, [transaction]) + + return ( + + ) +} + +const NetworkDisplay = () => { + const { position } = useYieldxyzManageWizard() + + if (!position) return null + + return ( +
+ + +
+ ) +} + +const NetworkFeeRow = () => { + const { network } = useYieldxyzManageWizard() + + switch (network?.platform) { + case "ethereum": + return + default: + return null + } +} + +const NetworkFeeRowEth = () => { + const { t } = useTranslation() + const { transaction } = useYieldxyzManageWizard() + + // keep the latest valid tx in state so we still have content to display after tx is submitted. + // without this we'd be getting a lot of flickering and bad UX + const [tx, setTx] = useState(transaction) + useEffect(() => { + if (transaction?.platform === "ethereum" && transaction.transaction && transaction.txDetails) + setTx(transaction) + }, [transaction]) + + return ( + <> + + {!!tx?.transaction && !!tx.txDetails && ( + + )} + + + {!!tx?.txDetails && ( + + )} + + + ) +} + +const useActionTitle = (action: ActionDto | null) => { + const { t } = useTranslation() + + return useMemo(() => { + if (!action) return t("Manage Position") + + switch (action.type) { + case "CLAIM_REWARDS": + return t("Claim Rewards") + case "CLAIM_UNSTAKED": + return t("Finalize Withdraw") + case "RESTAKE_REWARDS": + return t("Restake Rewards") + case "DELEGATE": + return t("Delegate Stake") + case "MIGRATE": + return t("Migrate") + case "REBOND": + return t("Rebond") + case "RESTAKE": + return t("Restake") + case "REVOKE": + return t("Revoke") + case "REVOTE": + return t("Revote") + case "STAKE": + return t("Stake") + case "STAKE_LOCKED": + return t("Stake Locked") + case "UNLOCK_LOCKED": + return t("Unlock") + case "UNSTAKE": + return t("Unstake") + case "VERIFY_WITHDRAW_CREDENTIALS": + return t("Verify Withdraw Credentials") + case "VOTE": + return t("Vote") + case "VOTE_LOCKED": + return t("Vote Locked") + case "WITHDRAW": + return t("Withdraw") + case "WITHDRAW_ALL": + return t("Withdraw All") + default: + return t("Manage Position") + } + }, [action, t]) +} diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/manage/useYieldxyzManageModal.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/useYieldxyzManageModal.ts new file mode 100644 index 0000000000..6187f4d107 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/useYieldxyzManageModal.ts @@ -0,0 +1,5 @@ +import { createGlobalOpenClose } from "@talisman/hooks/createGlobalOpenClose" + +import { YieldxyzManageWizardInputs } from "./useYieldxyzManageWizard" + +export const [useYieldxyzManageModal] = createGlobalOpenClose() diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/manage/useYieldxyzManageWizard.ts b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/useYieldxyzManageWizard.ts new file mode 100644 index 0000000000..a8b868f40c --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/manage/useYieldxyzManageWizard.ts @@ -0,0 +1,93 @@ +import { BalanceDto, isAccountOwned, PendingActionDto } from "extension-core" +import { useCallback, useEffect, useMemo, useRef } from "react" + +import { provideContext } from "@talisman/util/provideContext" +import { api } from "@ui/api" +import { useAccountByAddress, useNetworkById, YieldxyzPositionEnhanced } from "@ui/state" + +import { useYieldxyzTransactionManager } from "../hooks/useYieldxyzActionManager" +import { useYieldxyzPendingAction } from "../hooks/useYieldxyzPendingAction" +import { useYieldxyzManageModal } from "./useYieldxyzManageModal" + +export type YieldxyzManageWizardInputs = { + position: YieldxyzPositionEnhanced + pendingAction: PendingActionDto + balance?: BalanceDto | null +} + +const useYieldxyzManageWizardProvider = ({ + position, + pendingAction, + balance, +}: { + position: YieldxyzPositionEnhanced | null | undefined + pendingAction: PendingActionDto | null | undefined + balance: BalanceDto | null | undefined +}) => { + const { close, isOpen } = useYieldxyzManageModal() + + const account = useAccountByAddress(position?.address) + const network = useNetworkById(position?.networkId) + + const isOwned = useMemo(() => isAccountOwned(account), [account]) + + const { + canCreateAction, + action, // ⚠️ action.transactions order changes over time, make sure to sort it based on stepIndex + isLoading: isLoadingAction, + error: errorAction, + createAction, + refreshAction, + submitActionTransaction, + } = useYieldxyzPendingAction({ + address: position?.address, + yieldId: position?.yieldId, + pendingAction: isOwned ? pendingAction : undefined, + }) + + const refInitialized = useRef(false) + useEffect(() => { + // create the action on load, only once + if (canCreateAction && !refInitialized.current) { + refInitialized.current = true + createAction().catch(() => { + refInitialized.current = false // Allow retry + }) + } + }, [canCreateAction, createAction]) + + const onCompleted = useCallback(() => { + // do not await the refresh or UI will flicker + if (position) api.yieldxyzPositionRefresh(position) + if (isOpen) close() + }, [close, isOpen, position]) + + const { stepIndex, transaction, isProcessing, onSubmit } = useYieldxyzTransactionManager({ + action, + address: position?.address, + networkId: position?.networkId, + refreshAction, + submitActionTransaction, + onCompleted, + }) + + return { + position, + balance, + pendingAction, + network, + onSubmit, + isLoadingAction, + isProcessing, + action, + errorAction, + stepIndex, + transaction, + canCreateAction, + createAction, + } +} + +export const [YieldxyzManageWizardProvider, useYieldxyzManageWizard] = provideContext( + useYieldxyzManageWizardProvider, +) diff --git a/apps/extension/src/ui/domains/Earn/yieldxyz/positions/YieldxyzYieldPositions.tsx b/apps/extension/src/ui/domains/Earn/yieldxyz/positions/YieldxyzYieldPositions.tsx new file mode 100644 index 0000000000..291c2cb615 --- /dev/null +++ b/apps/extension/src/ui/domains/Earn/yieldxyz/positions/YieldxyzYieldPositions.tsx @@ -0,0 +1,408 @@ +import { ChevronLeftIcon, MoreHorizontalIcon } from "@talismn/icons" +import { cn } from "@talismn/util" +import { BalanceDto, isAccountOwned, YieldDto } from "extension-core" +import { log } from "extension-shared" +import { FC, useCallback, useEffect, useMemo } from "react" +import { useTranslation } from "react-i18next" +import { + Button, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + IconButton, +} from "talisman-ui" + +import { AssetLogo } from "@ui/domains/Asset/AssetLogo" +import { FiatFromUsd } from "@ui/domains/Asset/Fiat" +import { TokenDisplaySymbol } from "@ui/domains/Asset/TokenDisplaySymbol" +import { TokenLogo } from "@ui/domains/Asset/TokenLogo" +import { Tokens } from "@ui/domains/Asset/Tokens" +import { NetworkLogo } from "@ui/domains/Networks/NetworkLogo" +import { NetworkName } from "@ui/domains/Networks/NetworkName" +import { PortfolioAccount } from "@ui/domains/Portfolio/AssetDetails/PortfolioAccount" +import { useNavigateWithQuery } from "@ui/hooks/useNavigateWithQuery" +import { + useAccountByAddress, + useYieldNetworkIdToTalismanNetworkIdMap, + useYieldxyzProduct, + YieldxyzPositionEnhanced, +} from "@ui/state" +import { IS_POPUP } from "@ui/util/constants" + +import { EarnTypeBadge } from "../../components/EarnTypeBadge" +import { YieldxyzBalanceTypeDisplay } from "../components/YieldxyzBalanceTypeDisplay" +import { YieldxyzProviderLogo } from "../components/YieldxyzProviderLogo" +import { useYieldxyzEnterModal } from "../enter/useYieldxyzEnterModal" +import { useYieldxyzExitModal } from "../exit/useYieldxyzExitModal" +import { useGetYieldxyzToken } from "../hooks/useGetYieldxyzToken" +import { useYieldxyzYieldPositions } from "../hooks/useYieldxyzYieldPositions" +import { useYieldxyzManageModal } from "../manage/useYieldxyzManageModal" + +/** + * ⚠️ yield.xyz api returns 1/n positions for a given yield and an address. Also, returned positions dont have an id. + * => we need to display n positions on this page. + */ +export const YieldxyzYieldPositions: FC<{ yieldId: string; address: string }> = ({ + yieldId, + address, +}) => { + const { data: product } = useYieldxyzProduct(yieldId) + const { status, data: positions } = useYieldxyzYieldPositions(yieldId, address) + + useEffect(() => { + log.debug("[earn] YieldxyzYieldPositions", { positions }) + }, [positions]) + + if (!product) return null + + return ( +
+ + {positions?.map((position, index) => ( + + ))} +
+ ) +} + +const NavHeader: FC<{ + address: string + product: YieldDto + positions: YieldxyzPositionEnhanced[] | undefined + isLoading: boolean +}> = ({ address, product, positions }) => { + const { t } = useTranslation() + const navigate = useNavigateWithQuery() + const totalUsd = useMemo( + () => positions?.reduce((acc, position) => acc + position.totalAmountUsd, 0), + [positions], + ) + + return ( +
+
+ navigate("/earn", true)}> + + + + +
+
+
+
{product.metadata.name}
+ {product.mechanics?.type} +
+
{t("Total")}
+
+
+
+ +
+
+ +
+
+
+
+
+ ) +} + +const Position: FC<{ position: YieldxyzPositionEnhanced; isLoading: boolean }> = ({ + position, + isLoading, +}) => { + const { t } = useTranslation() + + const { supplied, rewards } = useMemo(() => { + return position.balances.reduce<{ + supplied: BalanceDto[] + rewards: BalanceDto[] + }>( + (acc, balance) => { + if (balance.type === "claimable") { + acc.rewards.push(balance) + } else { + acc.supplied.push(balance) + } + return acc + }, + { supplied: [], rewards: [] }, + ) + }, [position.balances]) + + return ( +
+ + + + +
+ ) +} + +const PositionBalancesGroup: FC<{ label: string; balances: BalanceDto[]; isLoading: boolean }> = ({ + label, + balances, + isLoading, +}) => { + if (!balances.length) return null + + return ( +
+
{label}
+
+ {balances.map((balance, index) => ( + + ))} +
+
+ ) +} + +const PositionBalancesGroupRow: FC<{ balance: BalanceDto; isLoading: boolean }> = ({ + balance, + isLoading, +}) => { + const { getYieldxyzToken } = useGetYieldxyzToken() + const token = useMemo(() => getYieldxyzToken(balance.token), [balance.token, getYieldxyzToken]) + + return ( +
+ {token ? ( + + ) : ( + + )} +
+
+
{token ? : balance.token.symbol}
+
+ {" "} + {balance.token.symbol} +
+
+
+
+ +
+
+ {balance.amountUsd ? : "-"} +
+
+
+
+ ) +} + +const PositionHeader: FC<{ position: YieldxyzPositionEnhanced }> = ({ position }) => { + const toTalismanNetworkId = useYieldNetworkIdToTalismanNetworkIdMap() + + const networkId = useMemo( + () => toTalismanNetworkId[position.product.network], + [position.product.network, toTalismanNetworkId], + ) + + const productTokens = useMemo(() => { + return position.product.token.name ?? position.product.token.symbol + }, [position.product.token.name, position.product.token.symbol]) + + return ( +
+
+
{productTokens}
+
+ + +
+
+ +
+ ) +} + +const PositionContextMenuButton: FC<{ position: YieldxyzPositionEnhanced }> = ({ position }) => { + const { t } = useTranslation() + const { + claimableBalances, + withdrawableBalances, + canEnter, + canExit, + canManage, + onAddToPositionClick, + onExitClick, + onClaimClick, + onWithdrawClick, + } = usePositionActions(position) + + return ( + + + + + + + + + {t("Add to position")} + + + {t("Exit position")} + + {claimableBalances.map((balance, index) => ( + +
+
{t("Claim")}
+ +
+
+ ))} + {withdrawableBalances.map((balance, index) => ( + +
+
{t("Withdraw")}
+ +
+
+ ))} +
+
+ ) +} + +const PositionActions: FC<{ position: YieldxyzPositionEnhanced }> = ({ position }) => { + const { t } = useTranslation() + const { + canEnter, + canManage, + onAddToPositionClick, + claimableBalances, + onClaimClick, + withdrawableBalances, + onWithdrawClick, + } = usePositionActions(position) + + const isGridLayout = useMemo( + () => IS_POPUP && (withdrawableBalances.length || claimableBalances.length), + [claimableBalances.length, withdrawableBalances.length], + ) + + return ( +
+ + {withdrawableBalances.length > 0 && ( + + )} + {claimableBalances.length > 0 && ( + + )} +
+ ) +} + +const usePositionActions = (position: YieldxyzPositionEnhanced) => { + const account = useAccountByAddress(position.address) + const { open: openEnter } = useYieldxyzEnterModal() + const { open: openExit } = useYieldxyzExitModal() + const { open: openManage } = useYieldxyzManageModal() + + const [claimableBalances, withdrawableBalances, canEnter, canExit, canManage] = useMemo(() => { + const isOwned = isAccountOwned(account) + return [ + position.balances.filter((b) => b.type === "claimable" && b.pendingActions.length), + position.balances.filter((b) => b.type === "withdrawable" && b.pendingActions.length), + isOwned && position.product.status.enter, + isOwned && position.product.status.exit && position.balances.some((b) => b.type === "active"), + isOwned, + ] + }, [account, position]) + + const onAddToPositionClick = useCallback(() => { + openEnter({ + address: position.address, + productId: position.product.id, + }) + }, [openEnter, position]) + + const onExitClick = useCallback(() => { + openExit(position) + }, [openExit, position]) + + const onClaimClick = useCallback( + (balance: BalanceDto) => () => { + const pendingAction = balance?.pendingActions[0] + if (!balance || !pendingAction) return + openManage({ + position, + pendingAction, + balance, + }) + }, + [openManage, position], + ) + + const onWithdrawClick = useCallback( + (balance: BalanceDto) => () => { + const pendingAction = balance?.pendingActions[0] + if (!balance || !pendingAction) return + openManage({ + position, + pendingAction, + balance, + }) + }, + [openManage, position], + ) + + return { + claimableBalances, + withdrawableBalances, + canEnter, + canExit, + canManage, + onAddToPositionClick, + onExitClick, + onClaimClick, + onWithdrawClick, + } +} diff --git a/apps/extension/src/ui/domains/Networks/NetworkLogo.tsx b/apps/extension/src/ui/domains/Networks/NetworkLogo.tsx index 25f8b7a965..8b107fdf45 100644 --- a/apps/extension/src/ui/domains/Networks/NetworkLogo.tsx +++ b/apps/extension/src/ui/domains/Networks/NetworkLogo.tsx @@ -32,7 +32,7 @@ const NetworkLogoBase: FC = ({ network, className }) => { type NetworkLogoProps = { className?: string - networkId?: NetworkId + networkId?: NetworkId | null } const NetworkLogoInner: FC = ({ networkId: id, className }) => { diff --git a/apps/extension/src/ui/domains/Portfolio/AssetsTable/DashboardAssetRow.tsx b/apps/extension/src/ui/domains/Portfolio/AssetsTable/DashboardAssetRow.tsx index 17be56ee9d..bc66491a86 100644 --- a/apps/extension/src/ui/domains/Portfolio/AssetsTable/DashboardAssetRow.tsx +++ b/apps/extension/src/ui/domains/Portfolio/AssetsTable/DashboardAssetRow.tsx @@ -1,8 +1,9 @@ import { Balances } from "@talismn/balances" -import { ZapFastIcon } from "@talismn/icons" +import { TrendingUpIcon, ZapFastIcon } from "@talismn/icons" import { classNames } from "@talismn/util" import { FC, useCallback } from "react" import { useTranslation } from "react-i18next" +import { PillButton } from "talisman-ui" import { AssetPrice } from "@ui/domains/Asset/AssetPrice" import { Fiat } from "@ui/domains/Asset/Fiat" @@ -17,6 +18,7 @@ import { useNetworkById } from "@ui/state" import { TokenLogo } from "../../Asset/TokenLogo" import { AssetBalanceCellValue } from "../AssetBalanceCellValue" +import { usePortfolioEarnButton } from "../usePortfolioEarnButton" import { useTokenBalancesSummary } from "../useTokenBalancesSummary" import { PortfolioNetworksLogoStack } from "./PortfolioNetworksLogoStack" import { usePortfolioNetworkIds } from "./usePortfolioNetworkIds" @@ -44,6 +46,7 @@ export const AssetRow: FC<{ balances: Balances; noCountUp?: boolean }> = ({ const tvl = useUniswapV2LpTokenTotalValueLocked(token, rate?.price, balances) const { canBond } = useBondButton({ balances }) + const { canEarn, openEarnModal } = usePortfolioEarnButton(balances) if (!token || !network || !summary) return null @@ -109,14 +112,14 @@ export const AssetRow: FC<{ balances: Balances; noCountUp?: boolean }> = ({ symbol={isUniswapV2LpToken ? "" : token.symbol} balancesStatus={status} className={classNames( - canBond && "group-hover:hidden", + (canEarn || canBond) && "group-hover:hidden", status.status === "fetching" && "animate-pulse transition-opacity", )} noCountUp={noCountUp} />
- {canBond && ( + {canBond ? ( <>
= ({
- )} + ) : canEarn ? ( + <> +
+ +
+ +
{t("Earn")}
+
+
+
+
+
+ +
+
+ + ) : null}
) } diff --git a/apps/extension/src/ui/domains/Portfolio/AssetsTable/PopupAssetsTable.tsx b/apps/extension/src/ui/domains/Portfolio/AssetsTable/PopupAssetsTable.tsx index 9329386645..dde4407757 100644 --- a/apps/extension/src/ui/domains/Portfolio/AssetsTable/PopupAssetsTable.tsx +++ b/apps/extension/src/ui/domains/Portfolio/AssetsTable/PopupAssetsTable.tsx @@ -1,9 +1,10 @@ import { Balances } from "@talismn/balances" -import { LockIcon } from "@talismn/icons" +import { LockIcon, TrendingUpIcon } from "@talismn/icons" import { classNames } from "@talismn/util" import { useVirtualizer } from "@tanstack/react-virtual" import { FC, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" +import { PillButton } from "talisman-ui" import { Accordion, AccordionIcon } from "@talisman/components/Accordion" import { FadeIn } from "@talisman/components/FadeIn" @@ -24,6 +25,7 @@ import { useNetworkById, usePortfolioGlobalData, useSelectedCurrency } from "@ui import { TokenLogo } from "../../Asset/TokenLogo" import { StaleBalancesIcon } from "../StaleBalancesIcon" import { usePortfolioDisplayBalances } from "../useDisplayBalances" +import { usePortfolioEarnButton } from "../usePortfolioEarnButton" import { usePortfolioNavigation } from "../usePortfolioNavigation" import { useTokenBalancesSummary } from "../useTokenBalancesSummary" import { PortfolioNetworksLogoStack } from "./PortfolioNetworksLogoStack" @@ -60,6 +62,7 @@ const AssetRow: FC<{ }> = ({ balances, locked, noCountUp }) => { const networkIds = usePortfolioNetworkIds(balances) const { genericEvent } = useAnalytics() + const { selectedAccount } = usePortfolioNavigation() const status = useBalancesStatus(balances) @@ -94,6 +97,7 @@ const AssetRow: FC<{ const { canBond } = useBondButton({ balances }) const showStakingButton = canBond && !locked + const { canEarn, openEarnModal } = usePortfolioEarnButton(balances) if (!token || !summary || !network) return null @@ -147,7 +151,7 @@ const AssetRow: FC<{ className={classNames( "whitespace-nowrap text-sm font-bold", locked ? "text-body-secondary" : "text-white", - showStakingButton && "group-hover:hidden", + selectedAccount?.type !== "watch-only" && "group-hover:hidden", )} > {fiat === null ? "-" : } @@ -173,7 +177,8 @@ const AssetRow: FC<{ - {showStakingButton && ( + + {showStakingButton ? (
- )} + ) : canEarn ? ( +
+ +
+ +
{t("Earn")}
+
+
+
+ ) : null} ) } diff --git a/apps/extension/src/ui/domains/Portfolio/usePortfolioEarnButton.ts b/apps/extension/src/ui/domains/Portfolio/usePortfolioEarnButton.ts new file mode 100644 index 0000000000..dc78e22df2 --- /dev/null +++ b/apps/extension/src/ui/domains/Portfolio/usePortfolioEarnButton.ts @@ -0,0 +1,32 @@ +import { Balances } from "@talismn/balances" +import { uniq } from "lodash-es" +import { useCallback, useMemo } from "react" + +import { useYieldxyzTalismanInputTokenIds } from "@ui/state" + +import { useYieldxyzEnterModal } from "../Earn/yieldxyz/enter/useYieldxyzEnterModal" + +export const usePortfolioEarnButton = (balances: Balances) => { + const { open: openYieldxyzModal } = useYieldxyzEnterModal() + const yieldxyzInputTokenIds = useYieldxyzTalismanInputTokenIds() + + // all tokenIds that match a yieldxyz product + const yieldxyzTokenIds = useMemo(() => { + const tokenIds = uniq(balances.each.map(({ tokenId }) => tokenId)) + return tokenIds.filter((tokenId) => yieldxyzInputTokenIds.includes(tokenId)) + }, [balances, yieldxyzInputTokenIds]) + + const openEarnModal = useCallback(() => { + if (yieldxyzTokenIds) openYieldxyzModal({ pickerTokenIds: yieldxyzTokenIds }) + + // if(seekTokenId) { + // // TODO + // } + }, [yieldxyzTokenIds, openYieldxyzModal]) + + return { + // in the future we will support other earn providers + canEarn: !!yieldxyzTokenIds.length, // || seekTokenId + openEarnModal, + } +} diff --git a/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButton.tsx b/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButton.tsx index 06297ba647..9e696f19e8 100644 --- a/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButton.tsx +++ b/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButton.tsx @@ -14,14 +14,17 @@ export const TxSubmitButton: FC = ({ label, className, disabled, + isProcessing, onSubmit, }) => { const { t } = useTranslation() - if (!tx || disabled) + if (!tx || disabled || isProcessing) return ( ) diff --git a/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButtonFallback.tsx b/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButtonFallback.tsx index 8ca2b50808..4c14e79dbc 100644 --- a/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButtonFallback.tsx +++ b/apps/extension/src/ui/domains/Sign/TxSubmitButton/TxSignButtonFallback.tsx @@ -3,14 +3,21 @@ import { FC } from "react" import { useTranslation } from "react-i18next" import { Button } from "talisman-ui" -export const TxSubmitButtonFallback: FC<{ label?: string; className?: string }> = ({ - label, - className, -}) => { +export const TxSubmitButtonFallback: FC<{ + label?: string + className?: string + disabled?: boolean + isProcessing?: boolean +}> = ({ label, className, disabled, isProcessing }) => { const { t } = useTranslation() return ( - ) diff --git a/apps/extension/src/ui/domains/Sign/TxSubmitButton/types.ts b/apps/extension/src/ui/domains/Sign/TxSubmitButton/types.ts index 4b421a05e5..e04be2c6d7 100644 --- a/apps/extension/src/ui/domains/Sign/TxSubmitButton/types.ts +++ b/apps/extension/src/ui/domains/Sign/TxSubmitButton/types.ts @@ -46,6 +46,7 @@ export type TxSubmitButtonProps< label?: string className?: string disabled?: boolean + isProcessing?: boolean /** * * @param txId hash for polkadot and ethereum, signature for solana diff --git a/apps/extension/src/ui/domains/Sign/risk-analysis/RiskAnalysisDrawers.tsx b/apps/extension/src/ui/domains/Sign/risk-analysis/RiskAnalysisDrawers.tsx index 3f88ccb882..4fcd9740f0 100644 --- a/apps/extension/src/ui/domains/Sign/risk-analysis/RiskAnalysisDrawers.tsx +++ b/apps/extension/src/ui/domains/Sign/risk-analysis/RiskAnalysisDrawers.tsx @@ -131,24 +131,29 @@ const RiskAnalysisCriticalPane: FC<{ ) } -export const RiskAnalysisDrawers: FC<{ riskAnalysis?: RiskAnalysis; onReject?: () => void }> = ({ - riskAnalysis, - onReject, -}) => { +export const RiskAnalysisDrawers: FC<{ + riskAnalysis?: RiskAnalysis + containerId?: string + onReject?: () => void +}> = ({ riskAnalysis, containerId = "main", onReject }) => { if (!riskAnalysis) return null return ( <> - + diff --git a/apps/extension/src/ui/domains/Sign/risk-analysis/context.tsx b/apps/extension/src/ui/domains/Sign/risk-analysis/context.tsx index 5742f1d900..0e7260159a 100644 --- a/apps/extension/src/ui/domains/Sign/risk-analysis/context.tsx +++ b/apps/extension/src/ui/domains/Sign/risk-analysis/context.tsx @@ -16,12 +16,16 @@ const useRisksAnalysisProvider = ({ riskAnalysis }: RisksAnalysisProviderProps) const [RiskAnalysisProviderInner, useRiskAnalysis] = provideContext(useRisksAnalysisProvider) export const RiskAnalysisProvider: FC< - RisksAnalysisProviderProps & { children: ReactNode; onReject?: () => void } -> = ({ riskAnalysis, children, onReject }) => { + RisksAnalysisProviderProps & { children: ReactNode; onReject?: () => void; containerId?: string } +> = ({ riskAnalysis, children, onReject, containerId }) => { return ( {children} - + ) } diff --git a/apps/extension/src/ui/hooks/useDummyTransaction.ts b/apps/extension/src/ui/hooks/useDummyTransaction.ts new file mode 100644 index 0000000000..29ea5c64b4 --- /dev/null +++ b/apps/extension/src/ui/hooks/useDummyTransaction.ts @@ -0,0 +1,44 @@ +import { useMemo } from "react" + +import { SendFundsTransactionProps } from "@ui/domains/SendFunds/types" +import { useSendFundsTransactionDot } from "@ui/domains/SendFunds/useSendFundsTransactionDot" +import { useSendFundsTransactionEth } from "@ui/domains/SendFunds/useSendFundsTransactionEth" +import { useSendFundsTransactionSol } from "@ui/domains/SendFunds/useSendFundsTransactionSol" +import { useToken } from "@ui/state" + +type UseDummyTransactionProps = { + address: string | undefined + tokenId: string | undefined +} + +export const useDummyTransaction = ({ address, tokenId }: UseDummyTransactionProps) => { + const token = useToken(tokenId) + + const inputs = useMemo(() => { + return { + tokenId, + from: address, + to: address, + value: "0", + sendMax: false, + allowReap: false, + } + }, [address, tokenId]) + + const txEth = useSendFundsTransactionEth(inputs) + const txDot = useSendFundsTransactionDot(inputs) + const txSol = useSendFundsTransactionSol(inputs) + + return useMemo(() => { + switch (token?.platform) { + case "polkadot": + return txDot + case "ethereum": + return txEth + case "solana": + return txSol + default: + return null + } + }, [token?.platform, txDot, txEth, txSol]) +} diff --git a/apps/extension/src/ui/state/fiatFromUsd.ts b/apps/extension/src/ui/state/fiatFromUsd.ts index 44fa9fdc5b..679abdd2a7 100644 --- a/apps/extension/src/ui/state/fiatFromUsd.ts +++ b/apps/extension/src/ui/state/fiatFromUsd.ts @@ -1,7 +1,7 @@ import { bind } from "@react-rxjs/core" -import { TokenRates } from "@talismn/token-rates" +import { TokenRateData, TokenRates } from "@talismn/token-rates" import { isNotNil } from "@talismn/util" -import { values } from "lodash-es" +import { fromPairs, toPairs, values } from "lodash-es" import { combineLatest, map, shareReplay } from "rxjs" import { selectedCurrency$ } from "./settings" @@ -45,3 +45,28 @@ export const [useFiatFromUsd, getFiatFromUsd$] = bind( ), null, ) + +export const [useTokenRatesFromUsd, getTokenRatesFromUsd$] = bind( + (usd: number | null | undefined) => + refTokenRates$.pipe( + map((refTokenRates): TokenRates | null => { + const usdRate = refTokenRates?.["usd"]?.price + if (!refTokenRates || !usd || !usdRate) return null + if (usd === 0) + return fromPairs( + toPairs(refTokenRates).map(([currency]) => [currency, null] as const), + ) as TokenRates + + return fromPairs( + toPairs(refTokenRates).map(([currency, rate]) => { + if (!rate) return [currency, null] as const + const data: TokenRateData = { + price: (usd / usdRate) * rate.price, + } + return [currency, data] as const + }), + ) as TokenRates + }), + ), + null, +) diff --git a/apps/extension/src/ui/state/index.ts b/apps/extension/src/ui/state/index.ts index 045c26219b..1933c59499 100644 --- a/apps/extension/src/ui/state/index.ts +++ b/apps/extension/src/ui/state/index.ts @@ -22,4 +22,5 @@ export * from "./tokenRates" export * from "./transactions" export * from "./defi" export * from "./fiatFromUsd" +export * from "./yieldxyz" export * from "./confirmedAddresses" diff --git a/apps/extension/src/ui/state/yieldxyz.ts b/apps/extension/src/ui/state/yieldxyz.ts new file mode 100644 index 0000000000..176d6d99c4 --- /dev/null +++ b/apps/extension/src/ui/state/yieldxyz.ts @@ -0,0 +1,258 @@ +import { bind } from "@react-rxjs/core" +import { + evmErc20TokenId, + evmNativeTokenId, + Network, + NetworkId, + solNativeTokenId, + solSplTokenId, + subNativeTokenId, +} from "@talismn/chaindata-provider" +import { isNotNil, Loadable } from "@talismn/util" +import { + getTalismanNetworkIdToYieldxyzNetworkIdMap, + getYieldxyzNetworkIdToTalismanNetworkIdMap, + Networks, + TokenDto, + YieldDto, + YieldxyzPosition, + YieldxyzProvider, +} from "extension-core" +import { log } from "extension-shared" +import { keyBy } from "lodash-es" +import { combineLatest, map, Observable, shareReplay } from "rxjs" + +import { api } from "@ui/api" + +import { getNetworksMapById$ } from "./chaindata" +import { remoteConfig$ } from "./remoteConfig" + +export const [useYieldNetworkIdToTalismanNetworkIdMap, yieldNetworkIdToTalismanNetworkIdMap$] = + bind(remoteConfig$.pipe(map(getYieldxyzNetworkIdToTalismanNetworkIdMap))) + +export const [useTalismanNetworkIdFromYieldNetworkId, getTalismanNetworkIdFromYieldNetworkId$] = + bind( + (yieldNetworkId: Networks | null | undefined) => + yieldNetworkIdToTalismanNetworkIdMap$.pipe( + map((map) => map[yieldNetworkId as Networks] ?? null), + ), + null, + ) + +export const [useTalismanNetworkIdToYieldNetworkIdMap, talismanNetworkIdToYieldNetworkIdMap$] = + bind(remoteConfig$.pipe(map(getTalismanNetworkIdToYieldxyzNetworkIdMap))) + +export const [useYieldNetworkIdFromTalismanNetworkId, getYieldNetworkIdFromTalismanNetworkId$] = + bind( + (talismanNetworkId: NetworkId | null | undefined) => + talismanNetworkIdToYieldNetworkIdMap$.pipe( + map((map) => map[talismanNetworkId as NetworkId] ?? null), + ), + null, + ) + +const rawYieldxyzProviders$ = new Observable>((subscriber) => { + const unsubscribe = api.yieldxyzProvidersSubscribe((loadable: Loadable) => { + subscriber.next(loadable) + }) + + return () => unsubscribe() +}).pipe(shareReplay({ bufferSize: 1, refCount: true })) + +export const [useYieldxyzProviders, yieldxyzProviders$] = bind(rawYieldxyzProviders$, { + status: "loading", + data: [], +}) + +export const [useYieldxyzProvider, yieldxyzProvider$] = bind( + (providerId: string | null | undefined) => + yieldxyzProviders$.pipe( + map((loadable) => { + if (!providerId) + return { status: "success", data: null } as Loadable + const provider = loadable.data?.find((p) => p.id === providerId) || null + return { ...loadable, data: provider } as Loadable + }), + ), + { status: "loading", data: null }, +) + +const rawYieldxyzProducts$ = new Observable>((subscriber) => { + const unsubscribe = api.yieldxyzProductsSubscribe((loadable: Loadable) => { + subscriber.next(loadable) + }) + + return () => unsubscribe() +}).pipe(shareReplay({ bufferSize: 1, refCount: true })) + +export const [useYieldxyzProducts, yieldxyzProducts$] = bind( + combineLatest([ + rawYieldxyzProducts$, + yieldNetworkIdToTalismanNetworkIdMap$, + getNetworksMapById$(), + ]).pipe( + map(([productsLoadable, yieldNetworkIdToTalismanNetworkIdMap, networksMap]) => { + return { + ...productsLoadable, + data: productsLoadable.data?.filter((product) => { + // filter out non-EVM networks. this is only a safety net as all yieldxyz products for Talisman are EVM at this time. + // this filter should be removed as soon as we support signing for other platforms (see useYieldxyzTransactionDot / Sol) + // our wizards are platform agnostic but we did not have any working example to finalize the platform specific transaction hooks. + const talismanNetworkId = yieldNetworkIdToTalismanNetworkIdMap[product.network] + if (!talismanNetworkId) return false + const network = networksMap[talismanNetworkId] + if (!network) return false + return network.platform === "ethereum" + }), + } + }), + ), + { + status: "loading", + data: [], + }, +) + +export const [useYieldxyzProduct, yieldxyzProduct$] = bind( + (yieldId: string | null | undefined) => + yieldxyzProducts$.pipe( + map((loadable) => { + if (!yieldId) return { status: "success", data: null } as Loadable + const product = loadable.data?.find((p) => p.id === yieldId) || null + return { ...loadable, data: product } as Loadable + }), + ), + { status: "loading", data: null }, +) + +// Add new observable for grouped yield balances using bind() +const rawYieldxyzPositions$ = new Observable>((subscriber) => { + const unsubscribe = api.yieldxyzPositionsSubscribe((loadable: Loadable) => { + subscriber.next(loadable) + }) + + return () => unsubscribe() +}).pipe(shareReplay({ bufferSize: 1, refCount: true })) + +export const [useYieldxyzPositionsEnhanced, yieldxyzPositionsEnhanced$] = bind( + combineLatest([rawYieldxyzPositions$, rawYieldxyzProducts$]).pipe( + map(([positionsLoadable, productsLoadable]) => { + const status = + positionsLoadable.status === "loading" || productsLoadable.status === "loading" + ? "loading" + : "success" + const data = + positionsLoadable.data && productsLoadable.data + ? enhanceYieldxyzPositions(positionsLoadable.data, productsLoadable.data) + : undefined + + return { status, data } as Loadable + }), + ), + { + status: "loading", + data: [], + }, +) + +export type YieldxyzPositionEnhanced = YieldxyzPosition & { + totalAmountUsd: number + product: YieldDto +} + +export const [useYieldxyzTalismanInputTokenIds, yieldxyzTalismanInputTokenIds$] = bind( + combineLatest([ + yieldxyzProducts$, + yieldNetworkIdToTalismanNetworkIdMap$, + getNetworksMapById$(), + ]).pipe( + map(([loadable, yieldNetworkIdToTalismanNetworkIdMap, networksMap]) => { + if (loadable.status === "loading") return [] + + const tokenIds = new Set() + for (const product of loadable.data ?? []) { + // if multiple tokens, consider only the native token (no address) + const inputToken = + product.inputTokens.length > 1 + ? product.inputTokens.find((t) => !t.address) + : product.inputTokens[0] + if (!inputToken) continue + + const tokenId = getYieldxyzTokenId( + inputToken, + yieldNetworkIdToTalismanNetworkIdMap, + networksMap, + ) + if (tokenId) tokenIds.add(tokenId) + } + + return Array.from(tokenIds) + }), + ), + [], +) + +export const getYieldxyzTokenId = ( + token: TokenDto, + yieldxyzToTalismanNetworkId: Record, + networksMap: Record, +) => { + const networkId = yieldxyzToTalismanNetworkId[token.network] + if (!networkId) return null + + const network = networksMap[networkId] + if (!network) return null + + switch (network.platform) { + case "ethereum": + return token.address + ? evmErc20TokenId(networkId, token.address as `0x${string}`) + : evmNativeTokenId(networkId) + case "polkadot": { + if (token.symbol === network.nativeCurrency.symbol) return subNativeTokenId(networkId) + log.warn("Unsupported polkadot token for yieldxyz:", token) + return null + } + case "solana": { + if (token.address) return solSplTokenId(networkId, token.address) + if (token.symbol === network.nativeCurrency.symbol) return solNativeTokenId(networkId) + log.warn("Unsupported solana token for yieldxyz:", token) + return null + } + } +} + +const enhanceYieldxyzPositions = ( + positions: YieldxyzPosition[], + products: YieldDto[], +): YieldxyzPositionEnhanced[] => { + const productById = keyBy(products, (p) => p.id) + + return positions + .map((position): YieldxyzPositionEnhanced | null => { + const product = productById[position.yieldId] + if (!product) return null + + // ignore the position if no balances + if ( + !position.balances.filter((b) => { + // if only a claimable balance without a way to claim, ignore it. + // happens on some products from provider Upshift + if (b.type === "claimable" && !b.pendingActions.length) return false + + // TODO identify other non-actionable balance types and ignore them too + + return true + }).length + ) + return null + + const totalAmountUsd = position.balances.reduce( + (sum, b) => sum + parseFloat(b.amountUsd || "0"), + 0, + ) + + return { ...position, totalAmountUsd, product } + }) + .filter(isNotNil) +} diff --git a/package.json b/package.json index 287fff6537..b518286af3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "clean": "pnpm run -r --parallel clean & rm -rf dist .turbo node_modules & wait" }, "dependencies": { + "@yieldxyz/sdk": "^0.0.4", "polkadot-api": "1.13.1" }, "devDependencies": { diff --git a/packages/extension-core/package.json b/packages/extension-core/package.json index cb5f45db3f..0821bd4a6a 100644 --- a/packages/extension-core/package.json +++ b/packages/extension-core/package.json @@ -22,6 +22,7 @@ "@isaacs/ttlcache": "1.4.1", "@metamask/browser-passworder": "5.0.1", "@metamask/eth-sig-util": "8.0.0", + "@yieldxyz/sdk": "^0.0.4", "@polkadot-api/merkleize-metadata": "1.1.18", "@polkadot/apps-config": "0.158.1", "@polkadot/extension-base": "0.59.2", diff --git a/packages/extension-core/src/db/blobs.ts b/packages/extension-core/src/db/blobs.ts index 43ff7b70ea..71f0081ebe 100644 --- a/packages/extension-core/src/db/blobs.ts +++ b/packages/extension-core/src/db/blobs.ts @@ -9,6 +9,9 @@ export type DbBlobId = | "chaindata" | "tokenRates" | "defi-positions" + | "yieldxyz-positions" + | "yieldxyz-products" + | "yieldxyz-providers" | "dynamic-tokens" | "bittensor-validators" diff --git a/packages/extension-core/src/domains/app/store.remoteConfig.ts b/packages/extension-core/src/domains/app/store.remoteConfig.ts index 1f502f9c46..59dc4a1c98 100644 --- a/packages/extension-core/src/domains/app/store.remoteConfig.ts +++ b/packages/extension-core/src/domains/app/store.remoteConfig.ts @@ -56,6 +56,14 @@ export const DEFAULT_REMOTE_CONFIG: RemoteConfigStoreData = { stakingEarlyRewardBoost: "", discountTiers: [], }, + earn: { + yieldxyzNetworks: { + // ethereum: "1", + // polygon: "137", + // optimism: "10", + // solana: "solana-mainnet", + }, + }, bittensor: { fee: { buy: {}, diff --git a/packages/extension-core/src/domains/app/store.settings.ts b/packages/extension-core/src/domains/app/store.settings.ts index 74383154cb..f2edd25259 100644 --- a/packages/extension-core/src/domains/app/store.settings.ts +++ b/packages/extension-core/src/domains/app/store.settings.ts @@ -1,5 +1,5 @@ import { TokenRateCurrency } from "@talismn/token-rates" -import { IS_FIREFOX } from "extension-shared" +import { DEBUG, IS_FIREFOX } from "extension-shared" import { StorageProvider } from "../../libs/Store" import { IdenticonType } from "../accounts/types" @@ -51,3 +51,12 @@ export const DEFAULT_SETTINGS: SettingsStoreData = { } export const settingsStore = new SettingsStore("settings", DEFAULT_SETTINGS) + +if (DEBUG) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const hostObj = globalThis as any + + hostObj.resetSettings = () => { + settingsStore.mutate(() => DEFAULT_SETTINGS) + } +} diff --git a/packages/extension-core/src/domains/app/types.ts b/packages/extension-core/src/domains/app/types.ts index dccc2b010c..285abddd26 100644 --- a/packages/extension-core/src/domains/app/types.ts +++ b/packages/extension-core/src/domains/app/types.ts @@ -1,4 +1,4 @@ -import { DotNetworkId, EthNetworkId, TokenId } from "@talismn/chaindata-provider" +import { DotNetworkId, EthNetworkId, NetworkId, TokenId } from "@talismn/chaindata-provider" import { ValidRequests } from "../../libs/requests/types" import { Address } from "../../types/base" @@ -52,6 +52,11 @@ export type RemoteConfigStoreData = { discount: number }> } + earn: { + /** Yield.xyz network ID => Talisman NetworkId */ + yieldxyzNetworks: Record + } + bittensor: { fee: { buy: Record diff --git a/packages/extension-core/src/domains/earn/exports.ts b/packages/extension-core/src/domains/earn/exports.ts new file mode 100644 index 0000000000..65e8f7baa6 --- /dev/null +++ b/packages/extension-core/src/domains/earn/exports.ts @@ -0,0 +1,3 @@ +export * from "./yieldxyz/types" +export * from "./yieldxyz/helpers" +export * from "./types" diff --git a/packages/extension-core/src/domains/earn/handler.ts b/packages/extension-core/src/domains/earn/handler.ts new file mode 100644 index 0000000000..26bc4598e8 --- /dev/null +++ b/packages/extension-core/src/domains/earn/handler.ts @@ -0,0 +1,38 @@ +import { genericSubscription } from "../../handlers/subscriptions" +import { ExtensionHandler } from "../../libs/Handler" +import { MessageTypes, RequestType, RequestTypes, ResponseType } from "../../types" +import { Port } from "../../types/base" +import { + refreshYieldxyzPosition, + walletYieldxyzPositions$, +} from "./yieldxyz/walletYieldxyzPositions" +import { walletYieldxyzProducts$ } from "./yieldxyz/walletYieldxyzProducts" +import { yieldxyzProviders$ } from "./yieldxyz/yieldxyzProviders" + +export class EarnHandler extends ExtensionHandler { + public async handle( + id: string, + type: TMessageType, + request: RequestTypes[TMessageType], + port: Port, + ): Promise> { + switch (type) { + case "pri(earn.yieldxyz.positions.subscribe)": + return genericSubscription(id, port, walletYieldxyzPositions$) + + case "pri(earn.yieldxyz.positions.refresh)": + return refreshYieldxyzPosition( + request as RequestType<"pri(earn.yieldxyz.positions.refresh)">, + ) + + case "pri(earn.yieldxyz.products.subscribe)": + return genericSubscription(id, port, walletYieldxyzProducts$) + + case "pri(earn.yieldxyz.providers.subscribe)": + return genericSubscription(id, port, yieldxyzProviders$) + + default: + throw new Error(`Unable to handle message of type ${type}`) + } + } +} diff --git a/packages/extension-core/src/domains/earn/types.ts b/packages/extension-core/src/domains/earn/types.ts new file mode 100644 index 0000000000..9d922efd0a --- /dev/null +++ b/packages/extension-core/src/domains/earn/types.ts @@ -0,0 +1,14 @@ +import { + YieldxyzOpportunitiesResponse, + YieldxyzPositionRefreshRequest, + YieldxyzPositionsResponse, + YieldxyzProvidersResponse, +} from "./yieldxyz/types" + +// Message type augmentation for handler routing +export interface EarnMessages { + "pri(earn.yieldxyz.positions.subscribe)": [null, boolean, YieldxyzPositionsResponse] + "pri(earn.yieldxyz.positions.refresh)": [YieldxyzPositionRefreshRequest, void] + "pri(earn.yieldxyz.products.subscribe)": [null, boolean, YieldxyzOpportunitiesResponse] + "pri(earn.yieldxyz.providers.subscribe)": [null, boolean, YieldxyzProvidersResponse] +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/helpers.ts b/packages/extension-core/src/domains/earn/yieldxyz/helpers.ts new file mode 100644 index 0000000000..52eaeb8fe5 --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/helpers.ts @@ -0,0 +1,29 @@ +import { NetworkId } from "@talismn/chaindata-provider" +import { Networks } from "@yieldxyz/sdk" +import { fromPairs, toPairs } from "lodash-es" + +import { RemoteConfigStoreData } from "../../app/types" + +export const getYieldxyzNetworkIdToTalismanNetworkIdMap = ( + remoteConfig: RemoteConfigStoreData, +): Record => { + return fromPairs( + toPairs(remoteConfig.earn.yieldxyzNetworks).filter(([yieldId]) => isYieldxyzNetworkId(yieldId)), + ) as Record +} + +export const getTalismanNetworkIdToYieldxyzNetworkIdMap = ( + remoteConfig: RemoteConfigStoreData, +): Record => { + return toPairs(remoteConfig.earn.yieldxyzNetworks).reduce( + (acc, [yieldId, talismanId]) => { + if (isYieldxyzNetworkId(yieldId)) acc[talismanId] = yieldId + return acc + }, + {} as Record, + ) +} + +export const isYieldxyzNetworkId = (id: string): id is Networks => { + return !!Networks[id as Networks] +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/isSupportedYieldxyzProduct.ts b/packages/extension-core/src/domains/earn/yieldxyz/isSupportedYieldxyzProduct.ts new file mode 100644 index 0000000000..91f038b8ca --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/isSupportedYieldxyzProduct.ts @@ -0,0 +1,39 @@ +import { YieldDto, YieldType } from "@yieldxyz/sdk" + +export const isSupportedYieldxyzProduct = (product: YieldDto): boolean => { + if (!isSupportedType(product.mechanics.type)) return false + + if (!isSupportedInputToken(product)) return false + + return true +} + +const isSupportedInputToken = (product: YieldDto): boolean => { + if (product.inputTokens.length > 1) { + // allow multi asset input only if only one can be supplied + if (!product.mechanics.arguments?.enter?.fields.some((f) => f.name === "inputToken")) + return false + + // also only allow if the input token can be a native token + if (!product.inputTokens.some((t) => !t.address)) return false + } + + return true +} + +const isSupportedType = (type: YieldType): boolean => { + switch (type) { + case "fixed_yield": + case "lending": + case "restaking": + case "staking": + case "vault": + return true + + // case "liquidity_pool": // multi asset input (typing missing somehow) + // case "concentrated_liquidity_pool": // multi asset input (typing missing somehow) + case "real_world_asset": // havent seen any yet, need to test + default: + return false + } +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/store.positions.ts b/packages/extension-core/src/domains/earn/yieldxyz/store.positions.ts new file mode 100644 index 0000000000..31dbb29fe4 --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/store.positions.ts @@ -0,0 +1,93 @@ +import { log } from "extension-shared" +import { isEqual } from "lodash-es" +import { debounceTime, firstValueFrom, map, pairwise, ReplaySubject } from "rxjs" + +import { getBlobStore } from "../../../db" +import { walletReady } from "../../../libs/isWalletReady" +import { BalanceDto, YieldxyzPosition } from "./types" + +const blobStore = getBlobStore("yieldxyz-positions") + +const DEFAULT_DATA: YieldxyzPosition[] = [] + +const subjectYieldxyzPositionsStore$ = new ReplaySubject(1) + +walletReady.then(async () => { + try { + const data = await blobStore.get() + subjectYieldxyzPositionsStore$.next(data ?? DEFAULT_DATA) + } catch (error) { + log.error("[yield.xyz] Error fetching yield balances:", error) + subjectYieldxyzPositionsStore$.next(DEFAULT_DATA) + } +}) + +// normalize function to order items consistently, so we can use isEqual reliably +const normalizeYieldxyzPositions = (items: YieldxyzPosition[]): YieldxyzPosition[] => { + return ( + items + ?.map((p) => ({ + ...p, + balances: p.balances.sort((a, b) => { + const getSortKey = (balance: BalanceDto) => + [ + balance.address, + balance.type, + balance.validator?.address, + balance.validators?.map((v) => v.address).join(","), + balance.token.symbol, + balance.token.address, + balance.amountRaw, + ].join("::") + const s1 = getSortKey(a) + const s2 = getSortKey(b) + if (s1 === s2) log.warn("Cannot sort yield position balances:", { a, b }) + return s1.localeCompare(s2) + }), + })) + .sort((a, b) => a.yieldId.localeCompare(b.yieldId)) || [] + ) +} + +// persist to db when store is updated +walletReady.then(() => { + subjectYieldxyzPositionsStore$ + .pipe(debounceTime(500), map(normalizeYieldxyzPositions), pairwise()) + .subscribe(async ([prev, items]) => { + try { + if (!isEqual(prev, items)) await blobStore.set(items) + } catch (error) { + log.error("[yield.xyz] Error saving yield balances:", error) + } + }) +}) + +export const yieldxyzPositionsStore$ = subjectYieldxyzPositionsStore$.asObservable() + +export const updateYieldxyzPositionsStore = (items: YieldxyzPosition[]) => { + subjectYieldxyzPositionsStore$.next(items) +} + +const matchesYieldIdAndAddress = (position: YieldxyzPosition, yieldId: string, address: string) => + position.yieldId === yieldId && position.address === address + +// Remove all 1/n entries matching yieldId + address. +export const removeYieldxyzPositionsByYieldIdAndAddress = async ( + yieldId: string, + address: string, +) => { + const currentYieldxyzPositions = await firstValueFrom(subjectYieldxyzPositionsStore$) + const next = currentYieldxyzPositions.filter( + (p) => !matchesYieldIdAndAddress(p, yieldId, address), + ) + if (!isEqual(next, currentYieldxyzPositions)) subjectYieldxyzPositionsStore$.next(next) +} + +// Replace all 1/n entries matching yieldId + address with the refreshed (single) position. +export const upsertYieldxyzPositionsByYieldIdAndAddress = async (position: YieldxyzPosition) => { + const currentYieldxyzPositions = await firstValueFrom(subjectYieldxyzPositionsStore$) + const remaining = currentYieldxyzPositions.filter( + (p) => !matchesYieldIdAndAddress(p, position.yieldId, position.address), + ) + subjectYieldxyzPositionsStore$.next([...remaining, position]) +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/store.products.ts b/packages/extension-core/src/domains/earn/yieldxyz/store.products.ts new file mode 100644 index 0000000000..add383725b --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/store.products.ts @@ -0,0 +1,46 @@ +import { log } from "extension-shared" +import { isEqual } from "lodash-es" +import { debounceTime, map, pairwise, ReplaySubject, tap } from "rxjs" + +import { getBlobStore } from "../../../db" +import { walletReady } from "../../../libs/isWalletReady" +import { YieldDto } from "./types" + +const blobStore = getBlobStore("yieldxyz-products") + +const DEFAULT_DATA: YieldDto[] = [] + +const subjectYieldxyzProductsStore$ = new ReplaySubject(1) +walletReady.then(async () => { + try { + const data = await blobStore.get() + subjectYieldxyzProductsStore$.next(data ?? DEFAULT_DATA) + } catch (error) { + log.error("[yield.xyz] Error fetching yield products:", error) + subjectYieldxyzProductsStore$.next(DEFAULT_DATA) + } +}) + +// normalize function to order items consistently, so we can use isEqual reliably +const normalizeYieldxyzProducts = (items: YieldDto[]): YieldDto[] => { + return items.concat().sort((a, b) => a.id.localeCompare(b.id)) +} + +// persist to db when store is updated +subjectYieldxyzProductsStore$ + .pipe(debounceTime(500), map(normalizeYieldxyzProducts), pairwise()) + .subscribe(async ([prev, items]) => { + try { + if (!isEqual(prev, items)) await blobStore.set(items) + } catch (error) { + log.error("[yield.xyz] Error saving yield products:", error) + } + }) + +export const yieldxyzProductsStore$ = subjectYieldxyzProductsStore$ + .asObservable() + .pipe(tap((val) => log.debug("[yield.xyz] yieldxyzProductsStore$ emitted", val))) + +export const updateYieldxyzProductsStore = (items: YieldDto[]) => { + subjectYieldxyzProductsStore$.next(items) +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/store.providers.ts b/packages/extension-core/src/domains/earn/yieldxyz/store.providers.ts new file mode 100644 index 0000000000..a0bd2abadc --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/store.providers.ts @@ -0,0 +1,45 @@ +import { log } from "extension-shared" +import { isEqual } from "lodash-es" +import { debounceTime, map, pairwise, ReplaySubject } from "rxjs" + +import { getBlobStore } from "../../../db" +import { walletReady } from "../../../libs/isWalletReady" +import { YieldxyzProvider } from "./types" + +const blobStore = getBlobStore("yieldxyz-providers") + +const DEFAULT_DATA: YieldxyzProvider[] = [] + +const subjectYieldxyzProvidersStore$ = new ReplaySubject(1) + +walletReady.then(async () => { + try { + const data = await blobStore.get() + subjectYieldxyzProvidersStore$.next(data ?? DEFAULT_DATA) + } catch (error) { + log.error("[yield.xyz] Error fetching providers:", error) + subjectYieldxyzProvidersStore$.next(DEFAULT_DATA) + } +}) + +// normalize function to order items consistently, so we can use isEqual reliably +const normalizeYieldxyzProviders = (items: YieldxyzProvider[]): YieldxyzProvider[] => { + return items.concat().sort((a, b) => a.id.localeCompare(b.id)) +} + +// persist to db when store is updated +subjectYieldxyzProvidersStore$ + .pipe(debounceTime(500), map(normalizeYieldxyzProviders), pairwise()) + .subscribe(async ([prev, items]) => { + try { + if (!isEqual(prev, items)) await blobStore.set(items) + } catch (error) { + log.error("[yield.xyz] Error saving yield providers:", error) + } + }) + +export const yieldxyzProvidersStore$ = subjectYieldxyzProvidersStore$.asObservable() + +export const updateYieldxyzProvidersStore = (items: YieldxyzProvider[]) => { + subjectYieldxyzProvidersStore$.next(items) +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/types.ts b/packages/extension-core/src/domains/earn/yieldxyz/types.ts new file mode 100644 index 0000000000..275053580a --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/types.ts @@ -0,0 +1,59 @@ +import { Address } from "@talismn/balances" +import { NetworkId } from "@talismn/chaindata-provider" +import { Loadable } from "@talismn/util" +import { BalanceDto, YieldDto } from "@yieldxyz/sdk" + +// Re-export SDK types for use in UI +export type { + ActionArgumentsDto, + ActionDto, + ArgumentFieldDto, + ArgumentSchemaDto, + BalanceDto, + BalancesRequestDto, + BalancesResponseDto, + CreateActionDto, + CreateManageActionDto, + HealthStatusDto, + NetworkDto, + Networks, + PendingActionDto, + ProviderDto, + SubmitHashDto, + TimePeriodDto, + TokenDto, + TransactionDto, + ValidatorDto, + YieldBalancesDto, + YieldBalancesRequestDto, + YieldDto, + YieldsControllerGetYieldsParams, + YieldsControllerGetYieldValidators200, +} from "@yieldxyz/sdk" + +export type YieldxyzProvider = { + id: string + name: string + logoURI: string + description: string + website: string + tvlUsd: object | null + type: "protocol" | "validator_provider" + references: string[] +} + +export type YieldxyzPosition = { + yieldId: string + networkId: NetworkId + address: Address + balances: BalanceDto[] +} + +export type YieldxyzPositionsResponse = Loadable +export type YieldxyzOpportunitiesResponse = Loadable +export type YieldxyzProvidersResponse = Loadable + +export type YieldxyzPositionRefreshRequest = { + yieldId: string + address: Address +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/walletYieldxyzPositions.ts b/packages/extension-core/src/domains/earn/yieldxyz/walletYieldxyzPositions.ts new file mode 100644 index 0000000000..a503bade6e --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/walletYieldxyzPositions.ts @@ -0,0 +1,253 @@ +import { NetworkId } from "@talismn/chaindata-provider" +import { getLoadableQuery$, isNotNil, keepAlive, Loadable } from "@talismn/util" +import { log, YIELD_API_BASE_URL } from "extension-shared" +import { chunk, isEqual, uniq } from "lodash-es" +import { + combineLatest, + concatMap, + defer, + distinctUntilChanged, + firstValueFrom, + map, + shareReplay, + startWith, + switchMap, + take, + tap, +} from "rxjs" + +import { remoteConfigStore } from "../../app/store.remoteConfig" +import { RemoteConfigStoreData } from "../../app/types" +import { walletBalances$ } from "../../balances/walletBalances" +import { + getTalismanNetworkIdToYieldxyzNetworkIdMap, + getYieldxyzNetworkIdToTalismanNetworkIdMap, +} from "./helpers" +import { + removeYieldxyzPositionsByYieldIdAndAddress, + updateYieldxyzPositionsStore, + upsertYieldxyzPositionsByYieldIdAndAddress, + yieldxyzPositionsStore$, +} from "./store.positions" +import { BalancesResponseDto, YieldBalancesDto, YieldxyzPosition } from "./types" + +const REFRESH_INTERVAL = 60_000 +const BATCH_SIZE = 20 +const KEEP_ALIVE = 3_000 + +type PositionsQuery = { + address: string + networkId: NetworkId +} + +// Fetch a single batch of queries with shared product caching +const fetchPositionsBatch = async ( + rawQueries: PositionsQuery[], + remoteConfig: RemoteConfigStoreData, + signal: AbortSignal, +): Promise => { + try { + const toYieldyxzNetworksIdMap = getTalismanNetworkIdToYieldxyzNetworkIdMap(remoteConfig) + const toTalismanNetworksIdMap = getYieldxyzNetworkIdToTalismanNetworkIdMap(remoteConfig) + + // Only send yield.xyz-shaped queries to the SDK + const queries = rawQueries + .map(({ address, networkId }) => ({ + address, + network: toYieldyxzNetworksIdMap[networkId], + })) + .filter((q) => !!q.network) + + if (!queries.length) return [] + + const req = await fetch(`${YIELD_API_BASE_URL}/talisman/positions`, { + signal, + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ queries }), + }) + + if (!req.ok) + throw new Error(`Failed to fetch yieldxyz balances: ${req.status} ${req.statusText}`) + + const result = (await req.json()) as BalancesResponseDto + + if (result.errors.length) + log.warn("[Yield.xyz] getAggregateBalances returned errors", result.errors) + + const positions = result.items.map((item) => { + // a position must be mono-account + if (uniq(item.balances.map((b) => b.address)).length !== 1) return null + + // a position must be mono-network + if (uniq(item.balances.map((b) => b.token.network)).length !== 1) return null + + // network must be known by Talisman + const address = item.balances[0].address + const networkId = toTalismanNetworksIdMap[item.balances[0].token.network] + if (!networkId) return null + + return { + address, + networkId, + ...item, + } + }) + + return positions.filter(isNotNil) + } catch (err) { + log.error("[yield.xyz] fetchPositionsBatch error", { err, rawQueries }) + throw err + } +} + +// Main function that handles batching and parallel execution +const fetchPositions = async ( + queries: PositionsQuery[], + remoteConfig: RemoteConfigStoreData, + signal: AbortSignal, +): Promise => { + try { + const batches = chunk(queries, BATCH_SIZE) + + const results = await Promise.all( + batches.map((batch) => fetchPositionsBatch(batch, remoteConfig, signal)), + ) + + return results.flat() + } catch (err) { + log.error("[yield.xyz] fetchPositions error", { err }) + throw err + } +} + +const fetchPosition = async ( + yieldId: string, + address: string, + signal: AbortSignal, +): Promise => { + try { + const req = await fetch( + `${YIELD_API_BASE_URL}/v1/yields/${encodeURIComponent(yieldId)}/balances`, + { + signal, + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ address }), + }, + ) + + if (!req.ok) + throw new Error(`Failed to fetch yieldxyz position balances: ${req.status} ${req.statusText}`) + + const { balances } = (await req.json()) as YieldBalancesDto + + if (!balances.length) return null + + const remoteConfig = await firstValueFrom(remoteConfigStore.observable) + const toTalismanNetworksIdMap = getYieldxyzNetworkIdToTalismanNetworkIdMap(remoteConfig) + + const networkId = toTalismanNetworksIdMap[balances[0].token.network] + if (!networkId) { + log.warn("No networkId found for", balances) + return null + } + + return { yieldId, networkId, address, balances } + } catch (err) { + log.error("[yield.xyz] fetchPosition error", { err, yieldId, address }) + throw err + } +} + +const walletYieldxyzQueries$ = combineLatest([walletBalances$, remoteConfigStore.observable]).pipe( + map(([balances, remoteConfig]) => { + const toYieldyxzNetworksIdMap = getTalismanNetworkIdToYieldxyzNetworkIdMap(remoteConfig) + return uniq( + balances.balances + .filter((b) => !!toYieldyxzNetworksIdMap[b.networkId]) + .map((b) => `${b.address}::${b.networkId}`), + ) + .sort() + .map((serialized): PositionsQuery => { + const [address, networkId] = serialized.split("::") as [string, NetworkId] + return { address, networkId } + }) + }), + distinctUntilChanged(isEqual), + shareReplay({ refCount: true, bufferSize: 1 }), +) + +const mainPositionsQuery$ = defer(() => + yieldxyzPositionsStore$.pipe( + take(1), + concatMap((defaultValue) => + combineLatest([walletYieldxyzQueries$, remoteConfigStore.observable]).pipe( + switchMap(([queries, remoteConfig]) => + getLoadableQuery$({ + namespace: "walletYieldPositions$", + args: [queries, remoteConfig] as const, + queryFn: ([qs, rc], signal) => fetchPositions(qs, rc, signal), + refreshInterval: REFRESH_INTERVAL, + defaultValue, + }), + ), + tap((positions) => { + if (positions.status === "success") updateYieldxyzPositionsStore(positions.data) + }), + startWith({ + status: "loading", + data: defaultValue, + } as Loadable), + ), + ), + ), +) + +export const walletYieldxyzPositions$ = combineLatest([ + mainPositionsQuery$, + yieldxyzPositionsStore$, +]).pipe( + map( + ([queryLoadable, storePositions]): Loadable => ({ + ...queryLoadable, + // Always show the persisted store as the source of truth. + // This makes refresh durable even if no one is currently subscribed. + data: storePositions, + }), + ), + distinctUntilChanged>(isEqual), + shareReplay({ refCount: true, bufferSize: 1 }), + keepAlive(KEEP_ALIVE), +) + +export const refreshYieldxyzPosition = async ({ + yieldId, + address, +}: { + yieldId: string + address: string +}) => { + log.log("Refreshing yield.xyz position", { yieldId, address }) + try { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30_000) + const position = await fetchPosition(yieldId, address, controller.signal) + clearTimeout(timeoutId) + + if (position) { + upsertYieldxyzPositionsByYieldIdAndAddress(position) + } else { + // Refresh can represent an exit/close, which yields empty balances. + // In that case, remove all 1/n entries matching yieldId+address. + removeYieldxyzPositionsByYieldIdAndAddress(yieldId, address) + } + } catch (err) { + log.error("Failed to refresh position", { yieldId, address, err }) + } +} diff --git a/packages/extension-core/src/domains/earn/yieldxyz/walletYieldxyzProducts.ts b/packages/extension-core/src/domains/earn/yieldxyz/walletYieldxyzProducts.ts new file mode 100644 index 0000000000..13352b6652 --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/walletYieldxyzProducts.ts @@ -0,0 +1,102 @@ +import { parseTokenId, TokenId } from "@talismn/chaindata-provider" +import { getLoadableQuery$, isNotNil, keepAlive, Loadable } from "@talismn/util" +import { log, YIELD_API_BASE_URL } from "extension-shared" +import { isEqual, uniq } from "lodash-es" +import { + combineLatest, + concatMap, + defer, + distinctUntilChanged, + map, + shareReplay, + startWith, + switchMap, + take, + tap, +} from "rxjs" + +import { remoteConfigStore } from "../../app/store.remoteConfig" +import { walletBalances$ } from "../../balances/walletBalances" +import { getTalismanNetworkIdToYieldxyzNetworkIdMap } from "./helpers" +import { isSupportedYieldxyzProduct } from "./isSupportedYieldxyzProduct" +import { updateYieldxyzProductsStore, yieldxyzProductsStore$ } from "./store.products" +import { YieldDto } from "./types" + +const REFRESH_INTERVAL = 30_000 +const KEEP_ALIVE = 3_000 + +const ownedTokenIds$ = walletBalances$.pipe( + map((balances) => uniq(balances.balances.map((b) => b.tokenId)).sort()), + distinctUntilChanged(isEqual), +) + +const yieldxyzNetworkIds$ = combineLatest([ownedTokenIds$, remoteConfigStore.observable]).pipe( + map(([tokenIds, remoteConfig]) => { + const toYieldxyzNetworkIdMap = getTalismanNetworkIdToYieldxyzNetworkIdMap(remoteConfig) + + return uniq( + tokenIds + .map((tokenId) => toYieldxyzNetworkIdMap[parseTokenId(tokenId).networkId]) + .filter(isNotNil), + ).sort() + }), + distinctUntilChanged(isEqual), +) + +const fetchYieldxyzProducts = async (networks: string[], signal?: AbortSignal) => { + if (!networks.length) return [] + + try { + const url = new URL(`/talisman/products`, YIELD_API_BASE_URL) + url.searchParams.append("networks", networks.join(",")) + + const req = await fetch(url.toString(), { signal }) + if (!req.ok) + throw new Error(`Failed to fetch yieldxyz products: ${req.status} ${req.statusText}`) + + const products = (await req.json()) as YieldDto[] + + return products.filter(isSupportedYieldxyzProduct) + } catch (err) { + log.error("Error fetching yieldxyz products", err) + throw err + } +} + +export const walletYieldxyzProducts$ = defer(() => + yieldxyzProductsStore$.pipe( + take(1), + concatMap((defaultValue) => + yieldxyzNetworkIds$.pipe( + switchMap((networks) => + getLoadableQuery$({ + namespace: "walletYieldxyzProducts$", + args: networks, + queryFn: (networks, signal) => fetchYieldxyzProducts(networks, signal), + refreshInterval: REFRESH_INTERVAL, + defaultValue, + }), + ), + tap((products) => { + if (products.status === "success") updateYieldxyzProductsStore(products.data) + }), + map( + (loadable): Loadable => + loadable.status === "success" ? loadable : { status: "loading", data: defaultValue }, + ), + startWith({ + status: "loading", + data: defaultValue, + } as Loadable), + ), + ), + distinctUntilChanged>(isEqual), + tap({ + next: (val) => log.debug("[yield.xyz] yield products emitted", val), + subscribe: () => log.debug("[yield.xyz] starting yield products subscription"), + unsubscribe: () => log.debug("[yield.xyz] stopping yield products subscription"), + }), + shareReplay({ refCount: true, bufferSize: 1 }), + keepAlive(KEEP_ALIVE), + ), +) diff --git a/packages/extension-core/src/domains/earn/yieldxyz/yieldxyzProviders.ts b/packages/extension-core/src/domains/earn/yieldxyz/yieldxyzProviders.ts new file mode 100644 index 0000000000..b05c3fe947 --- /dev/null +++ b/packages/extension-core/src/domains/earn/yieldxyz/yieldxyzProviders.ts @@ -0,0 +1,66 @@ +import { getLoadableQuery$, keepAlive, Loadable } from "@talismn/util" +import { log, YIELD_API_BASE_URL } from "extension-shared" +import { isEqual } from "lodash-es" +import { + concatMap, + defer, + distinctUntilChanged, + map, + shareReplay, + startWith, + take, + tap, +} from "rxjs" + +import { updateYieldxyzProvidersStore, yieldxyzProvidersStore$ } from "./store.providers" +import { YieldxyzProvider } from "./types" + +const KEEP_ALIVE = 3_000 +const REFRESH_INTERVAL = 60_000 + +const fetchYieldxyzProviders = async (signal?: AbortSignal) => { + try { + const req = await fetch(`${YIELD_API_BASE_URL}/talisman/providers`, { signal }) + if (!req.ok) + throw new Error(`Failed to fetch yieldxyz providers: ${req.status} ${req.statusText}`) + + return req.json() as Promise + } catch (err) { + log.error("Error fetching yieldxyz providers", err) + throw err + } +} + +export const yieldxyzProviders$ = defer(() => + yieldxyzProvidersStore$.pipe( + take(1), + concatMap((defaultValue) => + getLoadableQuery$({ + namespace: "yieldxyzProviders$", + args: [], + queryFn: (_, signal) => fetchYieldxyzProviders(signal), + refreshInterval: REFRESH_INTERVAL, + defaultValue, + }).pipe( + map( + (loadable): Loadable => + loadable.status === "success" ? loadable : { status: "loading", data: defaultValue }, + ), + startWith({ + status: "loading", + data: defaultValue, + } as Loadable), + ), + ), + distinctUntilChanged>(isEqual), + tap({ + next: (result) => { + if (result.status === "success") updateYieldxyzProvidersStore(result.data) + }, + subscribe: () => log.debug("[yield.xyz] starting yield providers subscription"), + unsubscribe: () => log.debug("[yield.xyz] stopping yield providers subscription"), + }), + shareReplay({ refCount: true, bufferSize: 1 }), + keepAlive(KEEP_ALIVE), + ), +) diff --git a/packages/extension-core/src/domains/solana/exports.ts b/packages/extension-core/src/domains/solana/exports.ts index 2e52c3b0e4..7903976a35 100644 --- a/packages/extension-core/src/domains/solana/exports.ts +++ b/packages/extension-core/src/domains/solana/exports.ts @@ -1,3 +1,4 @@ export * from "./types.extension" +export * from "./types.tabs" export * from "./helpers" diff --git a/packages/extension-core/src/handlers/Extension.ts b/packages/extension-core/src/handlers/Extension.ts index 3a4865449d..98cac6f91f 100644 --- a/packages/extension-core/src/handlers/Extension.ts +++ b/packages/extension-core/src/handlers/Extension.ts @@ -11,6 +11,7 @@ import { BittensorHandler } from "../domains/bittensor/handler" import { ChaindataHandler } from "../domains/chaindata/handler" import { ChainsHandler } from "../domains/chains" import { DefiHandler } from "../domains/defi/handler" +import { EarnHandler } from "../domains/earn/handler" import { EncryptHandler } from "../domains/encrypt" import { EthHandler } from "../domains/ethereum" import { keyringStore } from "../domains/keyring/store" @@ -47,6 +48,7 @@ export default class Extension extends ExtensionHandler { app: new AppHandler(stores), balances: new BalancesHandler(stores), defi: new DefiHandler(stores), + earn: new EarnHandler(stores), encrypt: new EncryptHandler(stores), eth: new EthHandler(stores), metadata: new MetadataHandler(stores), diff --git a/packages/extension-core/src/index.ts b/packages/extension-core/src/index.ts index 46a9d125e4..43030638ce 100644 --- a/packages/extension-core/src/index.ts +++ b/packages/extension-core/src/index.ts @@ -77,5 +77,6 @@ export * from "./domains/keyring/exports" export * from "./domains/transactions/exports" export * from "./domains/metadata/helpers" export * from "./domains/defi/exports" +export * from "./domains/earn/exports" export * from "./domains/solana/exports" export * from "./domains/bittensor/exports" diff --git a/packages/extension-core/src/types/index.ts b/packages/extension-core/src/types/index.ts index 61346a3847..528361643e 100644 --- a/packages/extension-core/src/types/index.ts +++ b/packages/extension-core/src/types/index.ts @@ -8,6 +8,7 @@ import { BalancesMessages } from "../domains/balances/types" import { BittensorMessages } from "../domains/bittensor/types" import { ChainsMessages } from "../domains/chains/types" import { DefiMessages } from "../domains/defi/types" +import { EarnMessages } from "../domains/earn/types" import { EncryptMessages } from "../domains/encrypt/types" import { EthMessages } from "../domains/ethereum/types" import { MetadataMessages } from "../domains/metadata/types" @@ -92,6 +93,7 @@ type AllMessages = Omit & AssetDiscoveryMessages & NftsMessages & DefiMessages & + EarnMessages & PingMessages & ChaindataMessages & BittensorMessages & diff --git a/packages/extension-shared/src/constants.ts b/packages/extension-shared/src/constants.ts index 995053ac87..f2c2c41942 100644 --- a/packages/extension-shared/src/constants.ts +++ b/packages/extension-shared/src/constants.ts @@ -18,6 +18,7 @@ export const RAMPS_COINBASE_API_BASE_PATH = "https://coinbase-api.talisman.xyz" export const RAMPS_COINBASE_PAY_URL = "https://pay.coinbase.com" export const RAMPS_RAMP_API_URL = "https://ramp-api.talisman.xyz" export const ASSET_DISCOVERY_API_URL = "https://ada.talisman.xyz" +export const YIELD_API_BASE_URL = "https://yap.talisman.xyz" export const TALISMAN_WEB_APP_DOMAIN = "app.talisman.xyz" export const TALISMAN_WEB_APP_URL = "https://app.talisman.xyz" diff --git a/packages/talisman-ui/src/components/ModalDialog.tsx b/packages/talisman-ui/src/components/ModalDialog.tsx index cf6fb9e231..9cda2d33df 100644 --- a/packages/talisman-ui/src/components/ModalDialog.tsx +++ b/packages/talisman-ui/src/components/ModalDialog.tsx @@ -1,5 +1,5 @@ import { XIcon } from "@talismn/icons" -import { classNames } from "@talismn/util" +import { classNames, cn } from "@talismn/util" import { FC, ReactNode } from "react" import { IconButton } from "./IconButton" @@ -11,6 +11,7 @@ type ModalDialogProps = { onClose?: () => void children?: ReactNode id?: string + contentClassName?: string } export const ModalDialog: FC = ({ @@ -20,6 +21,7 @@ export const ModalDialog: FC = ({ centerTitle, onClose, children, + contentClassName, }) => { return (
= ({ )} -
{children}
+
+ {children} +
) } diff --git a/packages/talisman-ui/src/components/WizardModalDialog.tsx b/packages/talisman-ui/src/components/WizardModalDialog.tsx new file mode 100644 index 0000000000..f7a88e7dab --- /dev/null +++ b/packages/talisman-ui/src/components/WizardModalDialog.tsx @@ -0,0 +1,46 @@ +import { ChevronLeftIcon, XIcon } from "@talismn/icons" +import { classNames, cn } from "@talismn/util" +import { FC, ReactNode } from "react" + +import { IconButton } from "./IconButton" + +export const WizardModalDialog: FC<{ + title?: ReactNode + id?: string + className?: string + contentClassName?: string + onBackClick?: () => void + onCloseClick?: () => void + children?: ReactNode +}> = ({ id, title, className, contentClassName, onBackClick, onCloseClick, children }) => { + return ( +
+
+ + + +

+ {title} +

+ + + +
+
+ {children} +
+
+ ) +} diff --git a/packages/talisman-ui/src/components/index.ts b/packages/talisman-ui/src/components/index.ts index 8f2c95f2cb..6f93a50eb2 100644 --- a/packages/talisman-ui/src/components/index.ts +++ b/packages/talisman-ui/src/components/index.ts @@ -22,5 +22,6 @@ export * from "./Radio" export * from "./UnsafeImage" export * from "./Toggle" export * from "./Tooltip" +export * from "./WizardModalDialog" export const MysticalBackground = MysticalBackgroundV3 diff --git a/packages/util/src/getLoadableQuery.ts b/packages/util/src/getLoadableQuery.ts new file mode 100644 index 0000000000..c860b06ce6 --- /dev/null +++ b/packages/util/src/getLoadableQuery.ts @@ -0,0 +1,49 @@ +import { map, Observable, startWith } from "rxjs" + +import { Loadable } from "./getLoadable" +import { getQuery$, QueryResult } from "./getQuery" + +export type GetLoadableQueryParams = { + namespace: string + args: TArgs + queryFn: (args: TArgs, signal: AbortSignal) => Promise + refreshInterval?: number + defaultValue?: TResult +} + +/** + * Thin wrapper around getQuery$ that returns Loadable and optionally + * primes the stream with a loading state using the provided default value. + * + * TODO: consolidate with getQuery$ + */ +export const getLoadableQuery$ = ( + params: GetLoadableQueryParams, +): Observable> => { + const initial = + params.defaultValue === undefined + ? [] + : ([{ status: "loading", data: params.defaultValue }] as Loadable[]) + + return getQuery$(params).pipe( + map((val: QueryResult): Loadable => { + switch (val.status) { + case "loading": + return { status: "loading", data: val.data } + case "loaded": + return { status: "success", data: val.data } + case "error": { + const err = val.error as Error | undefined + return { + status: "error", + error: { + name: err?.name ?? "QueryError", + message: err?.message ?? "Failed to execute query", + }, + } + } + } + }), + startWith(...initial), + ) +} diff --git a/packages/util/src/getQuery.ts b/packages/util/src/getQuery.ts index 4f782d56e8..8c0c2f2731 100644 --- a/packages/util/src/getQuery.ts +++ b/packages/util/src/getQuery.ts @@ -42,6 +42,8 @@ type QueryOptions = { * } * }); * ``` + * + * @deprecated use getLoadableQuery$ instead */ export const getQuery$ = ({ namespace, diff --git a/packages/util/src/index.ts b/packages/util/src/index.ts index cd30038af4..62662d7ef3 100644 --- a/packages/util/src/index.ts +++ b/packages/util/src/index.ts @@ -29,3 +29,4 @@ export * from "./tokensToPlanck" export * from "./validateHexString" export * from "./getLoadable" export * from "./getQuery" +export * from "./getLoadableQuery" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 592fb19e95..9f83aaaa76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: .: dependencies: + '@yieldxyz/sdk': + specifier: ^0.0.4 + version: 0.0.4 polkadot-api: specifier: 1.13.1 version: 1.13.1(@swc/core@1.7.39(@swc/helpers@0.5.13))(bufferutil@4.0.8)(jiti@2.5.1)(postcss@8.5.6)(rxjs@7.8.2)(tsx@4.20.3)(utf-8-validate@6.0.4)(yaml@2.6.0) @@ -1462,6 +1465,9 @@ importers: '@talismn/util': specifier: workspace:* version: link:../util + '@yieldxyz/sdk': + specifier: ^0.0.4 + version: 0.0.4 bcryptjs: specifier: ^2.4.3 version: 2.4.3 @@ -5765,6 +5771,17 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@yieldxyz/sdk@0.0.4': + resolution: {integrity: sha512-0Aq7g1ol7v5+o40WOCYxSgR288x5n/IdAlR6nUaCF0gTR/2+cVxR/P7CnkYZQ2CWGMOYSjqeVN4Y0UyIH3UhJg==} + peerDependencies: + '@faker-js/faker': ^9 + msw: ^2 + peerDependenciesMeta: + '@faker-js/faker': + optional: true + msw: + optional: true + '@zeitgeistpm/type-defs@1.0.0': resolution: {integrity: sha512-dtjNlJSb8ELz87aTD6jqKKfO7kY4HFYzSmDk9JrzHLv+w/JKtG+aLz+WImL6MSaF1MjDE1tm28dj980Zn+nfGA==} @@ -18120,6 +18137,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@yieldxyz/sdk@0.0.4': {} + '@zeitgeistpm/type-defs@1.0.0': {} '@zeroio/type-definitions@0.0.14': {}