From 2715e5c1cc8ea53230f95bddb6bc115bf80ca8ab Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Wed, 17 Dec 2025 16:00:20 +0200 Subject: [PATCH 1/6] feat: repay loans + revalidate active position list --- apps/indexer/src/app/plugins/cache.ts | 6 +- apps/web-app/public/locales/en/common.json | 12 +- apps/web-app/public/locales/en/tx.json | 10 - apps/web-app/src/@types/i18next.d.ts | 2 - apps/web-app/src/@types/react-query.d.ts | 16 + .../components/BorrowDialog/BorrowDialog.tsx | 17 +- .../components/AssetsTable/AssetsTable.tsx | 5 + .../components/LendDialog/LendDialog.tsx | 19 +- .../components/RepayDialog/RepayDialog.tsx | 291 ++++++++++++++++++ .../WithdrawDialog/WithdrawDialog.tsx | 16 +- .../src/components/MoneyMarket/constants.ts | 1 + .../MoneyMarket/hooks/use-money-positions.ts | 8 +- .../MoneyMarket/stores/repay-request.store.ts | 26 ++ .../TransactionDialog/TransactionDialog.tsx | 10 +- .../components/TransactionDialog/TxList.tsx | 23 +- .../tanstack-query/root-provider.tsx | 47 ++- apps/web-app/src/routes/money-market.tsx | 11 +- packages/sdk/src/lib/context.ts | 3 +- .../money-market/money-market.manager.ts | 136 ++++++++ packages/shared/src/lib/http-client.ts | 6 + 20 files changed, 604 insertions(+), 61 deletions(-) delete mode 100644 apps/web-app/public/locales/en/tx.json create mode 100644 apps/web-app/src/@types/react-query.d.ts create mode 100644 apps/web-app/src/components/MoneyMarket/components/RepayDialog/RepayDialog.tsx create mode 100644 apps/web-app/src/components/MoneyMarket/stores/repay-request.store.ts diff --git a/apps/indexer/src/app/plugins/cache.ts b/apps/indexer/src/app/plugins/cache.ts index 83ce193..125eb93 100644 --- a/apps/indexer/src/app/plugins/cache.ts +++ b/apps/indexer/src/app/plugins/cache.ts @@ -115,8 +115,8 @@ const redisCachePlugin: FastifyPluginAsync = async ( ) => { const redis = opts.redisClient ?? cacheRedisConnection; - const defaultTtl = opts.defaultTtlSeconds ?? 60; - const defaultStaleTtl = opts.defaultStaleTtlSeconds ?? 600; // 10 minutes + const defaultTtl = opts.defaultTtlSeconds ?? 10; // 10 seconds + const defaultStaleTtl = opts.defaultStaleTtlSeconds ?? 60; // 1 minute const keyPrefix = opts.keyPrefix ?? 'route-cache'; // @ts-expect-error declare decorator @@ -305,7 +305,7 @@ export const maybeCache = async ( const redis = cacheRedisConnection; - const ttl = opts.ttlSeconds ?? 30; // 30 seconds + const ttl = opts.ttlSeconds ?? 10; // 10 seconds const cacheKey = 'maybe-cache:fn:' + encode.sha256(key); diff --git a/apps/web-app/public/locales/en/common.json b/apps/web-app/public/locales/en/common.json index ac7cf82..ddbe596 100644 --- a/apps/web-app/public/locales/en/common.json +++ b/apps/web-app/public/locales/en/common.json @@ -5,5 +5,15 @@ "confirm": "Confirm", "continue": "Continue", "abort": "Abort", - "loading": "Loading..." + "loading": "Loading...", + "tx": { + "title": "Transaction Confirmation", + "description": "Please review and confirm transactions in your wallet", + "preparing": "Preparing transaction...", + "connectWallet": "Connect your wallet to proceed.", + "switchNetwork": "Switch to {{name}} network", + "signMessage": "Sign Message", + "signTypedData": "Sign Typed Data", + "sendTransaction": "Send Transaction" + } } diff --git a/apps/web-app/public/locales/en/tx.json b/apps/web-app/public/locales/en/tx.json deleted file mode 100644 index 5fb412a..0000000 --- a/apps/web-app/public/locales/en/tx.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "title": "Transaction Confirmation", - "description": "Please review and confirm transactions in your wallet", - "preparing": "Preparing transaction...", - "connectWallet": "Connect your wallet to proceed.", - "switchNetwork": "Switch to {{name}} network", - "signMessage": "Sign Message", - "signTypedData": "Sign Typed Data", - "sendTransaction": "Send Transaction" -} diff --git a/apps/web-app/src/@types/i18next.d.ts b/apps/web-app/src/@types/i18next.d.ts index 4d75bb1..41141d0 100644 --- a/apps/web-app/src/@types/i18next.d.ts +++ b/apps/web-app/src/@types/i18next.d.ts @@ -1,6 +1,5 @@ import type { resources as common } from 'public/locales/en/common'; import type { resources as glossary } from 'public/locales/en/glossary'; -import type { resources as tx } from 'public/locales/en/tx'; import type { resources as validation } from 'public/locales/en/validation'; import { defaultNS } from '../i18n'; @@ -12,7 +11,6 @@ declare module 'i18next' { common: typeof common; glossary: typeof glossary; validation: typeof validation; - tx: typeof tx; }; } } diff --git a/apps/web-app/src/@types/react-query.d.ts b/apps/web-app/src/@types/react-query.d.ts new file mode 100644 index 0000000..0617f1d --- /dev/null +++ b/apps/web-app/src/@types/react-query.d.ts @@ -0,0 +1,16 @@ +import '@tanstack/react-query'; + +interface GlobalQueryMeta extends Record { + revalidateCache?: boolean; +} + +interface GlobalMutationMeta extends Record { + invalidates?: Array; +} + +declare module '@tanstack/react-query' { + interface Register { + queryMeta: GlobalQueryMeta; + mutationMeta: GlobalMutationMeta; + } +} diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx index e327451..e15e199 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx @@ -17,6 +17,7 @@ import { ItemGroup, } from '@/components/ui/item'; import { useAppForm } from '@/hooks/app-form'; +import { revalidateQuery } from '@/integrations/tanstack-query/root-provider'; import { sdk } from '@/lib/sdk'; import { useSlayerTx } from '@/lib/transactions'; import { validateDecimal } from '@/lib/validations'; @@ -48,6 +49,15 @@ const BorrowDialogForm = () => { borrowRequestStore.getState().reset(); } }, + onCompleted: () => { + revalidateQuery({ + queryKey: [ + 'money-market:positions', + reserve.pool.id || 'default', + address, + ], + }); + }, }); const data = useMemo(() => { @@ -89,12 +99,6 @@ const BorrowDialogForm = () => { ), ); }, - onSubmitInvalid(props) { - console.log('Borrow request submission invalid:', props); - }, - onSubmitMeta() { - console.log('Borrow request submission meta:', form); - }, }); const handleSubmit = (e: React.FormEvent) => { @@ -104,7 +108,6 @@ const BorrowDialogForm = () => { }; const handleEscapes = (e: Event) => { - // borrowRequestStore.getState().reset(); e.preventDefault(); }; diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx index 4b11f9e..a5689ad 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx @@ -8,6 +8,7 @@ import { } from '@/components/ui/table/table'; import { Fragment, useCallback, useMemo, useState, type FC } from 'react'; +import { repayRequestStore } from '@/components/MoneyMarket/stores/repay-request.store'; import { AmountRenderer } from '@/components/ui/amount-renderer'; import { Button } from '@/components/ui/button'; import { InfoButton } from '@/components/ui/info-button'; @@ -64,6 +65,9 @@ export const AssetsTable: FC = ({ assets }) => { [selectedApy, rowKey], ); + const repayLoan = (position: MoneyMarketPoolPosition) => + repayRequestStore.getState().setPosition(position); + return ( @@ -178,6 +182,7 @@ export const AssetsTable: FC = ({ assets }) => { diff --git a/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx index 0b2b1e6..ae5103a 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/dialog'; import { Item, ItemContent, ItemGroup } from '@/components/ui/item'; import { useAppForm } from '@/hooks/app-form'; +import { revalidateQuery } from '@/integrations/tanstack-query/root-provider'; import { sdk } from '@/lib/sdk'; import { useSlayerTx } from '@/lib/transactions'; import { validateDecimal } from '@/lib/validations'; @@ -53,6 +54,15 @@ const LendDialogForm = () => { lendRequestStore.getState().reset(); } }, + onCompleted: () => { + revalidateQuery({ + queryKey: [ + 'money-market:positions', + reserve.pool.id || 'default', + address, + ], + }); + }, }); const { data: balance } = useBalance({ @@ -60,7 +70,7 @@ const LendDialogForm = () => { ? undefined : reserve.token.address, address: address, - // chainId: sdk.ctx.chainId, + chainId: sdk.ctx.chainId, }); const form = useAppForm({ @@ -79,12 +89,6 @@ const LendDialogForm = () => { }), ); }, - onSubmitInvalid(props) { - console.log('Lend request submission invalid:', props); - }, - onSubmitMeta() { - console.log('Lend request submission meta:', form); - }, }); const handleSubmit = (e: React.FormEvent) => { @@ -94,7 +98,6 @@ const LendDialogForm = () => { }; const handleEscapes = (e: Event) => { - // lendRequestStore.getState().reset(); e.preventDefault(); }; diff --git a/apps/web-app/src/components/MoneyMarket/components/RepayDialog/RepayDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/RepayDialog/RepayDialog.tsx new file mode 100644 index 0000000..5361dad --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/components/RepayDialog/RepayDialog.tsx @@ -0,0 +1,291 @@ +import { AmountRenderer } from '@/components/ui/amount-renderer'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { HealthFactorBar } from '@/components/ui/health-factor-bar'; +import { + Item, + ItemContent, + ItemDescription, + ItemGroup, +} from '@/components/ui/item'; +import { useAppForm } from '@/hooks/app-form'; +import { revalidateQuery } from '@/integrations/tanstack-query/root-provider'; +import { sdk } from '@/lib/sdk'; +import { useSlayerTx } from '@/lib/transactions'; +import { shouldUseFullAmount } from '@/lib/utils'; +import { validateDecimal } from '@/lib/validations'; +import { areAddressesEqual, Decimal } from '@sovryn/slayer-shared'; +import { useCallback, useMemo, useReducer } from 'react'; +import { useAccount, useBalance } from 'wagmi'; +import z from 'zod'; +import { useStore } from 'zustand'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; +import { MINIMUM_HEALTH_FACTOR } from '../../constants'; +import { useMoneyMarketPositions } from '../../hooks/use-money-positions'; +import { repayRequestStore } from '../../stores/repay-request.store'; + +const RepayDialogForm = () => { + const { address } = useAccount(); + + const position = useStore(repayRequestStore, (state) => state.position!); + + const [useCollateral] = useReducer((state) => !state, false); + + const { data } = useMoneyMarketPositions({ + pool: position.pool.id || 'default', + address: address!, + }); + + const summary = useMemo(() => { + const pos = (data?.data?.positions || []).find( + (item) => item.reserve.id === position.reserve.id, + ); + if (pos && data?.data) { + return data.data.summary; + } + return null; + }, [data]); + + const { begin } = useSlayerTx({ + onClosed: (ok: boolean) => { + if (ok) { + // close withdrawal dialog if tx was successful + repayRequestStore.getState().reset(); + } + }, + onCompleted: () => { + revalidateQuery({ + queryKey: [ + 'money-market:positions', + position.pool.id || 'default', + address, + ], + }); + }, + }); + + const { data: walletBalance } = useBalance({ + token: areAddressesEqual(position.token.address, position.pool.weth) + ? undefined + : position.token.address, + address: address, + chainId: sdk.ctx.chainId, + }); + + const maximumRepayAmount = useMemo(() => { + return Decimal.from(position.borrowed, position.token.decimals).gt( + walletBalance?.value ?? 0n, + walletBalance?.decimals, + ) + ? Decimal.from(walletBalance?.value ?? 0n, walletBalance?.decimals) + : Decimal.from(position.borrowed, position.token.decimals); + }, [ + position.borrowed, + position.token.decimals, + walletBalance?.decimals, + walletBalance?.value, + ]); + + const balance = useMemo( + () => ({ + value: maximumRepayAmount.toBigInt(), + decimals: position.token.decimals, + symbol: position.token.symbol, + }), + [position, maximumRepayAmount], + ); + + const form = useAppForm({ + defaultValues: { + amount: '', + }, + validators: { + onChange: z.object({ + amount: validateDecimal({ + min: 1n, + max: balance.value ?? undefined, + }), + }), + }, + onSubmit: ({ value }) => { + begin(() => + sdk.moneyMarket.repay( + { + ...position.reserve, + pool: position.pool, + token: position.token, + }, + value.amount, + maximumRepayAmount.lte(value.amount) || + shouldUseFullAmount(value.amount, position.borrowed), + useCollateral, + position.borrowRateMode, + { + account: address!, + }, + ), + ); + }, + onSubmitInvalid(props) { + console.log('Withdraw request submission invalid:', props); + }, + onSubmitMeta() { + console.log('Withdraw request submission meta:', form); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }; + + const handleEscapes = (e: Event) => { + e.preventDefault(); + }; + + const computeHealthFactor = useCallback( + (amount: string) => { + if (!summary || Decimal.from(summary.totalBorrowsUsd).eq(0)) { + return Decimal.INFINITY; + } + + return Decimal.from(summary.collateralBalanceUsd) + .mul(summary.currentLiquidationThreshold) + .div( + Decimal.from(summary.totalBorrowsUsd).sub( + Decimal.from(amount || '0').mul(position.reserve.priceUsd), + ), + ); + }, + [summary, position], + ); + + const calculateRemainingDebt = (repaymentAmount: string) => { + const amount = Decimal.from( + repaymentAmount || '0', + position.token.decimals, + ); + const current = Decimal.from(position.borrowed, position.token.decimals); + if (amount.gt(current)) { + return Decimal.ZERO.toString(); + } + return Decimal.from(position.borrowed, position.token.decimals) + .sub(repaymentAmount || '0') + .toString(); + }; + + return ( +
+ e.preventDefault()} + > + + Repay Loan + + Repay your borrowed assets in the money market. + + + + {(field) => ( + + )} + + + + [ + state.values.amount, + computeHealthFactor(state.values.amount ?? 0), + ] as const + } + > + {([amount, healthFactor]) => ( + + + + +
+
Collateral Ratio
+ +
+
+ + + +
+
+ + Remaining debt + + + + +
+ )} +
+ + + + + + + + + +
+ + ); +}; + +export const RepayDialog = () => { + const isOpen = useStoreWithEqualityFn( + repayRequestStore, + (state) => state.position !== null, + ); + + const handleClose = (open: boolean) => { + if (!open) { + repayRequestStore.getState().reset(); + } + }; + + return ( + + {isOpen && } + + ); +}; diff --git a/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx index 167eaba..491f34b 100644 --- a/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/dialog'; import { Item, ItemContent, ItemGroup } from '@/components/ui/item'; import { useAppForm } from '@/hooks/app-form'; +import { revalidateQuery } from '@/integrations/tanstack-query/root-provider'; import { sdk } from '@/lib/sdk'; import { useSlayerTx } from '@/lib/transactions'; import { shouldUseFullAmount } from '@/lib/utils'; @@ -42,6 +43,15 @@ const WithdrawDialogForm = () => { withdrawRequestStore.getState().reset(); } }, + onCompleted: () => { + revalidateQuery({ + queryKey: [ + 'money-market:positions', + position.pool.id || 'default', + address, + ], + }); + }, }); const maximumWithdrawAmount = useMemo(() => { @@ -118,12 +128,6 @@ const WithdrawDialogForm = () => { ), ); }, - onSubmitInvalid(props) { - console.log('Withdraw request submission invalid:', props); - }, - onSubmitMeta() { - console.log('Withdraw request submission meta:', form); - }, }); const handleSubmit = (e: React.FormEvent) => { diff --git a/apps/web-app/src/components/MoneyMarket/constants.ts b/apps/web-app/src/components/MoneyMarket/constants.ts index 3a3fedc..917f480 100644 --- a/apps/web-app/src/components/MoneyMarket/constants.ts +++ b/apps/web-app/src/components/MoneyMarket/constants.ts @@ -1 +1,2 @@ export const MINIMUM_HEALTH_FACTOR = 1.1; +export const MINIMUM_COLLATERAL_RATIO_LENDING_POOLS = 1.5; diff --git a/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts index 0c58f6c..7cbccdd 100644 --- a/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts +++ b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts @@ -1,3 +1,4 @@ +import { shouldRevalidateQuery } from '@/integrations/tanstack-query/root-provider'; import { sdk } from '@/lib/sdk'; import { useQuery } from '@tanstack/react-query'; import type { Address } from 'viem'; @@ -14,7 +15,12 @@ export const useMoneyMarketPositions = ({ }) => useQuery({ queryKey: ['money-market:positions', pool, address], - queryFn: () => sdk.moneyMarket.listUserPositions(pool, address!), + queryFn: ({ queryKey }) => + sdk.moneyMarket.listUserPositions( + pool, + address!, + shouldRevalidateQuery(queryKey), + ), staleTime: STALE_TIME, enabled: !!address && !!pool, }); diff --git a/apps/web-app/src/components/MoneyMarket/stores/repay-request.store.ts b/apps/web-app/src/components/MoneyMarket/stores/repay-request.store.ts new file mode 100644 index 0000000..516b35e --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/stores/repay-request.store.ts @@ -0,0 +1,26 @@ +import type { MoneyMarketPoolPosition } from '@sovryn/slayer-sdk'; +import { createStore } from 'zustand'; +import { combine } from 'zustand/middleware'; + +type State = { + position: MoneyMarketPoolPosition | null; +}; + +type Actions = { + setPosition: (position: MoneyMarketPoolPosition) => void; + reset: () => void; +}; + +type RepayRequestStore = State & Actions; + +export const repayRequestStore = createStore( + combine( + { + position: null as MoneyMarketPoolPosition | null, + }, + (set) => ({ + setPosition: (position: MoneyMarketPoolPosition) => set({ position }), + reset: () => set({ position: null }), + }), + ), +); diff --git a/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx b/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx index ecc3ba5..437c4c4 100644 --- a/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx +++ b/apps/web-app/src/components/TransactionDialog/TransactionDialog.tsx @@ -13,7 +13,7 @@ import { import { TxList } from './TxList'; export const TransactionDialogProvider = () => { - const { t } = useTranslation('tx'); + const { t } = useTranslation(); const [isOpen, isReady, isClosing] = useStoreWithEqualityFn( txStore, @@ -53,12 +53,14 @@ export const TransactionDialogProvider = () => { ) : ( <> - {t(($) => $.title)} - {t(($) => $.description)} + {t(($) => $.tx.title)} + + {t(($) => $.tx.description)} +
-

{t(($) => $.preparing)}

+

{t(($) => $.tx.preparing)}

)} diff --git a/apps/web-app/src/components/TransactionDialog/TxList.tsx b/apps/web-app/src/components/TransactionDialog/TxList.tsx index 1d5ad72..e86d4b5 100644 --- a/apps/web-app/src/components/TransactionDialog/TxList.tsx +++ b/apps/web-app/src/components/TransactionDialog/TxList.tsx @@ -24,7 +24,7 @@ import { useInternalTxHandler } from './hooks/use-internal-tx-handler'; import { TransactionItem } from './TransactionItem'; export const TxList = () => { - const { t } = useTranslation(['tx', 'common']); + const { t } = useTranslation(); const { switchChain } = useSwitchChain(); const config = useConfig(); const { isConnected, chainId } = useAccount(); @@ -75,14 +75,14 @@ export const TxList = () => { const confirmLabel = useMemo(() => { if (currentTx) { if (isMessageRequest(currentTx)) { - return t(($) => $.signMessage, { ns: 'tx' }); + return t(($) => $.tx.signMessage); } else if (isTransactionRequest(currentTx)) { - return t(($) => $.sendTransaction, { ns: 'tx' }); + return t(($) => $.tx.sendTransaction); } else if (isTypedDataRequest(currentTx)) { - return t(($) => $.signTypedData, { ns: 'tx' }); + return t(($) => $.tx.signTypedData); } } - return t(($) => $.confirm, { ns: 'common' }); + return t(($) => $.confirm); }, [currentTx]); const handleSwitchChain = useCallback(() => { @@ -100,21 +100,19 @@ export const TxList = () => { return ( <> - {t(($) => $.title, { ns: 'tx' })} - - {t(($) => $.description, { ns: 'tx' })} - + {t(($) => $.tx.title)} + {t(($) => $.tx.description)} {items.map((tx, index) => ( ))} - {!isConnected &&

{t(($) => $.connectWallet)}

} + {!isConnected &&

{t(($) => $.tx.connectWallet)}

} @@ -124,8 +122,7 @@ export const TxList = () => { requiredChain !== undefined && currentChain?.id !== requiredChain?.id ? ( diff --git a/apps/web-app/src/integrations/tanstack-query/root-provider.tsx b/apps/web-app/src/integrations/tanstack-query/root-provider.tsx index 7805b22..e78dc2c 100644 --- a/apps/web-app/src/integrations/tanstack-query/root-provider.tsx +++ b/apps/web-app/src/integrations/tanstack-query/root-provider.tsx @@ -1,7 +1,27 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + matchQuery, + MutationCache, + QueryClient, + QueryClientProvider, + type QueryKey, +} from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + mutationCache: new MutationCache({ + onSuccess: (_data, _variables, _context, mutation) => { + queryClient.invalidateQueries({ + predicate: (query) => + // invalidate all matching tags at once + // or everything if no meta is provided + mutation.meta?.invalidates?.some((queryKey) => + matchQuery({ queryKey, exact: false }, query), + ) ?? false, + }); + }, + }), +}); export function getContext() { - const queryClient = new QueryClient(); return { queryClient, }; @@ -18,3 +38,26 @@ export function Provider({ {children} ); } + +const runtimeMeta = new Map(); +const k = (queryKey: unknown) => JSON.stringify(queryKey); + +export function shouldRevalidateQuery(queryKey: QueryKey) { + const value = runtimeMeta.get(k(queryKey)); + if (value) { + runtimeMeta.delete(k(queryKey)); + return { revalidateCache: true }; + } + return {}; +} + +export function revalidateQuery({ + queryKey, + exact = true, +}: { + queryKey: QueryKey; + exact?: boolean; +}) { + runtimeMeta.set(k(queryKey), true); + queryClient.invalidateQueries({ queryKey, exact }); +} diff --git a/apps/web-app/src/routes/money-market.tsx b/apps/web-app/src/routes/money-market.tsx index 8f643d4..7af03cf 100644 --- a/apps/web-app/src/routes/money-market.tsx +++ b/apps/web-app/src/routes/money-market.tsx @@ -8,6 +8,7 @@ import { BorrowDialog } from '@/components/MoneyMarket/components/BorrowDialog/B import { BorrowPositionsList } from '@/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList'; import { LendAssetsList } from '@/components/MoneyMarket/components/LendAssetsList/LendAssetsList'; import { LendDialog } from '@/components/MoneyMarket/components/LendDialog/LendDialog'; +import { RepayDialog } from '@/components/MoneyMarket/components/RepayDialog/RepayDialog'; import { WithdrawDialog } from '@/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog'; import { QUERY_KEY_MONEY_MARKET_POSITIONS, @@ -47,7 +48,7 @@ export const Route = createFileRoute('/money-market')({ }); client.prefetchQuery({ - queryKey: ['money-market:reserve', pool || 'default'], + queryKey: ['money-market:reserves', pool || 'default'], queryFn: () => sdk.moneyMarket.listReserves(pool || 'default'), staleTime: STALE_TIME, }); @@ -75,8 +76,11 @@ function RouteComponent() { // }); const { data: reserves } = useQuery({ - queryKey: ['money-market:reserve', pool || 'default'], - queryFn: () => sdk.moneyMarket.listReserves(pool || 'default'), + queryKey: ['money-market:reserves', pool || 'default'], + queryFn: ({ meta }) => + sdk.moneyMarket.listReserves(pool || 'default', { + revalidateCache: meta?.revalidateCache ?? false, + }), staleTime: STALE_TIME, }); @@ -137,6 +141,7 @@ function RouteComponent() { + ); } diff --git a/packages/sdk/src/lib/context.ts b/packages/sdk/src/lib/context.ts index 911d47b..1c95fb8 100644 --- a/packages/sdk/src/lib/context.ts +++ b/packages/sdk/src/lib/context.ts @@ -34,6 +34,7 @@ export interface SdkRequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; // per-call baseUrl override if needed: baseUrlOverride?: string; + revalidateCache?: boolean; } export class Context { @@ -42,7 +43,7 @@ export class Context { readonly http: HttpClient; readonly publicClient: PublicClient; - readonly chainId: number; + readonly chainId: chain['id']; constructor(cfg: SdkConfig) { if (!cfg.publicClient) diff --git a/packages/sdk/src/managers/money-market/money-market.manager.ts b/packages/sdk/src/managers/money-market/money-market.manager.ts index 6aacf0f..fff8fea 100644 --- a/packages/sdk/src/managers/money-market/money-market.manager.ts +++ b/packages/sdk/src/managers/money-market/money-market.manager.ts @@ -81,6 +81,18 @@ const poolAbi = [ ], outputs: [], }, + { + type: 'function', + name: 'repay', + stateMutability: 'nonpayable', + inputs: [ + { type: 'address', name: 'asset' }, + { type: 'uint256', name: 'amount' }, + { type: 'uint256', name: 'rateMode' }, + { type: 'address', name: 'onBehalfOf' }, + ], + outputs: [], + }, ] as const; const debtWethApi = [ @@ -132,8 +144,22 @@ const wethGatewayAbi = [ ], outputs: [], }, + { + type: 'function', + name: 'repayETH', + stateMutability: 'payable', + inputs: [ + { type: 'address', name: 'pool' }, + { type: 'uint256', name: 'amount' }, + { type: 'uint256', name: 'rateMode' }, + { type: 'address', name: 'onBehalfOf' }, + ], + outputs: [], + }, ] as const; +const REPAY_ALL_ETH_SURPLUS = Decimal.from('0.01'); + export class MoneyMarketManager extends BaseClient { async listPools(opts: SdkRequestOptions = {}) { return this.ctx.http.request>( @@ -414,4 +440,114 @@ export class MoneyMarketManager extends BaseClient { }, ]; } + + async repay( + reserve: MoneyMarketPoolReserve, + amount: Decimalish, + isEntireDebt: boolean, + useCollateral: boolean, + borrowRateMode: BorrowRateMode, + opts: TransactionOpts, + ) { + log( + `Preparing repay of ${Decimal.from(amount).toString()} ${reserve.token.symbol} from pool ${reserve.pool.id}`, + { reserve, amount, isEntireDebt, useCollateral, borrowRateMode, opts }, + ); + if (!useCollateral) { + return this.repayWithBalance( + reserve, + amount, + isEntireDebt, + borrowRateMode, + opts, + ); + } + + throw new Error('Repay with collateral is not implemented yet'); + } + + private async repayWithBalance( + reserve: MoneyMarketPoolReserve, + amount: Decimalish, + isEntireDebt: boolean, + borrowRateMode: BorrowRateMode, + opts: TransactionOpts, + ) { + const asset = reserve.token; + const pool = reserve.pool; + + if (asset.isNative || areAddressesEqual(asset.address, pool.weth)) { + const entry = Decimal.from(amount); + + const value = isEntireDebt ? entry.add(REPAY_ALL_ETH_SURPLUS) : entry; + + return [ + { + id: 'repay_native_asset__balance', + title: `Repay ${asset.symbol}`, + description: `Repay ${entry.toString()} ${asset.symbol}`, + request: makeTransactionRequest({ + to: pool.wethGateway, + value: value.toBigInt(), + chain: this.ctx.publicClient.chain, + account: opts.account, + data: encodeFunctionData({ + abi: wethGatewayAbi, + functionName: 'repayETH', + args: [ + toAddress(pool.address), + value.toBigInt(), + borrowRateMode, + toAddress(opts.account), + ], + }), + }), + }, + ]; + } + + const entry = Decimal.from(amount); + const value = isEntireDebt ? Decimal.MAX_UINT_256 : entry; + const approval = await makeApprovalTransaction({ + spender: pool.address, + token: asset.address, + amount: value.toBigInt(), + account: toAddress(opts.account), + client: this.ctx.publicClient, + }); + + return [ + ...(approval + ? [ + { + id: 'approve_repay_asset__balance', + title: `Approve ${asset.symbol}`, + description: `Approve ${entry.toString()} ${asset.symbol} for repayment`, + request: approval, + }, + ] + : []), + { + id: 'repay_asset__balance', + title: `Repay ${asset.symbol}`, + description: `Repay ${entry.toString()} ${asset.symbol}`, + request: makeTransactionRequest({ + to: pool.address, + value: 0n, + chain: this.ctx.publicClient.chain, + account: opts.account, + data: encodeFunctionData({ + abi: poolAbi, + functionName: 'repay', + args: [ + toAddress(asset.address), + value.toBigInt(), + borrowRateMode, + toAddress(opts.account), + ], + }), + }), + }, + ]; + } } diff --git a/packages/shared/src/lib/http-client.ts b/packages/shared/src/lib/http-client.ts index ee296c0..e254d1a 100644 --- a/packages/shared/src/lib/http-client.ts +++ b/packages/shared/src/lib/http-client.ts @@ -12,6 +12,7 @@ export interface HttpRequestOptions { signal?: AbortSignal; headers?: Record; query?: Record; + revalidateCache?: boolean; body?: unknown; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; // per-call baseUrl override if needed: @@ -60,6 +61,11 @@ export class HttpClient { ? { authorization: `Bearer ${this.config.apiKey}` } : {}), ...(this.config.userAgent ? { 'user-agent': this.config.userAgent } : {}), + ...(opts.revalidateCache + ? { + 'x-cache-revalidate': '1', + } + : {}), ...(opts.headers ?? {}), }; From be0f10b0976140faa45e72fe37cda650e6e7854a Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Thu, 18 Dec 2025 12:05:11 +0200 Subject: [PATCH 2/6] feat: allow changing borrow rate mode --- apps/indexer/src/app/routes/_chain/routes.ts | 10 +- .../components/AssetsTable/AssetsTable.tsx | 112 ++++++++++-------- .../BorrowDialog/BorrowDialog.tsx | 15 ++- .../{ => Dialogs}/LendDialog/LendDialog.tsx | 4 +- .../{ => Dialogs}/RepayDialog/RepayDialog.tsx | 6 +- .../WithdrawDialog/WithdrawDialog.tsx | 6 +- .../components/AssetsTable/AssetsTable.tsx | 47 ++++++-- .../MoneyMarket/hooks/use-money-positions.ts | 2 +- .../hooks/use-internal-tx-handler.ts | 20 +++- .../src/components/ui/amount-renderer.tsx | 5 + apps/web-app/src/routes/money-market.tsx | 14 +-- .../money-market/money-market.manager.ts | 75 ++++++++++++ packages/sdk/src/types.ts | 2 + packages/shared/src/lib/decimal.ts | 4 + 14 files changed, 237 insertions(+), 85 deletions(-) rename apps/web-app/src/components/MoneyMarket/components/{ => Dialogs}/BorrowDialog/BorrowDialog.tsx (93%) rename apps/web-app/src/components/MoneyMarket/components/{ => Dialogs}/LendDialog/LendDialog.tsx (96%) rename apps/web-app/src/components/MoneyMarket/components/{ => Dialogs}/RepayDialog/RepayDialog.tsx (97%) rename apps/web-app/src/components/MoneyMarket/components/{ => Dialogs}/WithdrawDialog/WithdrawDialog.tsx (96%) diff --git a/apps/indexer/src/app/routes/_chain/routes.ts b/apps/indexer/src/app/routes/_chain/routes.ts index 4045dbf..88fa5f0 100644 --- a/apps/indexer/src/app/routes/_chain/routes.ts +++ b/apps/indexer/src/app/routes/_chain/routes.ts @@ -157,6 +157,7 @@ export default async function (fastify: ZodFastifyInstance) { .mul(100) .toFixed(USD_DECIMALS), canBeCollateral: item.usageAsCollateralEnabled, + stableBorrowRateEnabled: item.stableBorrowRateEnabled, isActive: item.isActive, isFroze: item.isFrozen, // eModes: item.eModes, @@ -376,6 +377,7 @@ export default async function (fastify: ZodFastifyInstance) { .mul(100) .toFixed(USD_DECIMALS), canBeCollateral: item.reserve.usageAsCollateralEnabled, + stableBorrowRateEnabled: item.reserve.stableBorrowRateEnabled, isActive: item.reserve.isActive, isFroze: item.reserve.isFrozen, // eModes: item.reserve.eModes, @@ -386,8 +388,12 @@ export default async function (fastify: ZodFastifyInstance) { supplyApy: Decimal.from(item.reserve.supplyAPY).mul(100).toString(), canToggleCollateral, - borrowed: item.variableBorrows, - borrowedUsd: item.variableBorrowsUSD, + borrowed: Decimal.from(item.variableBorrows) + .add(item.stableBorrows) + .toString(), + borrowedUsd: Decimal.from(item.variableBorrowsUSD) + .add(item.stableBorrowsUSD) + .toString(), collateral: item.usageAsCollateralEnabledOnUser, diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx index a5689ad..eccf329 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/components/AssetsTable/AssetsTable.tsx @@ -6,7 +6,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table/table'; -import { Fragment, useCallback, useMemo, useState, type FC } from 'react'; +import { Fragment, useCallback, useMemo, type FC } from 'react'; import { repayRequestStore } from '@/components/MoneyMarket/stores/repay-request.store'; import { AmountRenderer } from '@/components/ui/amount-renderer'; @@ -19,55 +19,68 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import type { MoneyMarketPoolPosition } from '@sovryn/slayer-sdk'; +import { revalidateQuery } from '@/integrations/tanstack-query/root-provider'; +import { sdk } from '@/lib/sdk'; +import { useSlayerTx } from '@/lib/transactions'; +import { + BORROW_RATE_MODES, + type BorrowRateMode, + type MoneyMarketPoolPosition, +} from '@sovryn/slayer-sdk'; import { Decimal } from '@sovryn/slayer-shared'; -import type { BorrowPosition } from '../../BorrowPositionsList.types'; +import { useAccount } from 'wagmi'; type AssetsTableProps = { assets: MoneyMarketPoolPosition[]; }; export const AssetsTable: FC = ({ assets }) => { + const { address } = useAccount(); const items = useMemo( () => assets.filter((a) => Decimal.from(a.borrowed).gt(0)), [assets], ); - const [selectedApy, setSelectedApy] = useState>({}); - - const parsePct = (v: unknown): number => { - if (typeof v === 'number') return v; - if (typeof v === 'string') { - const n = Number(v.replace('%', '').trim()); - return Number.isFinite(n) ? n : 0; - } - return 0; - }; - - const inferDefaultSelected = (asset: BorrowPosition): number => { - const candidates = (asset.apyType ?? []) - .map(Number) - .filter(Number.isFinite); - const active = parsePct(asset.apy); - if (candidates.includes(active)) return active; - return candidates.length ? candidates[0] : active; - }; - - const rowKey = useMemo( - () => (asset: BorrowPosition, idx: number) => - asset.poolId ?? asset.address ?? `${asset.symbol}-${idx}`, - [], - ); - - const currentApy = useCallback( - (a: BorrowPosition, idx: number) => - selectedApy[rowKey(a, idx)] ?? inferDefaultSelected(a), - [selectedApy, rowKey], - ); + const { begin } = useSlayerTx({ + onCompleted: () => + revalidateQuery({ + queryKey: [ + 'money-market:positions', + items[0]?.pool.id || 'default', + address, + ], + }), + }); const repayLoan = (position: MoneyMarketPoolPosition) => repayRequestStore.getState().setPosition(position); + const handleBorrowRateChange = useCallback( + (position: MoneyMarketPoolPosition, value: string) => { + const currentMode = position.borrowRateMode; + const selected = BigInt(value) as BorrowRateMode; + + if (currentMode === selected) { + return; + } + + return begin(() => + sdk.moneyMarket.swapBorrowRateMode( + { + ...position.reserve, + token: position.token, + pool: position.pool, + }, + currentMode, + { + account: address!, + }, + ), + ); + }, + [address, begin], + ); + return (
@@ -147,16 +160,16 @@ export const AssetsTable: FC = ({ assets }) => {
diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/BorrowDialog/BorrowDialog.tsx similarity index 93% rename from apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx rename to apps/web-app/src/components/MoneyMarket/components/Dialogs/BorrowDialog/BorrowDialog.tsx index e15e199..356b0e6 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/BorrowDialog/BorrowDialog.tsx @@ -28,9 +28,9 @@ import { useAccount } from 'wagmi'; import z from 'zod'; import { useStore } from 'zustand'; import { useStoreWithEqualityFn } from 'zustand/traditional'; -import { MINIMUM_HEALTH_FACTOR } from '../../constants'; -import { useMoneyMarketPositions } from '../../hooks/use-money-positions'; -import { borrowRequestStore } from '../../stores/borrow-request.store'; +import { MINIMUM_HEALTH_FACTOR } from '../../../constants'; +import { useMoneyMarketPositions } from '../../../hooks/use-money-positions'; +import { borrowRequestStore } from '../../../stores/borrow-request.store'; const BorrowDialogForm = () => { const { address } = useAccount(); @@ -92,7 +92,7 @@ const BorrowDialogForm = () => { sdk.moneyMarket.borrow( reserve, value.amount, - BORROW_RATE_MODES.variable, + data?.position.borrowRateMode ?? BORROW_RATE_MODES.variable, { account: address!, }, @@ -214,7 +214,12 @@ const BorrowDialogForm = () => { Borrow APY diff --git a/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/LendDialog/LendDialog.tsx similarity index 96% rename from apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx rename to apps/web-app/src/components/MoneyMarket/components/Dialogs/LendDialog/LendDialog.tsx index ae5103a..e2e94d6 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/LendDialog/LendDialog.tsx @@ -21,8 +21,8 @@ import { useAccount, useBalance } from 'wagmi'; import z from 'zod'; import { useStore } from 'zustand'; import { useStoreWithEqualityFn } from 'zustand/traditional'; -import { useMoneyMarketPositions } from '../../hooks/use-money-positions'; -import { lendRequestStore } from '../../stores/lend-request.store'; +import { useMoneyMarketPositions } from '../../../hooks/use-money-positions'; +import { lendRequestStore } from '../../../stores/lend-request.store'; const LendDialogForm = () => { const { address } = useAccount(); diff --git a/apps/web-app/src/components/MoneyMarket/components/RepayDialog/RepayDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx similarity index 97% rename from apps/web-app/src/components/MoneyMarket/components/RepayDialog/RepayDialog.tsx rename to apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx index 5361dad..634a56a 100644 --- a/apps/web-app/src/components/MoneyMarket/components/RepayDialog/RepayDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx @@ -28,9 +28,9 @@ import { useAccount, useBalance } from 'wagmi'; import z from 'zod'; import { useStore } from 'zustand'; import { useStoreWithEqualityFn } from 'zustand/traditional'; -import { MINIMUM_HEALTH_FACTOR } from '../../constants'; -import { useMoneyMarketPositions } from '../../hooks/use-money-positions'; -import { repayRequestStore } from '../../stores/repay-request.store'; +import { MINIMUM_HEALTH_FACTOR } from '../../../constants'; +import { useMoneyMarketPositions } from '../../../hooks/use-money-positions'; +import { repayRequestStore } from '../../../stores/repay-request.store'; const RepayDialogForm = () => { const { address } = useAccount(); diff --git a/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/WithdrawDialog/WithdrawDialog.tsx similarity index 96% rename from apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx rename to apps/web-app/src/components/MoneyMarket/components/Dialogs/WithdrawDialog/WithdrawDialog.tsx index 491f34b..8425be6 100644 --- a/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/WithdrawDialog/WithdrawDialog.tsx @@ -22,9 +22,9 @@ import { useAccount } from 'wagmi'; import z from 'zod'; import { useStore } from 'zustand'; import { useStoreWithEqualityFn } from 'zustand/traditional'; -import { MINIMUM_HEALTH_FACTOR } from '../../constants'; -import { useMoneyMarketPositions } from '../../hooks/use-money-positions'; -import { withdrawRequestStore } from '../../stores/withdraw-request.store'; +import { MINIMUM_HEALTH_FACTOR } from '../../../constants'; +import { useMoneyMarketPositions } from '../../../hooks/use-money-positions'; +import { withdrawRequestStore } from '../../../stores/withdraw-request.store'; const WithdrawDialogForm = () => { const { address } = useAccount(); diff --git a/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx index bef78f6..4db046f 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx @@ -13,28 +13,52 @@ import { AmountRenderer } from '@/components/ui/amount-renderer'; import { Button } from '@/components/ui/button'; import { InfoButton } from '@/components/ui/info-button'; import { Switch } from '@/components/ui/switch'; +import { revalidateQuery } from '@/integrations/tanstack-query/root-provider'; +import { sdk } from '@/lib/sdk'; +import { useSlayerTx } from '@/lib/transactions'; import type { MoneyMarketPoolPosition } from '@sovryn/slayer-sdk'; import { Decimal } from '@sovryn/slayer-shared'; +import { useAccount } from 'wagmi'; type AssetsTableProps = { assets: MoneyMarketPoolPosition[]; }; export const AssetsTable: FC = ({ assets }) => { + const { address } = useAccount(); + const items = useMemo( () => assets.filter((asset) => Decimal.from(asset.supplied).gt(0)), [assets], ); + const { begin } = useSlayerTx({ + onCompleted: () => + revalidateQuery({ + queryKey: [ + 'money-market:positions', + items[0]?.pool.id || 'default', + address, + ], + }), + }); - const toggleCollateral = useCallback((symbol: string) => { - // setSortedAssets((prevAssets) => - // prevAssets.map((asset) => - // asset.symbol === symbol - // ? { ...asset, collateral: !asset.collateral } - // : asset, - // ), - // ); - }, []); + const toggleCollateral = useCallback( + (position: MoneyMarketPoolPosition, useAsCollateral: boolean) => + begin(() => + sdk.moneyMarket.changeCollateralMode( + { + ...position.reserve, + token: position.token, + pool: position.pool, + }, + useAsCollateral, + { + account: address!, + }, + ), + ), + [address, begin], + ); const withdrawSupply = (position: MoneyMarketPoolPosition) => withdrawRequestStore.getState().setPosition(position); @@ -116,8 +140,9 @@ export const AssetsTable: FC = ({ assets }) => { className="cursor-pointer data-[state=checked]:bg-primary" checked={item.collateral} id={`collateral-${item.token.address}`} - onClick={() => toggleCollateral(item.id)} - // disabled={!asset} + onCheckedChange={(checked) => + toggleCollateral(item, checked) + } />
diff --git a/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts index 7cbccdd..9e5e213 100644 --- a/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts +++ b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts @@ -3,7 +3,7 @@ import { sdk } from '@/lib/sdk'; import { useQuery } from '@tanstack/react-query'; import type { Address } from 'viem'; -export const STALE_TIME = 1000 * 60 * 60; // 1 hour +export const STALE_TIME = 1000 * 60; // 1 minute export const QUERY_KEY_MONEY_MARKET_POSITIONS = 'money-market:positions'; export const useMoneyMarketPositions = ({ diff --git a/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts b/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts index 27ac6d8..05f0346 100644 --- a/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts +++ b/apps/web-app/src/components/TransactionDialog/hooks/use-internal-tx-handler.ts @@ -8,6 +8,7 @@ import { isTransactionRequest, isTypedDataRequest, } from '@sovryn/slayer-sdk'; +import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { prepareTransactionRequest } from 'viem/actions'; import { @@ -21,6 +22,9 @@ import { useStore } from 'zustand'; import { useStoreWithEqualityFn } from 'zustand/traditional'; import { handleErrorMessage } from '../utils'; +const log = debug('slayer-app:use-internal-tx-handler'); +const logError = debug('slayer-app:use-internal-tx-handler:error'); + export function useInternalTxHandler( props: Pick = {}, ) { @@ -44,6 +48,7 @@ export function useInternalTxHandler( mutation: { onError(error) { if (!currentTx) return; + logError('Sign message error:', error); const msg = handleErrorMessage(error); setItemError(currentTx.id, msg); props.onError?.(currentTx!, msg, error); @@ -51,6 +56,7 @@ export function useInternalTxHandler( }, onSuccess(data) { if (!currentTx) return; + log('Message signed successfully:', data); updateItem(currentTx.id, TRANSACTION_STATE.success, { transactionHash: data, }); @@ -68,6 +74,7 @@ export function useInternalTxHandler( mutation: { onError(error) { if (!currentTx) return; + logError('Sign typed data error:', error); const msg = handleErrorMessage(error); setItemError(currentTx?.id || '', msg); props.onError?.(currentTx!, msg, error); @@ -75,6 +82,7 @@ export function useInternalTxHandler( }, onSuccess(data) { if (!currentTx) return; + log('Typed data signed successfully:', data); updateItem(currentTx?.id || '', TRANSACTION_STATE.success, { transactionHash: data, }); @@ -97,6 +105,7 @@ export function useInternalTxHandler( onSettled(data, error) { if (!currentTx) return; if (data) { + log('Transaction sent successfully:', data); updateItem(currentTx.id, TRANSACTION_STATE.pending, { transactionHash: data, }); @@ -106,7 +115,7 @@ export function useInternalTxHandler( pendingTxs, ); } else if (error) { - console.log('Send transaction error:', error); + logError('Send transaction error:', error); const msg = handleErrorMessage(error); setItemError(currentTx.id, msg); props.onError?.(currentTx, msg, error); @@ -134,6 +143,7 @@ export function useInternalTxHandler( onReplaced: (tx) => { if (!currentTx) return; if (tx.reason === 'cancelled') { + log('Transaction was cancelled by the user:', tx); updateItemState(currentTx.id, TRANSACTION_STATE.idle); props.onError?.( currentTx, @@ -160,9 +170,11 @@ export function useInternalTxHandler( useEffect(() => { if (!currentTx || !receipt) return; if (receiptStatus === 'success') { + log('Transaction completed successfully:', receipt); updateItem(currentTx.id, TRANSACTION_STATE.success, receipt); handlers.onSuccess?.(currentTx, receipt); } else if (receiptStatus === 'error') { + logError('Transaction failed with status:', receipt.status); updateItem(currentTx.id, TRANSACTION_STATE.error, receipt); setItemError( currentTx.id, @@ -184,6 +196,7 @@ export function useInternalTxHandler( const handleConfirm = useCallback(async () => { if (!currentTx) return; try { + log('Confirming transaction:', currentTx); setIsPreparing(true); updateItemState(currentTx.id, TRANSACTION_STATE.pending); @@ -193,10 +206,13 @@ export function useInternalTxHandler( currentTx.request.data; if (isMessageRequest(currentTx)) { + log('Signing message request', currentTx); signMessage(modifiedData); } else if (isTypedDataRequest(currentTx)) { + log('Signing typed data request', currentTx); signTypedData(modifiedData); } else if (isTransactionRequest(currentTx)) { + log('Preparing transaction request', currentTx); const prepared = await prepareTransactionRequest( config.getClient(), modifiedData, @@ -204,9 +220,11 @@ export function useInternalTxHandler( sendTransaction(prepared); } else { + logError('Unknown transaction request type', currentTx); throw new Error('Unknown transaction request type'); } } catch (e) { + logError('Error during transaction confirmation:', e); const msg = handleErrorMessage(e); setItemError(currentTx.id, msg); props.onError?.(currentTx, msg, e); diff --git a/apps/web-app/src/components/ui/amount-renderer.tsx b/apps/web-app/src/components/ui/amount-renderer.tsx index fff661b..36c3122 100644 --- a/apps/web-app/src/components/ui/amount-renderer.tsx +++ b/apps/web-app/src/components/ui/amount-renderer.tsx @@ -17,6 +17,11 @@ export type AmountRendererProps = { function formatAmount(value: string | number | bigint, decimals = 8) { try { const dec = Decimal.from(value); + + if (dec.isInfinite()) { + return '∞'; + } + let str = dec.d.toFixed(decimals).replace(/\.?(0+)$/, ''); // if the value is very small, try with more decimals if (str === '' || str === '0') { diff --git a/apps/web-app/src/routes/money-market.tsx b/apps/web-app/src/routes/money-market.tsx index 7af03cf..48bc472 100644 --- a/apps/web-app/src/routes/money-market.tsx +++ b/apps/web-app/src/routes/money-market.tsx @@ -4,12 +4,12 @@ import { LendPositionsList } from '@/components/MoneyMarket/components/LendPosit import { TopPanel } from '@/components/MoneyMarket/components/TopPanel/TopPanel'; import { BorrowAssetsList } from '@/components/MoneyMarket/components/BorrowAssetsList/BorrowAssetsList'; -import { BorrowDialog } from '@/components/MoneyMarket/components/BorrowDialog/BorrowDialog'; import { BorrowPositionsList } from '@/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList'; +import { BorrowDialog } from '@/components/MoneyMarket/components/Dialogs/BorrowDialog/BorrowDialog'; +import { LendDialog } from '@/components/MoneyMarket/components/Dialogs/LendDialog/LendDialog'; +import { RepayDialog } from '@/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog'; +import { WithdrawDialog } from '@/components/MoneyMarket/components/Dialogs/WithdrawDialog/WithdrawDialog'; import { LendAssetsList } from '@/components/MoneyMarket/components/LendAssetsList/LendAssetsList'; -import { LendDialog } from '@/components/MoneyMarket/components/LendDialog/LendDialog'; -import { RepayDialog } from '@/components/MoneyMarket/components/RepayDialog/RepayDialog'; -import { WithdrawDialog } from '@/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog'; import { QUERY_KEY_MONEY_MARKET_POSITIONS, useMoneyMarketPositions, @@ -69,12 +69,6 @@ function RouteComponent() { const { pool } = Route.useLoaderDeps(); const { address } = useAccount(); - // const { data: pools } = useQuery({ - // queryKey: ['money-market:pools'], - // queryFn: () => sdk.moneyMarket.listPools(), - // staleTime: 1000 * 60 * 60, // 1 hour - // }); - const { data: reserves } = useQuery({ queryKey: ['money-market:reserves', pool || 'default'], queryFn: ({ meta }) => diff --git a/packages/sdk/src/managers/money-market/money-market.manager.ts b/packages/sdk/src/managers/money-market/money-market.manager.ts index fff8fea..5cbae9b 100644 --- a/packages/sdk/src/managers/money-market/money-market.manager.ts +++ b/packages/sdk/src/managers/money-market/money-market.manager.ts @@ -9,6 +9,7 @@ import { makeTransactionRequest, } from '../../lib/transaction.js'; import { + BORROW_RATE_MODES, BorrowRateMode, MoneyMarketPool, MoneyMarketPoolPosition, @@ -275,6 +276,41 @@ export class MoneyMarketManager extends BaseClient { ]; } + async swapBorrowRateMode( + reserve: MoneyMarketPoolReserve, + currentRateMode: BorrowRateMode, + opts: TransactionOpts, + ) { + const asset = reserve.token; + const pool = reserve.pool; + log( + `Swapping borrow rate mode for ${asset.symbol} in pool ${pool.id} from ${currentRateMode}`, + { reserve, currentRateMode, opts }, + ); + + const newRateMode = + currentRateMode === BORROW_RATE_MODES.stable ? 'Variable' : 'Stable'; + + return [ + { + id: 'swap_borrow_rate_mode', + title: `Swap borrow rate mode for ${asset.symbol}`, + description: `Swap borrow rate mode for ${asset.symbol} to ${newRateMode}`, + request: makeTransactionRequest({ + to: pool.address, + value: 0n, + chain: this.ctx.publicClient.chain, + account: opts.account, + data: encodeFunctionData({ + abi: poolAbi, + functionName: 'swapBorrowRateMode', + args: [toAddress(asset.address), currentRateMode], + }), + }), + }, + ]; + } + async supply( reserve: MoneyMarketPoolReserve, amount: Decimalish, @@ -348,6 +384,45 @@ export class MoneyMarketManager extends BaseClient { ]; } + async changeCollateralMode( + reserve: MoneyMarketPoolReserve, + useAsCollateral: boolean, + opts: TransactionOpts, + ) { + const asset = reserve.token; + const pool = reserve.pool; + + log( + `Switching collateral for ${asset.symbol} in pool ${pool.id} to ${useAsCollateral}`, + { reserve, useAsCollateral, opts }, + ); + + const tokenAddress = toAddress( + asset.isNative || areAddressesEqual(asset.address, pool.weth) + ? pool.weth + : asset.address, + ); + + return [ + { + id: 'switch_collateral', + title: `${useAsCollateral ? 'Enable' : 'Disable'} ${asset.symbol} as collateral`, + description: `${useAsCollateral ? 'Enable' : 'Disable'} ${asset.symbol} as collateral in pool ${pool.id}`, + request: makeTransactionRequest({ + to: pool.address, + value: 0n, + chain: this.ctx.publicClient.chain, + account: opts.account, + data: encodeFunctionData({ + abi: poolAbi, + functionName: 'setUserUseReserveAsCollateral', + args: [tokenAddress, useAsCollateral], + }), + }), + }, + ]; + } + async withdraw( reserve: MoneyMarketPoolReserve, amount: Decimalish, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index bc3a9a3..479249b 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -50,6 +50,8 @@ export interface MoneyMarketPoolReserve { canBeBorrowed: boolean; canBeCollateral: boolean; + stableBorrowRateEnabled: boolean; + isActive: boolean; isFrozen: boolean; } diff --git a/packages/shared/src/lib/decimal.ts b/packages/shared/src/lib/decimal.ts index 8715b40..4901f2e 100644 --- a/packages/shared/src/lib/decimal.ts +++ b/packages/shared/src/lib/decimal.ts @@ -188,6 +188,10 @@ export class Decimal { return this.d.isNegative(); } + isInfinite(): boolean { + return this.gte(Decimal.INFINITY, 0); + } + abs(): Decimal { return Decimal.from(this.d.abs().toString(), this.precision); } From d98117fa117f613d7ea80347621135dd39ee7724 Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Thu, 18 Dec 2025 15:52:13 +0200 Subject: [PATCH 3/6] wip: emode changing --- apps/indexer/src/app/routes/_chain/routes.ts | 36 +- apps/indexer/src/libs/loaders/money-market.ts | 98 ++++++ .../BorrowPositionsList.tsx | 21 +- .../EfficiencyModeDialog.tsx | 329 ++++++++++++++++++ .../MoneyMarket/hooks/use-money-positions.ts | 14 +- .../MoneyMarket/hooks/use-money-reserves.ts | 26 ++ .../stores/efficiency-mode-request.store.ts | 26 ++ apps/web-app/src/routes/money-market.tsx | 25 +- .../money-market/money-market.manager.ts | 13 +- packages/sdk/src/types.ts | 23 ++ 10 files changed, 585 insertions(+), 26 deletions(-) create mode 100644 apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx create mode 100644 apps/web-app/src/components/MoneyMarket/hooks/use-money-reserves.ts create mode 100644 apps/web-app/src/components/MoneyMarket/stores/efficiency-mode-request.store.ts diff --git a/apps/indexer/src/app/routes/_chain/routes.ts b/apps/indexer/src/app/routes/_chain/routes.ts index 88fa5f0..ced71c1 100644 --- a/apps/indexer/src/app/routes/_chain/routes.ts +++ b/apps/indexer/src/app/routes/_chain/routes.ts @@ -11,6 +11,7 @@ import { client } from '../../../database/client'; import { tTokens } from '../../../database/schema'; import { tTokensSelectors } from '../../../database/selectors'; import { + fetchEmodeCategoryData, fetchPoolList, fetchPoolReserves, fetchUserReserves, @@ -112,6 +113,12 @@ export default async function (fastify: ZodFastifyInstance) { pool, ); + const categories = await fetchEmodeCategoryData( + req.chain.chainId, + pool, + reservesData, + ); + const tokens = await client.query.tTokens.findMany({ columns: tTokensSelectors.columns, where: and( @@ -160,11 +167,26 @@ export default async function (fastify: ZodFastifyInstance) { stableBorrowRateEnabled: item.stableBorrowRateEnabled, isActive: item.isActive, isFroze: item.isFrozen, - // eModes: item.eModes, + + eModeCategoryId: item.eModeCategoryId, + borrowCap: item.borrowCap.toString(), + supplyCap: item.supplyCap.toString(), + eModeLtv: item.eModeLtv, + eModeLiquidationThreshold: item.eModeLiquidationThreshold, + eModeLiquidationBonus: item.eModeLiquidationBonus, + eModePriceSource: item.eModePriceSource.toString(), + eModeLabel: item.eModeLabel.toString(), }; }); - return { data: { reservesData: items, baseCurrencyData } }; + const eModes = categories.map((category) => ({ + ...category, + assets: category.assets.map((asset) => { + return tokens.find((t) => areAddressesEqual(t.address, asset)); + }), + })); + + return { data: { reservesData: items, baseCurrencyData, eModes } }; }, ); @@ -380,7 +402,15 @@ export default async function (fastify: ZodFastifyInstance) { stableBorrowRateEnabled: item.reserve.stableBorrowRateEnabled, isActive: item.reserve.isActive, isFroze: item.reserve.isFrozen, - // eModes: item.reserve.eModes, + + eModeCategoryId: item.reserve.eModeCategoryId, + borrowCap: item.reserve.borrowCap.toString(), + supplyCap: item.reserve.supplyCap.toString(), + eModeLtv: item.reserve.eModeLtv, + eModeLiquidationThreshold: item.reserve.eModeLiquidationThreshold, + eModeLiquidationBonus: item.reserve.eModeLiquidationBonus, + eModePriceSource: item.reserve.eModePriceSource.toString(), + eModeLabel: item.reserve.eModeLabel.toString(), }, supplied: item.underlyingBalance, suppliedUsd: item.underlyingBalanceUSD, diff --git a/apps/indexer/src/libs/loaders/money-market.ts b/apps/indexer/src/libs/loaders/money-market.ts index 427a50b..dcb5b14 100644 --- a/apps/indexer/src/libs/loaders/money-market.ts +++ b/apps/indexer/src/libs/loaders/money-market.ts @@ -1,3 +1,4 @@ +import { Decimal } from '@sovryn/slayer-shared'; import { Address } from 'viem'; import { maybeCache } from '../../app/plugins/cache'; import { ChainId, chains, ChainSelector } from '../../configs/chains'; @@ -411,6 +412,55 @@ const uiPoolDataProviderAbi = [ }, ] as const; +const poolAbi = [ + { + inputs: [ + { + internalType: 'uint8', + name: 'id', + type: 'uint8', + }, + ], + name: 'getEModeCategoryData', + outputs: [ + { + components: [ + { + internalType: 'uint16', + name: 'ltv', + type: 'uint16', + }, + { + internalType: 'uint16', + name: 'liquidationThreshold', + type: 'uint16', + }, + { + internalType: 'uint16', + name: 'liquidationBonus', + type: 'uint16', + }, + { + internalType: 'address', + name: 'priceSource', + type: 'address', + }, + { + internalType: 'string', + name: 'label', + type: 'string', + }, + ], + internalType: 'struct DataTypes.EModeCategory', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + export type PoolDefinition = { id: string | 'default'; name: string; @@ -700,3 +750,51 @@ export async function fetchUserReserves( userEmodeCategoryId, }; } + +export async function fetchEmodeCategoryData( + chainId: ChainSelector, + pool: PoolDefinition, + reserves: Awaited>['reservesData'], +) { + const chain = chains.get(chainId); + if (!chain) { + throw new Error(`Unsupported chain: ${chainId}`); + } + + const categoryIds = Array.from( + new Set(reserves.map((reserve) => reserve.eModeCategoryId)), + ).filter((id) => id !== 0); + + const results = await chain.rpc.multicall({ + contracts: categoryIds.map((id) => ({ + address: pool.address, + abi: poolAbi, + functionName: 'getEModeCategoryData', + args: [id], + })), + }); + + return results + .map(({ result }, index) => { + if (!result) { + return null; + } + + const { ltv, liquidationThreshold, liquidationBonus, label } = result; + const categoryId = categoryIds[index]; + + return { + id: categoryId, + ltv: Decimal.from(ltv).div(100).toString(), + liquidationThreshold: Decimal.from(liquidationThreshold) + .div(100) + .toString(), + liquidationBonus: Decimal.from(liquidationBonus).div(100).toString(), + label, + assets: reserves + .filter((reserve) => reserve.eModeCategoryId === categoryId) + .map((reserve) => reserve.underlyingAsset.toLowerCase()), + }; + }) + .filter((item) => item != null); +} diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList.tsx index 816c49d..96db416 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList.tsx @@ -1,8 +1,10 @@ import { Accordion } from '@/components/ui/accordion'; +import { buttonVariants } from '@/components/ui/button'; import type { MoneyMarketPoolPosition } from '@sovryn/slayer-sdk'; import { Settings, Zap } from 'lucide-react'; import { useState, type FC } from 'react'; import { AmountRenderer } from '../../../ui/amount-renderer'; +import { efficiencyModeRequestStore } from '../../stores/efficiency-mode-request.store'; import { PoolPositionStat } from '../PoolPositionStat/PoolPositionStat'; import { AssetsTable } from './components/AssetsTable/AssetsTable'; @@ -12,6 +14,7 @@ type BorrowPositionsListProps = { borrowPower: string; borrowPositions: MoneyMarketPoolPosition[]; loading?: boolean; + eModesCategoryId?: number; }; export const BorrowPositionsList: FC = ({ @@ -19,21 +22,31 @@ export const BorrowPositionsList: FC = ({ borrowWeightedApy, borrowPower, borrowPositions, + eModesCategoryId, }) => { const [open, setOpen] = useState(false); + const handleEmodeToggle = (e: React.MouseEvent) => { + e.stopPropagation(); + efficiencyModeRequestStore.getState().setActive(true); + }; + return ( Your loans -
+
E-Mode -
-
- Disabled + {eModesCategoryId === 0 ? 'Disabled' : 'Enabled'}
diff --git a/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx new file mode 100644 index 0000000..15d34fa --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx @@ -0,0 +1,329 @@ +import { useMoneyMarketReserves } from '@/components/MoneyMarket/hooks/use-money-reserves'; +import { efficiencyModeRequestStore } from '@/components/MoneyMarket/stores/efficiency-mode-request.store'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useAppForm } from '@/hooks/app-form'; +import { useSlayerTx } from '@/lib/transactions'; +import type { MoneyMarketUserSummary } from '@sovryn/slayer-sdk'; +import { Decimal } from '@sovryn/slayer-shared'; +import { useLoaderDeps } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import { useAccount } from 'wagmi'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; +import { useMoneyMarketPositions } from '../../../hooks/use-money-positions'; +import { borrowRequestStore } from '../../../stores/borrow-request.store'; + +const normalizeEmodeSummary = ( + summary: MoneyMarketUserSummary, + categoryId: number, +) => { + if (!summary) { + return { + ltv: Decimal.ZERO, + collateralRatio: Decimal.INFINITY, + liquidationRisk: false, + }; + } + + const healthFactor = Decimal.from(summary.healthFactor); + const liquidationRisk = healthFactor.lte(1) && healthFactor.gt(0); + + const borrowed = Decimal.from(summary.totalBorrowsUsd); + const collateralRatio = borrowed.eq(0) + ? Decimal.INFINITY + : Decimal.from(summary.totalCollateralUsd).div(borrowed); + + return { + ltv: Decimal.from(summary.currentLoanToValue).mul(100), + collateralRatio, + liquidationRisk, + }; +}; + +const EfficiencyModeDialogForm = () => { + const { pool } = useLoaderDeps({ from: '/money-market' }); + const { address } = useAccount(); + + const { eModes } = useMoneyMarketReserves({ + pool: pool || 'default', + }); + + const { summary } = useMoneyMarketPositions({ + pool: pool || 'default', + address: address!, + }); + + const currentCategoryId = useMemo( + () => summary?.userEmodeCategoryId ?? 0, + [summary], + ); + const currentCategory = useMemo( + () => eModes.find((c) => c.id === currentCategoryId), + [eModes, currentCategoryId], + ); + + const emodeSummary = normalizeEmodeSummary(summary!, currentCategoryId); + const emodeThen = normalizeEmodeSummary(summary!, 0); + + const { begin } = useSlayerTx({ + onClosed: (ok: boolean) => { + console.log('borrow tx modal closed, success:', ok); + if (ok) { + // close borrowing dialog if tx was successful + borrowRequestStore.getState().reset(); + } + }, + onCompleted: () => { + // revalidateQuery({ + // queryKey: [ + // 'money-market:positions', + // reserve.pool.id || 'default', + // address, + // ], + // }); + }, + }); + + const form = useAppForm({ + defaultValues: { + mode: currentCategoryId, + }, + onSubmit: ({ value }) => { + // begin(() => + // sdk.moneyMarket.borrow( + // reserve, + // value.amount, + // data?.position.borrowRateMode ?? BORROW_RATE_MODES.variable, + // { + // account: address!, + // }, + // ), + // ); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }; + + // const handleEscapes = (e: Event) => { + // e.preventDefault(); + // }; + + // const calculateLiquidationPrice = useCallback( + // (amount: string) => { + // if (!data || Decimal.from(data.summary.collateralBalanceUsd).eq(0)) { + // return Decimal.INFINITY; + // } + + // return Decimal.from( + // Decimal.from(amount || '0').mul(data.position.reserve.priceUsd), + // ) + // .mul(data.summary.currentLiquidationThreshold) + // .div(data.summary.collateralBalanceUsd); + // }, + // [data], + // ); + + // const computeHealthFactor = useCallback( + // (amount: string) => { + // if (!data || Decimal.from(data.summary.totalBorrowsUsd).eq(0)) { + // return Decimal.INFINITY; + // } + + // return Decimal.from(data.summary.collateralBalanceUsd) + // .mul(data.summary.currentLiquidationThreshold) + // .div( + // Decimal.from(data.summary.totalBorrowsUsd).add( + // Decimal.from(amount || '0').mul(data.position.reserve.priceUsd), + // ), + // ); + // }, + // [data], + // ); + + return ( +
+ + + Efficiency Mode + emode: {pool} + + {summary?.userEmodeCategoryId} // {currentCategoryId} -{' '} + {currentCategory?.label || 'None'} +

ltv: {emodeSummary.ltv.toString()}

+

collateralRatio: {emodeSummary.collateralRatio.toString()}

+

liquidationRisk: {emodeSummary.liquidationRisk.toString()}

+

--- Previous ---

+

ltv: {emodeThen.ltv.toString()}

+

collateralRatio: {emodeThen.collateralRatio.toString()}

+

liquidationRisk: {emodeThen.liquidationRisk.toString()}

+ + + + + + + + +
+ + ); + + // return ( + //
+ // e.preventDefault()} + // > + // + // Borrow Asset + // + // Borrowing functionality is under development. + // + // + // + // {(field) => ( + // <> + // + // + // )} + // + + // + // [ + // state.values.amount, + // computeHealthFactor(state.values.amount ?? 0), + // ] as const + // } + // > + // {([amount, healthFactor]) => ( + // + // + // + // + //
+ //
Collateral Ratio
+ // + //
+ //
+ // + // + // + //
+ //
+ // + // Borrow APY + // + // + // + // + // + // Liquidation price + // + // + // + // + // + // {data?.position.token.symbol} Price + // + // + // + // + //
+ // )} + //
+ + // + // {(field) => ( + // + // )} + // + + // + // + // + // + // + // + // + // + //
+ // + // ); +}; + +export const EfficiencyModeDialog = () => { + const isOpen = useStoreWithEqualityFn( + efficiencyModeRequestStore, + (state) => state.active, + ); + + const handleClose = (open: boolean) => { + if (!open) { + efficiencyModeRequestStore.getState().reset(); + } + }; + + return ( + + {isOpen && } + + ); +}; diff --git a/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts index 9e5e213..5c378fe 100644 --- a/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts +++ b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts @@ -12,9 +12,9 @@ export const useMoneyMarketPositions = ({ }: { address: Address; pool: string; -}) => - useQuery({ - queryKey: ['money-market:positions', pool, address], +}) => { + const { data, ...etc } = useQuery({ + queryKey: [QUERY_KEY_MONEY_MARKET_POSITIONS, pool, address], queryFn: ({ queryKey }) => sdk.moneyMarket.listUserPositions( pool, @@ -24,3 +24,11 @@ export const useMoneyMarketPositions = ({ staleTime: STALE_TIME, enabled: !!address && !!pool, }); + + return { + ...etc, + data, + positions: data?.data.positions || [], + summary: data?.data.summary, + }; +}; diff --git a/apps/web-app/src/components/MoneyMarket/hooks/use-money-reserves.ts b/apps/web-app/src/components/MoneyMarket/hooks/use-money-reserves.ts new file mode 100644 index 0000000..71c8c16 --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/hooks/use-money-reserves.ts @@ -0,0 +1,26 @@ +import { shouldRevalidateQuery } from '@/integrations/tanstack-query/root-provider'; +import { sdk } from '@/lib/sdk'; +import { useQuery } from '@tanstack/react-query'; + +export const STALE_TIME = 1000 * 60 * 60; // 1 hour +export const QUERY_KEY_MONEY_MARKET_RESERVES = 'money-market:reserves'; + +export const useMoneyMarketReserves = ({ pool }: { pool: string }) => { + const { data, ...rest } = useQuery({ + queryKey: [QUERY_KEY_MONEY_MARKET_RESERVES, pool || 'default'], + queryFn: ({ queryKey }) => + sdk.moneyMarket.listReserves( + pool || 'default', + shouldRevalidateQuery(queryKey), + ), + staleTime: STALE_TIME, + }); + + return { + ...rest, + data, + reserves: data?.data.reservesData || [], + eModes: data?.data.eModes || [], + baseCurrencyData: data?.data.baseCurrencyData, + }; +}; diff --git a/apps/web-app/src/components/MoneyMarket/stores/efficiency-mode-request.store.ts b/apps/web-app/src/components/MoneyMarket/stores/efficiency-mode-request.store.ts new file mode 100644 index 0000000..a13a246 --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/stores/efficiency-mode-request.store.ts @@ -0,0 +1,26 @@ +import { createStore } from 'zustand'; +import { combine } from 'zustand/middleware'; + +type State = { + active: boolean; +}; + +type Actions = { + setActive: (value: boolean) => void; + reset: () => void; +}; + +type EfficiencyModeRequestStore = State & Actions; + +export const efficiencyModeRequestStore = + createStore( + combine( + { + active: false, + }, + (set) => ({ + setActive: (value: boolean) => set({ active: value }), + reset: () => set({ active: false }), + }), + ), + ); diff --git a/apps/web-app/src/routes/money-market.tsx b/apps/web-app/src/routes/money-market.tsx index 48bc472..3a48c55 100644 --- a/apps/web-app/src/routes/money-market.tsx +++ b/apps/web-app/src/routes/money-market.tsx @@ -6,6 +6,7 @@ import { TopPanel } from '@/components/MoneyMarket/components/TopPanel/TopPanel' import { BorrowAssetsList } from '@/components/MoneyMarket/components/BorrowAssetsList/BorrowAssetsList'; import { BorrowPositionsList } from '@/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList'; import { BorrowDialog } from '@/components/MoneyMarket/components/Dialogs/BorrowDialog/BorrowDialog'; +import { EfficiencyModeDialog } from '@/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog'; import { LendDialog } from '@/components/MoneyMarket/components/Dialogs/LendDialog/LendDialog'; import { RepayDialog } from '@/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog'; import { WithdrawDialog } from '@/components/MoneyMarket/components/Dialogs/WithdrawDialog/WithdrawDialog'; @@ -14,9 +15,12 @@ import { QUERY_KEY_MONEY_MARKET_POSITIONS, useMoneyMarketPositions, } from '@/components/MoneyMarket/hooks/use-money-positions'; +import { + QUERY_KEY_MONEY_MARKET_RESERVES, + useMoneyMarketReserves, +} from '@/components/MoneyMarket/hooks/use-money-reserves'; import { Heading } from '@/components/ui/heading/heading'; import { sdk } from '@/lib/sdk'; -import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; import { useAccount } from 'wagmi'; import z from 'zod'; @@ -48,7 +52,7 @@ export const Route = createFileRoute('/money-market')({ }); client.prefetchQuery({ - queryKey: ['money-market:reserves', pool || 'default'], + queryKey: [QUERY_KEY_MONEY_MARKET_RESERVES, pool || 'default'], queryFn: () => sdk.moneyMarket.listReserves(pool || 'default'), staleTime: STALE_TIME, }); @@ -69,13 +73,8 @@ function RouteComponent() { const { pool } = Route.useLoaderDeps(); const { address } = useAccount(); - const { data: reserves } = useQuery({ - queryKey: ['money-market:reserves', pool || 'default'], - queryFn: ({ meta }) => - sdk.moneyMarket.listReserves(pool || 'default', { - revalidateCache: meta?.revalidateCache ?? false, - }), - staleTime: STALE_TIME, + const { reserves } = useMoneyMarketReserves({ + pool: pool || 'default', }); const { data: positions, isPending } = useMoneyMarketPositions({ @@ -84,7 +83,7 @@ function RouteComponent() { }); const borrowAssets = useMemo( - () => (reserves?.data ?? []).filter((r) => r.canBeBorrowed), + () => reserves.filter((r) => r.canBeBorrowed), [reserves], ); @@ -117,7 +116,7 @@ function RouteComponent() { positions?.data?.summary?.supplyWeightedApy ?? '0' } /> - +
@@ -136,6 +138,7 @@ function RouteComponent() { + ); } diff --git a/packages/sdk/src/managers/money-market/money-market.manager.ts b/packages/sdk/src/managers/money-market/money-market.manager.ts index 5cbae9b..b213aa2 100644 --- a/packages/sdk/src/managers/money-market/money-market.manager.ts +++ b/packages/sdk/src/managers/money-market/money-market.manager.ts @@ -11,7 +11,9 @@ import { import { BORROW_RATE_MODES, BorrowRateMode, + MoneyMarketBaseCurrencyData, MoneyMarketPool, + MoneyMarketPoolEmodeCategory, MoneyMarketPoolPosition, MoneyMarketPoolReserve, MoneyMarketUserSummary, @@ -174,15 +176,16 @@ export class MoneyMarketManager extends BaseClient { opts: SdkRequestOptions = {}, ) { const response = await this.ctx.http.request<{ - data: { reservesData: MoneyMarketPoolReserve[] }; + data: { + reservesData: MoneyMarketPoolReserve[]; + baseCurrencyData: MoneyMarketBaseCurrencyData; + eModes: MoneyMarketPoolEmodeCategory[]; + }; }>(`/${this.ctx.chainId}/money-market/${pool}/reserves`, { ...opts, query: buildQuery(opts.query), }); - return { - ...response, - data: response.data.reservesData, - }; + return response; } async listUserPositions( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 479249b..c0f3a46 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -54,6 +54,22 @@ export interface MoneyMarketPoolReserve { isActive: boolean; isFrozen: boolean; + + eModeCategoryId: number; + eModeLtv: number; + eModeLiquidationThreshold: number; + eModeLiquidationBonus: number; + eModePriceSource: string; + eModeLabel: string; +} + +export interface MoneyMarketPoolEmodeCategory { + id: number; + ltv: string; + liquidationThreshold: string; + liquidationBonus: string; + label: string; + assets: SdkToken[]; } export interface MoneyMarketPool { @@ -71,6 +87,13 @@ export interface MoneyMarketPool { priceFeedURI: string; } +export interface MoneyMarketBaseCurrencyData { + marketReferenceCurrencyDecimals: number; + marketReferenceCurrencyPriceInUsd: string; + networkBaseTokenPriceInUsd: string; + networkBaseTokenPriceDecimals: number; +} + export const BORROW_RATE_MODES = { stable: 1n, variable: 2n, From 8ffa5673ed983aca7f150f5122f89f9d018940d9 Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Fri, 19 Dec 2025 12:11:23 +0200 Subject: [PATCH 4/6] feat: add efficiency mode --- apps/indexer/src/app/routes/_chain/routes.ts | 33 +- .../web-app/src/components/FormComponents.tsx | 15 +- .../BorrowAssetsList/BorrowAssetsList.tsx | 15 +- .../Dialogs/BorrowDialog/BorrowDialog.tsx | 5 +- .../EfficiencyModeDialog.tsx | 324 +++++++----------- .../components/AssetsTable/AssetsTable.tsx | 2 +- .../components/TopPanel/TopPanel.tsx | 2 +- .../MoneyMarket/hooks/use-money-pools.ts | 24 ++ apps/web-app/src/components/ui/alert.tsx | 66 ++++ apps/web-app/src/routes/money-market.tsx | 108 ++++-- .../money-market/money-market.manager.ts | 37 ++ packages/sdk/src/types.ts | 9 + 12 files changed, 389 insertions(+), 251 deletions(-) create mode 100644 apps/web-app/src/components/MoneyMarket/hooks/use-money-pools.ts create mode 100644 apps/web-app/src/components/ui/alert.tsx diff --git a/apps/indexer/src/app/routes/_chain/routes.ts b/apps/indexer/src/app/routes/_chain/routes.ts index ced71c1..a6633e9 100644 --- a/apps/indexer/src/app/routes/_chain/routes.ts +++ b/apps/indexer/src/app/routes/_chain/routes.ts @@ -414,16 +414,33 @@ export default async function (fastify: ZodFastifyInstance) { }, supplied: item.underlyingBalance, suppliedUsd: item.underlyingBalanceUSD, + suppliedBalanceMarketReferenceCurrency: Decimal.from( + item.underlyingBalanceMarketReferenceCurrency, + baseCurrencyData.marketReferenceCurrencyDecimals, + ).toFixed(USD_DECIMALS), supplyApy: Decimal.from(item.reserve.supplyAPY).mul(100).toString(), canToggleCollateral, - borrowed: Decimal.from(item.variableBorrows) - .add(item.stableBorrows) - .toString(), - borrowedUsd: Decimal.from(item.variableBorrowsUSD) - .add(item.stableBorrowsUSD) - .toString(), + borrowed: Decimal.from(item.totalBorrows).toString(), + borrowedUsd: Decimal.from(item.totalBorrowsUSD).toString(), + borrowedBalanceMarketReferenceCurrency: Decimal.from( + item.totalBorrowsMarketReferenceCurrency, + baseCurrencyData.marketReferenceCurrencyDecimals, + ).toFixed(USD_DECIMALS), + + borrowedStable: Decimal.from(item.stableBorrows).toString(), + borrowedStableUsd: Decimal.from(item.stableBorrowsUSD).toString(), + borrowedBalanceStableMarketReferenceCurrency: Decimal.from( + item.stableBorrowsMarketReferenceCurrency, + baseCurrencyData.marketReferenceCurrencyDecimals, + ).toFixed(USD_DECIMALS), + borrowedVariable: Decimal.from(item.variableBorrows).toString(), + borrowedVariableUsd: Decimal.from(item.variableBorrowsUSD).toString(), + borrowedBalanceVariableMarketReferenceCurrency: Decimal.from( + item.variableBorrowsMarketReferenceCurrency, + baseCurrencyData.marketReferenceCurrencyDecimals, + ).toFixed(USD_DECIMALS), collateral: item.usageAsCollateralEnabledOnUser, @@ -477,6 +494,10 @@ export default async function (fastify: ZodFastifyInstance) { netWorthUsd: summary.netWorthUSD, userEmodeCategoryId: summary.userEmodeCategoryId, isInIsolationMode: summary.isInIsolationMode, + + underlyingBalanceMarketReferenceCurrency: Decimal.from( + summary.totalBorrowsUSD, + ), }, }, }; diff --git a/apps/web-app/src/components/FormComponents.tsx b/apps/web-app/src/components/FormComponents.tsx index 4a37816..bc14400 100644 --- a/apps/web-app/src/components/FormComponents.tsx +++ b/apps/web-app/src/components/FormComponents.tsx @@ -18,7 +18,13 @@ import { Checkbox } from './ui/checkbox'; import { Field, FieldDescription, FieldError, FieldLabel } from './ui/field'; import { InputGroup, InputGroupAddon, InputGroupInput } from './ui/input-group'; -export function SubscribeButton({ label }: { label: string }) { +export function SubscribeButton({ + label, + disabled, +}: { + label: string; + disabled?: boolean; +}) { const form = useFormContext(); return ( ( - + ); - - // return ( - //
- // e.preventDefault()} - // > - // - // Borrow Asset - // - // Borrowing functionality is under development. - // - // - // - // {(field) => ( - // <> - // - // - // )} - // - - // - // [ - // state.values.amount, - // computeHealthFactor(state.values.amount ?? 0), - // ] as const - // } - // > - // {([amount, healthFactor]) => ( - // - // - // - // - //
- //
Collateral Ratio
- // - //
- //
- // - // - // - //
- //
- // - // Borrow APY - // - // - // - // - // - // Liquidation price - // - // - // - // - // - // {data?.position.token.symbol} Price - // - // - // - // - //
- // )} - //
- - // - // {(field) => ( - // - // )} - // - - // - // - // - // - // - // - // - // - //
- // - // ); }; export const EfficiencyModeDialog = () => { diff --git a/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx index 6961e51..f5b0c47 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.tsx @@ -87,7 +87,7 @@ export const AssetsTable: FC = ({ assets }) => {
- +
diff --git a/apps/web-app/src/components/MoneyMarket/components/TopPanel/TopPanel.tsx b/apps/web-app/src/components/MoneyMarket/components/TopPanel/TopPanel.tsx index a9db8ad..9e79332 100644 --- a/apps/web-app/src/components/MoneyMarket/components/TopPanel/TopPanel.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/TopPanel/TopPanel.tsx @@ -18,7 +18,7 @@ export const TopPanel: FC = ({ }) => (
-
+
{ + const { data, ...rest } = useQuery({ + queryKey: [QUERY_KEY_MONEY_MARKET_POOLS], + queryFn: () => sdk.moneyMarket.listPools(), + staleTime: STALE_TIME, + }); + + return { + ...rest, + pools: data?.data || [], + }; +}; + +export const useMoneyMarketPoolById = (id?: string) => { + const { pools } = useMoneyMarketPools(); + + return pools.find((p) => p.id === (id || 'default')); +}; diff --git a/apps/web-app/src/components/ui/alert.tsx b/apps/web-app/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/apps/web-app/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/web-app/src/routes/money-market.tsx b/apps/web-app/src/routes/money-market.tsx index 3a48c55..f9d7035 100644 --- a/apps/web-app/src/routes/money-market.tsx +++ b/apps/web-app/src/routes/money-market.tsx @@ -11,6 +11,10 @@ import { LendDialog } from '@/components/MoneyMarket/components/Dialogs/LendDial import { RepayDialog } from '@/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog'; import { WithdrawDialog } from '@/components/MoneyMarket/components/Dialogs/WithdrawDialog/WithdrawDialog'; import { LendAssetsList } from '@/components/MoneyMarket/components/LendAssetsList/LendAssetsList'; +import { + QUERY_KEY_MONEY_MARKET_POOLS, + useMoneyMarketPools, +} from '@/components/MoneyMarket/hooks/use-money-pools'; import { QUERY_KEY_MONEY_MARKET_POSITIONS, useMoneyMarketPositions, @@ -20,7 +24,15 @@ import { useMoneyMarketReserves, } from '@/components/MoneyMarket/hooks/use-money-reserves'; import { Heading } from '@/components/ui/heading/heading'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { sdk } from '@/lib/sdk'; +import { SelectGroup } from '@radix-ui/react-select'; import { useMemo } from 'react'; import { useAccount } from 'wagmi'; import z from 'zod'; @@ -28,8 +40,6 @@ import z from 'zod'; const STALE_TIME = 1000 * 60 * 60; // 1 hour const poolSearchSchema = z.object({ - offset: z.number().min(0).default(0), - limit: z.number().min(1).max(100).default(20), search: z.string().default(''), pool: z.string().default('default'), }); @@ -37,16 +47,14 @@ const poolSearchSchema = z.object({ export const Route = createFileRoute('/money-market')({ component: RouteComponent, validateSearch: poolSearchSchema, - loaderDeps: ({ search: { offset, limit, search, pool } }) => ({ - offset, - limit, + loaderDeps: ({ search: { search, pool } }) => ({ search, pool, }), loader: ({ deps: { pool }, context }) => { const client = context.queryClient; client.prefetchQuery({ - queryKey: ['money-market:pools'], + queryKey: [QUERY_KEY_MONEY_MARKET_POOLS], queryFn: () => sdk.moneyMarket.listPools(), staleTime: STALE_TIME, }); @@ -73,64 +81,96 @@ function RouteComponent() { const { pool } = Route.useLoaderDeps(); const { address } = useAccount(); + const { pools } = useMoneyMarketPools(); + const { reserves } = useMoneyMarketReserves({ pool: pool || 'default', }); - const { data: positions, isPending } = useMoneyMarketPositions({ + const { positions, summary, isPending } = useMoneyMarketPositions({ pool: pool || 'default', address: address!, }); const borrowAssets = useMemo( - () => reserves.filter((r) => r.canBeBorrowed), - [reserves], + () => + reserves.filter( + (r) => + r.canBeBorrowed && + summary?.userEmodeCategoryId && + r.eModeCategoryId === summary?.userEmodeCategoryId, + ), + [reserves, summary], ); + const navigate = Route.useNavigate(); + + const handlePoolChange = (value: string) => { + navigate({ + search: (old) => ({ ...old, pool: value }), + }); + }; + return ( <>
Money Market

- Earn fees from AMM swaps on RSK + Lend and borrow assets with variable and stable interest rates

- +
+ + + +
+ -
diff --git a/packages/sdk/src/managers/money-market/money-market.manager.ts b/packages/sdk/src/managers/money-market/money-market.manager.ts index b213aa2..12f036a 100644 --- a/packages/sdk/src/managers/money-market/money-market.manager.ts +++ b/packages/sdk/src/managers/money-market/money-market.manager.ts @@ -96,6 +96,13 @@ const poolAbi = [ ], outputs: [], }, + { + type: 'function', + name: 'setUserEMode', + stateMutability: 'nonpayable', + inputs: [{ type: 'uint8', name: 'categoryId' }], + outputs: [], + }, ] as const; const debtWethApi = [ @@ -544,6 +551,36 @@ export class MoneyMarketManager extends BaseClient { throw new Error('Repay with collateral is not implemented yet'); } + async changeEfficiencyMode( + pool: MoneyMarketPool, + categoryId: MoneyMarketPoolEmodeCategory['id'], + opts: TransactionOpts, + ) { + log(`Changing efficiency mode to category ${categoryId} in pool ${pool}`, { + categoryId, + opts, + }); + + return [ + { + id: 'set_user_emode', + title: !categoryId ? 'Disable E-Mode' : 'Enable E-Mode', + description: !categoryId ? 'Disable E-Mode' : `Enable E-Mode`, + request: makeTransactionRequest({ + to: pool.address, + value: 0n, + chain: this.ctx.publicClient.chain, + account: opts.account, + data: encodeFunctionData({ + abi: poolAbi, + functionName: 'setUserEMode', + args: [categoryId], + }), + }), + }, + ]; + } + private async repayWithBalance( reserve: MoneyMarketPoolReserve, amount: Decimalish, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c0f3a46..ca2f9b1 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -116,6 +116,15 @@ export type MoneyMarketPoolPosition = { borrowed: string; borrowedUsd: string; + borrowedBalanceMarketReferenceCurrency: string; + + borrowedStable: string; + borrowedStableUsd: string; + borrowedBalanceStableMarketReferenceCurrency: string; + + borrowedVariable: string; + borrowedVariableUsd: string; + borrowedBalanceVariableMarketReferenceCurrency: string; borrowApy: string; stableBorrowApy: string; From 10c92f9084290cf9858a14088a2a6405e181ae99 Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Fri, 19 Dec 2025 14:07:15 +0200 Subject: [PATCH 5/6] fix: review comments --- .../BorrowAssetsList/BorrowAssetsList.tsx | 2 +- .../EfficiencyModeDialog.tsx | 34 +------------------ .../Dialogs/RepayDialog/RepayDialog.tsx | 8 +---- apps/web-app/src/routes/money-market.tsx | 23 +++++++++---- packages/shared/src/lib/decimal.spec.ts | 12 +++++++ packages/shared/src/lib/decimal.ts | 2 +- 6 files changed, 32 insertions(+), 49 deletions(-) diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/BorrowAssetsList.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/BorrowAssetsList.tsx index c1a12b2..94d69c6 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/BorrowAssetsList.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/BorrowAssetsList.tsx @@ -24,7 +24,7 @@ export const BorrowAssetsList: FC = ({ open={open} onClick={setOpen} > - {eModesCategoryId && ( + {!!eModesCategoryId && ( diff --git a/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx index e99666c..9b7bb42 100644 --- a/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx @@ -16,7 +16,6 @@ import { useAppForm } from '@/hooks/app-form'; import { revalidateQuery } from '@/integrations/tanstack-query/root-provider'; import { sdk } from '@/lib/sdk'; import { useSlayerTx } from '@/lib/transactions'; -import type { MoneyMarketUserSummary } from '@sovryn/slayer-sdk'; import { Decimal } from '@sovryn/slayer-shared'; import { useStore } from '@tanstack/react-form'; import { useLoaderDeps } from '@tanstack/react-router'; @@ -28,33 +27,6 @@ import { useMoneyMarketPositions } from '../../../hooks/use-money-positions'; const DISABLED_EMODE_CATEGORY_ID = '0'; -const normalizeEmodeSummary = ( - summary: MoneyMarketUserSummary, - categoryId: string, -) => { - if (!summary) { - return { - ltv: Decimal.ZERO, - collateralRatio: Decimal.INFINITY, - liquidationRisk: false, - }; - } - - const healthFactor = Decimal.from(summary.healthFactor); - const liquidationRisk = healthFactor.lte(1) && healthFactor.gt(0); - - const borrowed = Decimal.from(summary.totalBorrowsUsd); - const collateralRatio = borrowed.eq(0) - ? Decimal.INFINITY - : Decimal.from(summary.totalCollateralUsd).div(borrowed); - - return { - ltv: Decimal.from(summary.currentLoanToValue).mul(100), - collateralRatio, - liquidationRisk, - }; -}; - const EfficiencyModeDialogForm = () => { const { pool } = useLoaderDeps({ from: '/money-market' }); const { address } = useAccount(); @@ -72,10 +44,6 @@ const EfficiencyModeDialogForm = () => { () => String(summary?.userEmodeCategoryId ?? DISABLED_EMODE_CATEGORY_ID), [summary], ); - const currentCategory = useMemo( - () => eModes.find((c) => c.id.toString() === currentCategoryId), - [eModes, currentCategoryId], - ); const { begin } = useSlayerTx({ onClosed: (ok: boolean) => { @@ -218,7 +186,7 @@ const EfficiencyModeDialogForm = () => { diff --git a/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx index 634a56a..5e64651 100644 --- a/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx @@ -57,7 +57,7 @@ const RepayDialogForm = () => { const { begin } = useSlayerTx({ onClosed: (ok: boolean) => { if (ok) { - // close withdrawal dialog if tx was successful + // close dialog if tx was successful repayRequestStore.getState().reset(); } }, @@ -134,12 +134,6 @@ const RepayDialogForm = () => { ), ); }, - onSubmitInvalid(props) { - console.log('Withdraw request submission invalid:', props); - }, - onSubmitMeta() { - console.log('Withdraw request submission meta:', form); - }, }); const handleSubmit = (e: React.FormEvent) => { diff --git a/apps/web-app/src/routes/money-market.tsx b/apps/web-app/src/routes/money-market.tsx index f9d7035..2cbfb53 100644 --- a/apps/web-app/src/routes/money-market.tsx +++ b/apps/web-app/src/routes/money-market.tsx @@ -94,12 +94,21 @@ function RouteComponent() { const borrowAssets = useMemo( () => - reserves.filter( - (r) => - r.canBeBorrowed && - summary?.userEmodeCategoryId && - r.eModeCategoryId === summary?.userEmodeCategoryId, - ), + reserves.filter((r) => { + if (!r.canBeBorrowed) { + return false; + } + + const userEmodeCategoryId = summary?.userEmodeCategoryId; + + // When E-Mode is disabled (category 0 or undefined, allow all borrowable assets) + if (userEmodeCategoryId === undefined || userEmodeCategoryId === 0) { + return true; + } + + // When E-Mode is enabled, restrict to assets in the same E-Mode category. + return r.eModeCategoryId === userEmodeCategoryId; + }), [reserves, summary], ); @@ -121,7 +130,7 @@ function RouteComponent() {

-
+
{ '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', ); }); + + it('isInfinite() returns true for Infinity Decimal', () => { + const d = Decimal.from('Infinity'); + expect(d.isInfinite()).toBe(true); + }); + + it('isInfinite() returns true for very large number', () => { + const d = Decimal.from( + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + ); + expect(d.isInfinite()).toBe(true); + }); }); describe('arithmetics', () => { diff --git a/packages/shared/src/lib/decimal.ts b/packages/shared/src/lib/decimal.ts index 4901f2e..e0bcc70 100644 --- a/packages/shared/src/lib/decimal.ts +++ b/packages/shared/src/lib/decimal.ts @@ -189,7 +189,7 @@ export class Decimal { } isInfinite(): boolean { - return this.gte(Decimal.INFINITY, 0); + return this.gte(Decimal.INFINITY); } abs(): Decimal { From 264a8459a00da7ce465ea85acfee04d2a4327438 Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Fri, 19 Dec 2025 14:19:37 +0200 Subject: [PATCH 6/6] fix: review comments --- apps/web-app/src/components/FormComponents.tsx | 2 +- .../Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web-app/src/components/FormComponents.tsx b/apps/web-app/src/components/FormComponents.tsx index bc14400..597aa65 100644 --- a/apps/web-app/src/components/FormComponents.tsx +++ b/apps/web-app/src/components/FormComponents.tsx @@ -278,7 +278,7 @@ export function AmountField({ ); const handleChange = (input: string) => { - const value = input.replace(',', '.'); + const value = input.replace(/,/g, '.'); setRenderedValue(input); field.setValue(tryDecimalValue(value) as never, { dontRunListeners: true, diff --git a/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx index 9b7bb42..f5e883b 100644 --- a/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx @@ -47,7 +47,6 @@ const EfficiencyModeDialogForm = () => { const { begin } = useSlayerTx({ onClosed: (ok: boolean) => { - console.log('borrow tx modal closed, success:', ok); if (ok) { efficiencyModeRequestStore.getState().reset(); }