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..4a37816 100644 --- a/apps/web-app/src/components/FormComponents.tsx +++ b/apps/web-app/src/components/FormComponents.tsx @@ -8,11 +8,15 @@ import * as ShadcnSelect from '@/components/ui/select'; import { Slider as ShadcnSlider } from '@/components/ui/slider'; import { Switch as ShadcnSwitch } from '@/components/ui/switch'; import { Textarea as ShadcnTextarea } from '@/components/ui/textarea'; +import type { CheckedState } from '@radix-ui/react-checkbox'; 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 { AmountRenderer } from './ui/amount-renderer'; +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 }) { const form = useFormContext(); @@ -24,7 +28,7 @@ export function SubscribeButton({ label }: { label: string }) { + + + ) : ( + <>{label} )} - handleChange(e.target.value)} - /> + + handleChange(e.target.value)} + type="number" + step="0.00001" + /> + {addonRight && ( + {addonRight} + )} + {description && {description}} {field.state.meta.isTouched && } diff --git a/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx index 26fdb56..f963f86 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowAssetsList/components/AssetsTable/AssetsTable.tsx @@ -70,7 +70,7 @@ export const AssetsTable: FC = ({ assets }) => { -
+
= ({ assets }) => { > Borrow -
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 cd760b4..b1d5148 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx @@ -1,3 +1,4 @@ +import { AmountRenderer } from '@/components/ui/amount-renderer'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -8,24 +9,36 @@ import { 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 { sdk } from '@/lib/sdk'; import { useSlayerTx } from '@/lib/transactions'; import { validateDecimal } from '@/lib/validations'; import { BORROW_RATE_MODES } from '@sovryn/slayer-sdk'; +import { Decimal } from '@sovryn/slayer-shared'; +import { useCallback, useMemo } from 'react'; import { useAccount } from 'wagmi'; import z from 'zod'; import { useStore } from 'zustand'; 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!); + const { data: items } = useMoneyMarketPositions({ + pool: reserve.pool.id || 'default', + address: address!, + }); + const { begin } = useSlayerTx({ onClosed: (ok: boolean) => { console.log('borrow tx modal closed, success:', ok); @@ -35,15 +48,33 @@ const BorrowDialogForm = () => { } }, }); - const { address } = useAccount(); + + 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(() => @@ -76,8 +107,40 @@ const BorrowDialogForm = () => { 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 ( -
+ { > Borrow Asset - + Borrowing functionality is under development. - {(field) => } + {(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) => ( + + )} + + -
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 4641a89..df474d8 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx @@ -1,3 +1,4 @@ +import { AmountRenderer } from '@/components/ui/amount-renderer'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -8,20 +9,43 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Item, ItemContent, ItemGroup } from '@/components/ui/item'; import { useAppForm } from '@/hooks/app-form'; import { sdk } from '@/lib/sdk'; import { useSlayerTx } from '@/lib/transactions'; import { validateDecimal } from '@/lib/validations'; import { areAddressesEqual } from '@sovryn/slayer-shared'; +import { useMemo } from 'react'; 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'; const LendDialogForm = () => { + const { address } = useAccount(); + const reserve = useStore(lendRequestStore, (state) => state.reserve!); + const { data: items } = useMoneyMarketPositions({ + pool: reserve.pool.id || 'default', + address: address!, + }); + + 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 { begin } = useSlayerTx({ onClosed: (ok: boolean) => { if (ok) { @@ -30,7 +54,6 @@ const LendDialogForm = () => { } }, }); - const { address } = useAccount(); const { data: balance } = useBalance({ token: areAddressesEqual(reserve.token.address, reserve.pool.weth) @@ -45,10 +68,7 @@ const LendDialogForm = () => { amount: '', }, validators: { - onMount: z.object({ - amount: validateDecimal({ min: 1n }), - }), - onBlur: z.object({ + onChange: z.object({ amount: validateDecimal({ min: 1n, max: balance?.value ?? undefined }), }), }, @@ -79,7 +99,7 @@ const LendDialogForm = () => { }; return ( - + { > Lend Asset - + Lending functionality is under development. {(field) => ( - + )} -

- {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/MoneyMarket/hooks/use-money-positions.ts b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts new file mode 100644 index 0000000..0c58f6c --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/hooks/use-money-positions.ts @@ -0,0 +1,20 @@ +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 QUERY_KEY_MONEY_MARKET_POSITIONS = 'money-market:positions'; + +export const useMoneyMarketPositions = ({ + pool, + address, +}: { + address: Address; + pool: string; +}) => + useQuery({ + queryKey: ['money-market:positions', pool, address], + queryFn: () => sdk.moneyMarket.listUserPositions(pool, address!), + staleTime: STALE_TIME, + enabled: !!address && !!pool, + }); diff --git a/apps/web-app/src/components/ui/amount-renderer.tsx b/apps/web-app/src/components/ui/amount-renderer.tsx index 1bfc767..fff661b 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 = { @@ -13,11 +14,14 @@ export type AmountRendererProps = { showApproxSign?: boolean; }; -function formatAmount(value: string | number | bigint, decimals = 4) { +function formatAmount(value: string | number | bigint, decimals = 8) { try { const dec = Decimal.from(value); - let str = dec.d.toFixed(decimals); - str = str.replace(/\.?(0+)$/, ''); + let str = dec.d.toFixed(decimals).replace(/\.?(0+)$/, ''); + // if the value is very small, try with more decimals + if (str === '' || str === '0') { + str = dec.d.toFixed(decimals * 2).replace(/\.?(0+)$/, ''); + } return str; } catch { return '-'; @@ -40,34 +44,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/field.tsx b/apps/web-app/src/components/ui/field.tsx index db0dc12..3bf2c5a 100644 --- a/apps/web-app/src/components/ui/field.tsx +++ b/apps/web-app/src/components/ui/field.tsx @@ -1,86 +1,86 @@ -import { useMemo } from "react" -import { cva, type VariantProps } from "class-variance-authority" +import { cva, type VariantProps } from 'class-variance-authority'; +import { useMemo } from 'react'; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; -function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { return (
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", - className + 'flex flex-col gap-6', + 'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + className, )} {...props} /> - ) + ); } function FieldLegend({ className, - variant = "legend", + variant = 'legend', ...props -}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { return ( - ) + ); } -function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { return (
[data-slot=field-group]]:gap-4", - className + 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4', + className, )} {...props} /> - ) + ); } const fieldVariants = cva( - "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + 'group/field flex w-full gap-1 data-[invalid=true]:text-destructive', { variants: { orientation: { - vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'], horizontal: [ - "flex-row items-center", - "[&>[data-slot=field-label]]:flex-auto", - "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + 'flex-row items-center', + '[&>[data-slot=field-label]]:flex-auto', + 'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ], responsive: [ - "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", - "@md/field-group:[&>[data-slot=field-label]]:flex-auto", - "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + 'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto', + '@md/field-group:[&>[data-slot=field-label]]:flex-auto', + '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', ], }, }, defaultVariants: { - orientation: "vertical", + orientation: 'vertical', }, - } -) + }, +); function Field({ className, - orientation = "vertical", + orientation = 'vertical', ...props -}: React.ComponentProps<"div"> & VariantProps) { +}: React.ComponentProps<'div'> & VariantProps) { return (
- ) + ); } -function FieldContent({ className, ...props }: React.ComponentProps<"div">) { +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { return (
- ) + ); } function FieldLabel({ @@ -113,58 +113,58 @@ function FieldLabel({