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/indexer/src/app/routes/_chain/routes.ts b/apps/indexer/src/app/routes/_chain/routes.ts index 4045dbf..a6633e9 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( @@ -157,13 +164,29 @@ 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, + + 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 } }; }, ); @@ -376,18 +399,48 @@ 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, + + 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, + suppliedBalanceMarketReferenceCurrency: Decimal.from( + item.underlyingBalanceMarketReferenceCurrency, + baseCurrencyData.marketReferenceCurrencyDecimals, + ).toFixed(USD_DECIMALS), supplyApy: Decimal.from(item.reserve.supplyAPY).mul(100).toString(), canToggleCollateral, - borrowed: item.variableBorrows, - borrowedUsd: item.variableBorrowsUSD, + 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, @@ -441,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/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/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/FormComponents.tsx b/apps/web-app/src/components/FormComponents.tsx index 4a37816..597aa65 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 ( ( 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 88% 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 e327451..5743edd 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/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'; @@ -27,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(); @@ -48,6 +49,15 @@ const BorrowDialogForm = () => { borrowRequestStore.getState().reset(); } }, + onCompleted: () => { + revalidateQuery({ + queryKey: [ + 'money-market:positions', + reserve.pool.id || 'default', + address, + ], + }); + }, }); const data = useMemo(() => { @@ -82,19 +92,16 @@ const BorrowDialogForm = () => { sdk.moneyMarket.borrow( reserve, value.amount, - BORROW_RATE_MODES.variable, + data?.position.reserve.stableBorrowRateEnabled && + !data?.position.collateral + ? (data?.position.borrowRateMode ?? BORROW_RATE_MODES.variable) + : BORROW_RATE_MODES.variable, { account: address!, }, ), ); }, - onSubmitInvalid(props) { - console.log('Borrow request submission invalid:', props); - }, - onSubmitMeta() { - console.log('Borrow request submission meta:', form); - }, }); const handleSubmit = (e: React.FormEvent) => { @@ -104,7 +111,6 @@ const BorrowDialogForm = () => { }; const handleEscapes = (e: Event) => { - // borrowRequestStore.getState().reset(); e.preventDefault(); }; @@ -211,7 +217,12 @@ const BorrowDialogForm = () => { Borrow APY 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..f5e883b --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/EfficiencyModeDialog/EfficiencyModeDialog.tsx @@ -0,0 +1,216 @@ +import { useMoneyMarketReserves } from '@/components/MoneyMarket/hooks/use-money-reserves'; +import { efficiencyModeRequestStore } from '@/components/MoneyMarket/stores/efficiency-mode-request.store'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AmountRenderer } from '@/components/ui/amount-renderer'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} 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 { Decimal } from '@sovryn/slayer-shared'; +import { useStore } from '@tanstack/react-form'; +import { useLoaderDeps } from '@tanstack/react-router'; +import { CircleAlert } from 'lucide-react'; +import { useMemo } from 'react'; +import { useAccount } from 'wagmi'; +import { useStoreWithEqualityFn } from 'zustand/traditional'; +import { useMoneyMarketPositions } from '../../../hooks/use-money-positions'; + +const DISABLED_EMODE_CATEGORY_ID = '0'; + +const EfficiencyModeDialogForm = () => { + const { pool } = useLoaderDeps({ from: '/money-market' }); + const { address } = useAccount(); + + const { eModes, reserves } = useMoneyMarketReserves({ + pool: pool || 'default', + }); + + const { summary, positions } = useMoneyMarketPositions({ + pool: pool || 'default', + address: address!, + }); + + const currentCategoryId = useMemo( + () => String(summary?.userEmodeCategoryId ?? DISABLED_EMODE_CATEGORY_ID), + [summary], + ); + + const { begin } = useSlayerTx({ + onClosed: (ok: boolean) => { + if (ok) { + efficiencyModeRequestStore.getState().reset(); + } + }, + onCompleted: () => { + revalidateQuery({ + queryKey: ['money-market:positions', pool || 'default', address], + }); + }, + }); + + const form = useAppForm({ + defaultValues: { + mode: currentCategoryId, + }, + onSubmit: ({ value }) => { + begin(() => + sdk.moneyMarket.changeEfficiencyMode( + reserves[0].pool, + Number(value.mode), + { + account: address!, + }, + ), + ); + }, + }); + + const selectedCategoryId = useStore(form.store, (state) => state.values.mode); + + const selectedCategory = useMemo( + () => eModes.find((c) => c.id.toString() === selectedCategoryId), + [eModes, selectedCategoryId], + ); + + const hasLoansInOutsideCategory = useMemo( + () => + selectedCategoryId !== DISABLED_EMODE_CATEGORY_ID && + positions.some( + (item) => + item.reserve.eModeCategoryId !== Number(selectedCategoryId) && + Decimal.from(item.borrowed).gt(0), + ), + [positions, selectedCategoryId], + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }; + + const handleEscapes = (e: Event) => { + e.preventDefault(); + }; + + return ( +
+ e.preventDefault()} + > + + Efficiency Mode + + + {(field) => ( + ({ + label: category.label, + value: category.id.toString(), + })), + ]} + /> + )} + + + {selectedCategory && + selectedCategory.id.toString() !== DISABLED_EMODE_CATEGORY_ID && ( + <> + + + Available assets: + + {selectedCategory.assets + .map((token) => token.symbol) + .join(', ')} + + + + Max Loan to value: + + + + + + + {hasLoansInOutsideCategory ? ( + + + + To enable E-mode for the {selectedCategory.label} category, + all borrow positions outside of this category must be + closed. + + + ) : ( + + + + Enabling E-Mode only allows you to borrow assets belonging + to the selected category! + + + )} + + )} + + + + + + + + + +
+ ); +}; + +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/components/LendDialog/LendDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/LendDialog/LendDialog.tsx similarity index 91% 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 0b2b1e6..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 @@ -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'; @@ -20,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(); @@ -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/Dialogs/RepayDialog/RepayDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx new file mode 100644 index 0000000..5e64651 --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/components/Dialogs/RepayDialog/RepayDialog.tsx @@ -0,0 +1,285 @@ +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 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!, + }, + ), + ); + }, + }); + + 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/Dialogs/WithdrawDialog/WithdrawDialog.tsx similarity index 92% 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 167eaba..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 @@ -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'; @@ -21,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(); @@ -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/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/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/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/MoneyMarket/hooks/use-money-positions.ts b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts index 0c58f6c..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 @@ -1,8 +1,9 @@ +import { shouldRevalidateQuery } from '@/integrations/tanstack-query/root-provider'; 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 = ({ @@ -11,10 +12,23 @@ export const useMoneyMarketPositions = ({ }: { address: Address; pool: string; -}) => - useQuery({ - queryKey: ['money-market:positions', pool, address], - queryFn: () => sdk.moneyMarket.listUserPositions(pool, address!), +}) => { + const { data, ...etc } = useQuery({ + queryKey: [QUERY_KEY_MONEY_MARKET_POSITIONS, pool, address], + queryFn: ({ queryKey }) => + sdk.moneyMarket.listUserPositions( + pool, + address!, + shouldRevalidateQuery(queryKey), + ), 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/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/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/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/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/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..2cbfb53 100644 --- a/apps/web-app/src/routes/money-market.tsx +++ b/apps/web-app/src/routes/money-market.tsx @@ -4,18 +4,35 @@ 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 { 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'; import { LendAssetsList } from '@/components/MoneyMarket/components/LendAssetsList/LendAssetsList'; -import { LendDialog } from '@/components/MoneyMarket/components/LendDialog/LendDialog'; -import { WithdrawDialog } from '@/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog'; +import { + QUERY_KEY_MONEY_MARKET_POOLS, + useMoneyMarketPools, +} from '@/components/MoneyMarket/hooks/use-money-pools'; 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { sdk } from '@/lib/sdk'; -import { useQuery } from '@tanstack/react-query'; +import { SelectGroup } from '@radix-ui/react-select'; import { useMemo } from 'react'; import { useAccount } from 'wagmi'; import z from 'zod'; @@ -23,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'), }); @@ -32,22 +47,20 @@ 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, }); client.prefetchQuery({ - queryKey: ['money-market:reserve', pool || 'default'], + queryKey: [QUERY_KEY_MONEY_MARKET_RESERVES, pool || 'default'], queryFn: () => sdk.moneyMarket.listReserves(pool || 'default'), staleTime: STALE_TIME, }); @@ -68,75 +81,113 @@ 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 { pools } = useMoneyMarketPools(); - const { data: reserves } = useQuery({ - queryKey: ['money-market:reserve', pool || 'default'], - queryFn: () => sdk.moneyMarket.listReserves(pool || 'default'), - staleTime: STALE_TIME, + 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?.data ?? []).filter((r) => r.canBeBorrowed), - [reserves], + () => + 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], ); + 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/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..12f036a 100644 --- a/packages/sdk/src/managers/money-market/money-market.manager.ts +++ b/packages/sdk/src/managers/money-market/money-market.manager.ts @@ -9,8 +9,11 @@ import { makeTransactionRequest, } from '../../lib/transaction.js'; import { + BORROW_RATE_MODES, BorrowRateMode, + MoneyMarketBaseCurrencyData, MoneyMarketPool, + MoneyMarketPoolEmodeCategory, MoneyMarketPoolPosition, MoneyMarketPoolReserve, MoneyMarketUserSummary, @@ -81,6 +84,25 @@ 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: [], + }, + { + type: 'function', + name: 'setUserEMode', + stateMutability: 'nonpayable', + inputs: [{ type: 'uint8', name: 'categoryId' }], + outputs: [], + }, ] as const; const debtWethApi = [ @@ -132,8 +154,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>( @@ -147,15 +183,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( @@ -249,6 +286,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, @@ -322,6 +394,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, @@ -414,4 +525,144 @@ 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'); + } + + 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, + 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/sdk/src/types.ts b/packages/sdk/src/types.ts index bc3a9a3..ca2f9b1 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -50,8 +50,26 @@ export interface MoneyMarketPoolReserve { canBeBorrowed: boolean; canBeCollateral: boolean; + stableBorrowRateEnabled: boolean; + 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 { @@ -69,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, @@ -91,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; diff --git a/packages/shared/src/lib/decimal.spec.ts b/packages/shared/src/lib/decimal.spec.ts index 1c17a71..b06a64c 100644 --- a/packages/shared/src/lib/decimal.spec.ts +++ b/packages/shared/src/lib/decimal.spec.ts @@ -93,6 +93,18 @@ describe('decimal', () => { '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 8715b40..e0bcc70 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); + } + abs(): Decimal { return Decimal.from(this.d.abs().toString(), this.precision); } 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 ?? {}), };