From d014f54b67d9c3c7e52b94a79b2d61e484c0ea0a Mon Sep 17 00:00:00 2001 From: Rytis Grincevicius Date: Wed, 10 Dec 2025 16:12:50 +0200 Subject: [PATCH 1/3] feat: calculate borrowing apy --- apps/web-app/package.json | 2 +- .../web-app/src/components/FormComponents.tsx | 51 ++++--- .../components/BorrowDialog/BorrowDialog.tsx | 142 +++++++++++++++++- .../components/LendDialog/LendDialog.tsx | 2 +- .../MoneyMarket/hooks/use-money-positions.ts | 20 +++ .../src/components/ui/health-factor-bar.tsx | 103 +++++++++++++ apps/web-app/src/routes/money-market.tsx | 15 +- packages/sdk/src/types.ts | 2 + packages/shared/src/lib/decimal.ts | 6 + pnpm-lock.yaml | 77 +++++----- 10 files changed, 355 insertions(+), 65 deletions(-) create mode 100644 apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts create mode 100644 apps/web-app/src/components/ui/health-factor-bar.tsx diff --git a/apps/web-app/package.json b/apps/web-app/package.json index 8d6d43b..c91ada8 100644 --- a/apps/web-app/package.json +++ b/apps/web-app/package.json @@ -23,7 +23,7 @@ "@tailwindcss/vite": "4.1.13", "@tanstack/devtools-vite": "0.3.3", "@tanstack/react-devtools": "0.7.0", - "@tanstack/react-form": "1.23.0", + "@tanstack/react-form": "1.27.2", "@tanstack/react-query": "5.90.2", "@tanstack/react-query-devtools": "5.90.2", "@tanstack/react-router": "1.131.50", diff --git a/apps/web-app/src/components/FormComponents.tsx b/apps/web-app/src/components/FormComponents.tsx index 2893b72..3cb9de4 100644 --- a/apps/web-app/src/components/FormComponents.tsx +++ b/apps/web-app/src/components/FormComponents.tsx @@ -10,7 +10,7 @@ import { Switch as ShadcnSwitch } from '@/components/ui/switch'; import { Textarea as ShadcnTextarea } from '@/components/ui/textarea'; import { Decimal } from '@sovryn/slayer-shared'; import { Loader2Icon } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import type { GetBalanceData } from 'wagmi/query'; import { Field, FieldDescription, FieldError, FieldLabel } from './ui/field'; @@ -24,7 +24,7 @@ export function SubscribeButton({ label }: { label: string }) { + + } + /> + +
+

+ Collateral Ratio: + +

+ + + +

+ Borrow APY: + +

+

+ Liquidation price: + +

+

+ {data?.position.token.symbol} price:{' '} + +

+
+ + )} + - 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 d4c182f..c1b18d4 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx @@ -10,6 +10,12 @@ import { 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 { sdk } from '@/lib/sdk'; import { useSlayerTx } from '@/lib/transactions'; @@ -24,10 +30,6 @@ import { useStoreWithEqualityFn } from 'zustand/traditional'; import { useMoneyMarketPositions } from '../../hooks/use-money-positions'; import { borrowRequestStore } from '../../stores/borrow-request.store'; -const schema = z.object({ - amount: validateDecimal({ min: 1n }), -}); - const BorrowDialogForm = () => { const { address } = useAccount(); const reserve = useStore(borrowRequestStore, (state) => state.reserve!); @@ -47,13 +49,32 @@ const BorrowDialogForm = () => { }, }); + const data = useMemo(() => { + const position = (items?.data?.positions || []).find( + (item) => item.reserve.id === reserve.id, + ); + if (position && items?.data) { + return { + position, + summary: items.data.summary, + }; + } + return null; + }, [items]); + const form = useAppForm({ defaultValues: { amount: '', + agree: false, }, validators: { - onMount: schema, - onBlur: schema, + onChange: z.object({ + amount: validateDecimal({ + min: 1n, + max: Decimal.from(data?.position.availableToBorrow ?? '0').toBigInt(), + }), + agree: z.literal(true, 'Must agree to proceed with borrowing'), + }), }, onSubmit: ({ value }) => { begin(() => @@ -86,19 +107,6 @@ const BorrowDialogForm = () => { e.preventDefault(); }; - const data = useMemo(() => { - const position = (items?.data?.positions || []).find( - (item) => item.reserve.id === reserve.id, - ); - if (position && items?.data) { - return { - position, - summary: items.data.summary, - }; - } - return null; - }, [items]); - const calculateLiquidationPrice = useCallback( (amount: string) => { if (!data || Decimal.from(data.summary.collateralBalanceUsd).eq(0)) { @@ -140,7 +148,7 @@ const BorrowDialogForm = () => { > Borrow Asset - + Borrowing functionality is under development. @@ -163,68 +171,96 @@ const BorrowDialogForm = () => { ); }} > - Max: - + + Max: + + } + placeholder="Amount to borrow" + addonRight={data?.position.token.symbol} /> + + )} + -
-

- Collateral Ratio: - -

- - - -

- Borrow APY: + + [ + state.values.amount, + computeHealthFactor(state.values.amount ?? 0), + ] as const + } + > + {([amount, healthFactor]) => ( + + + + +

+
Collateral Ratio
+ +
+ + + + + + + + Borrow APY + -

-

- Liquidation price: + + + + Liquidation price + -

-

- {data?.position.token.symbol} price:{' '} + + + + {data?.position.token.symbol} Price + -

-
- + + + + )} + + + + {(field) => ( + )} diff --git a/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.constants.tsx b/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.constants.tsx deleted file mode 100644 index 416042d..0000000 --- a/apps/web-app/src/components/MoneyMarket/components/LendAssetsList/components/AssetsTable/AssetsTable.constants.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import iconUsdc from '@/assets/tokens/usdc.png'; -import iconUsdt from '@/assets/tokens/usdt.png'; -import iconWbtc from '@/assets/tokens/wbtc.png'; -import type { LendAsset } from '../../LendAssetsList.types'; - -export const LEND_ASSETS: LendAsset[] = [ - { - symbol: 'USDC', - balance: '1.01234566', - balanceUsd: 159489.7, - apy: '15.34', - icon: iconUsdc, - isSortable: true, - canBeCollateral: true, - }, - { - symbol: 'USDT', - balance: '0', - balanceUsd: 0, - apy: '12.50', - icon: iconUsdt, - isSortable: true, - canBeCollateral: false, - }, - { - symbol: 'WBTC', - balance: '0.005', - balanceUsd: 1.23, - apy: '8.75', - icon: iconWbtc, - isSortable: true, - canBeCollateral: true, - }, -]; 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 63b0f51..6961e51 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 @@ -107,12 +107,6 @@ export const AssetsTable: FC = ({ assets }) => { > Lend - 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 fcb9ce7..31a7b83 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx @@ -1,4 +1,6 @@ +import { AmountRenderer } from '@/components/ui/amount-renderer'; import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; import { Dialog, DialogClose, @@ -96,10 +98,19 @@ const LendDialogForm = () => { )} -

- {reserve.token.symbol} can be used as collateral:{' '} - {reserve.canBeCollateral ? 'Yes' : 'No'} -

+ + + +
+ Lend APY: + +
+
+ Collateralization:{' '} + {reserve.canBeCollateral ? 'Enabled' : 'Disabled'} +
+
+
diff --git a/apps/web-app/src/components/ui/amount-renderer.tsx b/apps/web-app/src/components/ui/amount-renderer.tsx index 1bfc767..b464dd8 100644 --- a/apps/web-app/src/components/ui/amount-renderer.tsx +++ b/apps/web-app/src/components/ui/amount-renderer.tsx @@ -1,6 +1,7 @@ import { Decimal } from '@sovryn/slayer-shared'; +import clsx from 'clsx'; import { CopyIcon } from 'lucide-react'; -import { type FC, useCallback } from 'react'; +import { type FC, useCallback, useMemo } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip'; export type AmountRendererProps = { @@ -40,34 +41,32 @@ export const AmountRenderer: FC = ({ }, [value]); const approxSign = showApproxSign ? ( - - ∼ + + ~  ) : null; - if (!showTooltip) { - return ( - + const content = useMemo( + () => ( + <> {approxSign} {prefix} {formatted} - {suffix && {suffix}} - - ); + {suffix &&  {suffix}} + + ), + [approxSign, prefix, formatted, suffix], + ); + + if (!showTooltip) { + return {content}; } return ( - - {approxSign} - {prefix} - {formatted} - {suffix && {suffix}} + + {content} diff --git a/apps/web-app/src/components/ui/health-factor-bar.tsx b/apps/web-app/src/components/ui/health-factor-bar.tsx index 2f04484..da7bbe3 100644 --- a/apps/web-app/src/components/ui/health-factor-bar.tsx +++ b/apps/web-app/src/components/ui/health-factor-bar.tsx @@ -41,7 +41,7 @@ export const HealthFactorBar: FC = ({
= ({ > {value !== undefined && ( = ({
= ({ > {value !== undefined && ( = ({
= ({ > {value !== undefined && ( ) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "text-sm shadow-none flex gap-2 items-center", + { + variants: { + size: { + xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2", + sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +