diff --git a/apps/main/src/lend/components/PageLoanManage/index.tsx b/apps/main/src/lend/components/PageLoanManage/index.tsx index 6fbdbfb48c..83a6a659bf 100644 --- a/apps/main/src/lend/components/PageLoanManage/index.tsx +++ b/apps/main/src/lend/components/PageLoanManage/index.tsx @@ -28,8 +28,8 @@ const tabsLoan: TabOption[] = [ ] const tabsCollateral: TabOption[] = [ - { value: 'collateral-increase', label: t`Add collateral` }, - { value: 'collateral-decrease', label: t`Remove collateral` }, + { value: 'collateral-increase', label: t`Add` }, + { value: 'collateral-decrease', label: t`Remove` }, ] const ManageLoan = (pageProps: PageContentProps & { params: MarketUrlParams }) => { @@ -81,6 +81,7 @@ const ManageLoan = (pageProps: PageContentProps & { params: MarketUrlParams }) = onChange={setSubTab} options={subTabs} fullWidth + sx={{ backgroundColor: (t) => t.design.Layer[1].Fill }} /> {subTab === 'loan-increase' && ( diff --git a/apps/main/src/lend/lib/apiLending.ts b/apps/main/src/lend/lib/apiLending.ts index 48928fd016..6eafcf73fe 100644 --- a/apps/main/src/lend/lib/apiLending.ts +++ b/apps/main/src/lend/lib/apiLending.ts @@ -28,7 +28,8 @@ import { UserMarketBalances, } from '@/lend/types/lend.types' import { OneWayMarketTemplate } from '@/lend/types/lend.types' -import { fulfilledValue, getErrorMessage, log } from '@/lend/utils/helpers' +import { fulfilledValue, log } from '@/lend/utils/helpers' +import { getErrorMessage } from '@/llamalend/helpers' import { getIsUserCloseToLiquidation, getLiquidationStatus, reverseBands, sortBandsLend } from '@/llamalend/llama.utils' import PromisePool from '@supercharge/promise-pool' import type { StepStatus } from '@ui/Stepper/types' diff --git a/apps/main/src/lend/utils/helpers.ts b/apps/main/src/lend/utils/helpers.ts index 72f14081fe..141a72dd62 100644 --- a/apps/main/src/lend/utils/helpers.ts +++ b/apps/main/src/lend/utils/helpers.ts @@ -30,14 +30,6 @@ export function getErrorMessage(error: CustomError, defaultErrorMessage: string) return errorMessage } -export function scrollToTop() { - window.scroll({ - top: 0, - left: 0, - behavior: 'smooth', - }) -} - export function fulfilledValue(result: PromiseSettledResult) { if (result.status === 'fulfilled') { return result.value diff --git a/apps/main/src/llamalend/features/manage-loan/components/AddCollateralForm.tsx b/apps/main/src/llamalend/features/manage-loan/components/AddCollateralForm.tsx index e220cdcd39..bc45960005 100644 --- a/apps/main/src/llamalend/features/manage-loan/components/AddCollateralForm.tsx +++ b/apps/main/src/llamalend/features/manage-loan/components/AddCollateralForm.tsx @@ -1,7 +1,5 @@ -import { useLoanToValueFromUserState } from '@/llamalend/features/manage-loan/hooks/useLoanToValueFromUserState' import type { LlamaMarketTemplate, NetworkDict } from '@/llamalend/llamalend.types' import type { AddCollateralOptions } from '@/llamalend/mutations/add-collateral.mutation' -import { useMarketRates } from '@/llamalend/queries/market-rates' import { LoanFormAlerts } from '@/llamalend/widgets/manage-loan/LoanFormAlerts' import { LoanFormTokenInput } from '@/llamalend/widgets/manage-loan/LoanFormTokenInput' import { LoanFormWrapper } from '@/llamalend/widgets/manage-loan/LoanFormWrapper' @@ -35,55 +33,55 @@ export const AddCollateralForm = ({ isPending, onSubmit, action, - params, values, - bands, health, - prices, gas, isApproved, formErrors, collateralToken, borrowToken, txHash, + userState, + expectedCollateral, + prevHealth, + marketRates, + prevLoanToValue, + loanToValue, } = useAddCollateralForm({ market, network, networks, enabled, onAdded, + isAccordionOpen: isOpen, }) - const marketRates = useMarketRates(params, isOpen) - return ( } > }> ({ /> - - ({ handledErrors={['userCollateral']} successTitle={t`Collateral added`} /> + + ) } diff --git a/apps/main/src/llamalend/features/manage-loan/components/RemoveCollateralForm.tsx b/apps/main/src/llamalend/features/manage-loan/components/RemoveCollateralForm.tsx index 627257bccf..82721afdc7 100644 --- a/apps/main/src/llamalend/features/manage-loan/components/RemoveCollateralForm.tsx +++ b/apps/main/src/llamalend/features/manage-loan/components/RemoveCollateralForm.tsx @@ -1,7 +1,5 @@ -import { useLoanToValueFromUserState } from '@/llamalend/features/manage-loan/hooks/useLoanToValueFromUserState' import type { LlamaMarketTemplate, NetworkDict } from '@/llamalend/llamalend.types' import type { RemoveCollateralOptions } from '@/llamalend/mutations/remove-collateral.mutation' -import { useMarketRates } from '@/llamalend/queries/market-rates' import { LoanFormAlerts } from '@/llamalend/widgets/manage-loan/LoanFormAlerts' import { LoanFormTokenInput } from '@/llamalend/widgets/manage-loan/LoanFormTokenInput' import { LoanFormWrapper } from '@/llamalend/widgets/manage-loan/LoanFormWrapper' @@ -11,9 +9,7 @@ import Button from '@mui/material/Button' import Stack from '@mui/material/Stack' import { useSwitch } from '@ui-kit/hooks/useSwitch' import { t } from '@ui-kit/lib/i18n' -import { Balance } from '@ui-kit/shared/ui/Balance' import { InputDivider } from '../../../widgets/InputDivider' -import { setValueOptions } from '../../borrow/react-form.utils' import { useRemoveCollateralForm } from '../hooks/useRemoveCollateralForm' export const RemoveCollateralForm = ({ @@ -38,57 +34,53 @@ export const RemoveCollateralForm = ({ onSubmit, action, maxRemovable, - params, - values, - bands, health, - prices, gas, formErrors, collateralToken, borrowToken, txHash, + userState, + expectedCollateral, + prevHealth, + marketRates, + prevLoanToValue, + loanToValue, } = useRemoveCollateralForm({ market, network, networks, enabled, onRemoved, + isAccordionOpen: isOpen, }) - const marketRates = useMarketRates(params, isOpen) - return ( } > }> ({ max={maxRemovable} testId="remove-collateral-input" network={network} - message={ - form.setValue('userCollateral', maxRemovable.data, setValueOptions)} - /> - } + positionBalance={{ + position: maxRemovable, + tooltip: t`Max Removable Collateral`, + }} /> - - ({ handledErrors={['userCollateral']} successTitle={t`Collateral removed`} /> + + ) } diff --git a/apps/main/src/llamalend/features/manage-loan/hooks/useAddCollateralForm.ts b/apps/main/src/llamalend/features/manage-loan/hooks/useAddCollateralForm.ts index 1b25917ced..f5e45bbf4a 100644 --- a/apps/main/src/llamalend/features/manage-loan/hooks/useAddCollateralForm.ts +++ b/apps/main/src/llamalend/features/manage-loan/hooks/useAddCollateralForm.ts @@ -1,3 +1,4 @@ +import BigNumber from 'bignumber.js' import { useEffect, useMemo } from 'react' import type { UseFormReturn } from 'react-hook-form' import { useForm } from 'react-hook-form' @@ -11,16 +12,23 @@ import { useAddCollateralBands } from '@/llamalend/queries/add-collateral/add-co import { useAddCollateralEstimateGas } from '@/llamalend/queries/add-collateral/add-collateral-gas-estimate.query' import { getAddCollateralHealthOptions } from '@/llamalend/queries/add-collateral/add-collateral-health.query' import { useAddCollateralPrices } from '@/llamalend/queries/add-collateral/add-collateral-prices.query' +import { useMarketRates } from '@/llamalend/queries/market-rates' +import { getUserHealthOptions } from '@/llamalend/queries/user-health.query' +import { useUserState } from '@/llamalend/queries/user-state.query' +import { mapQuery } from '@/llamalend/queries/utils' import type { CollateralParams } from '@/llamalend/queries/validation/manage-loan.types' import { - collateralFormValidationSuite, + addCollateralFormValidationSuite, type CollateralForm, } from '@/llamalend/queries/validation/manage-loan.validation' import type { IChainId as LlamaChainId } from '@curvefi/llamalend-api/lib/interfaces' import { vestResolver } from '@hookform/resolvers/vest' import { useDebouncedValue } from '@ui-kit/hooks/useDebounce' +import { useTokenBalance } from '@ui-kit/hooks/useTokenBalance' import { formDefaultOptions } from '@ui-kit/lib/model' +import { Decimal, decimal } from '@ui-kit/utils/decimal' import { useFormErrors } from '../../borrow/react-form.utils' +import { useLoanToValueFromUserState } from './useLoanToValueFromUserState' const useCallbackAfterFormUpdate = (form: UseFormReturn, callback: () => void) => useEffect(() => form.subscribe({ formState: { values: true }, callback }), [form, callback]) @@ -31,12 +39,14 @@ export const useAddCollateralForm = ({ networks, enabled, onAdded, + isAccordionOpen, }: { market: LlamaMarketTemplate | undefined network: LlamaNetwork networks: NetworkDict enabled?: boolean onAdded: NonNullable + isAccordionOpen: boolean }) => { const { address: userAddress } = useConnection() const { chainId } = network @@ -45,12 +55,14 @@ export const useAddCollateralForm = ({ const tokens = market && getTokens(market) const collateralToken = tokens?.collateralToken const borrowToken = tokens?.borrowToken + const { data: maxCollateral } = useTokenBalance({ chainId, userAddress }, collateralToken) const form = useForm({ ...formDefaultOptions, - resolver: vestResolver(collateralFormValidationSuite), + resolver: vestResolver(addCollateralFormValidationSuite), defaultValues: { userCollateral: undefined, + maxCollateral: undefined, }, }) @@ -81,28 +93,74 @@ export const useAddCollateralForm = ({ useCallbackAfterFormUpdate(form, action.reset) - const bands = useAddCollateralBands(params, enabled) - const health = useHealthQueries((isFull) => getAddCollateralHealthOptions({ ...params, isFull }, enabled)) + const userState = useUserState(params, enabled) const prices = useAddCollateralPrices(params, enabled) + const health = useHealthQueries((isFull) => getAddCollateralHealthOptions({ ...params, isFull }, enabled)) const gas = useAddCollateralEstimateGas(networks, params, enabled) + const prevHealth = useHealthQueries((isFull) => getUserHealthOptions({ ...params, isFull }, enabled)) + const bands = useAddCollateralBands(params, enabled && isAccordionOpen) + const prevLoanToValue = useLoanToValueFromUserState({ + chainId, + marketId: params.marketId, + userAddress: params.userAddress, + collateralToken, + borrowToken, + enabled: isAccordionOpen, + expectedBorrowed: userState.data?.debt, + }) + const loanToValue = useLoanToValueFromUserState({ + chainId: params.chainId!, + marketId: params.marketId, + userAddress: params.userAddress, + collateralToken, + borrowToken, + enabled: isAccordionOpen && !!values.userCollateral, + collateralDelta: values.userCollateral, + expectedBorrowed: userState.data?.debt, + }) + const marketRates = useMarketRates(params, isAccordionOpen) + + const expectedCollateral = useMemo( + () => + mapQuery( + userState, + (state) => + (values.userCollateral && + state.collateral && { + value: decimal(new BigNumber(values.userCollateral).plus(state.collateral)) as Decimal, + tokenSymbol: collateralToken?.symbol, + }) ?? + null, + ), + [collateralToken?.symbol, userState, values.userCollateral], + ) const formErrors = useFormErrors(form.formState) + useEffect(() => { + form.setValue('maxCollateral', maxCollateral, { shouldValidate: true }) + }, [form, maxCollateral]) + return { form, values, - params, isPending: form.formState.isSubmitting || action.isPending, onSubmit: form.handleSubmit(onSubmit), action, bands, + prevHealth, health, + prevLoanToValue, + loanToValue, + marketRates, prices, gas, isApproved, formErrors, collateralToken, + expectedCollateral, borrowToken, txHash: action.data?.hash, + userState, } } diff --git a/apps/main/src/llamalend/features/manage-loan/hooks/useRemoveCollateralForm.ts b/apps/main/src/llamalend/features/manage-loan/hooks/useRemoveCollateralForm.ts index 5a67f91a6b..20a4565d7d 100644 --- a/apps/main/src/llamalend/features/manage-loan/hooks/useRemoveCollateralForm.ts +++ b/apps/main/src/llamalend/features/manage-loan/hooks/useRemoveCollateralForm.ts @@ -1,3 +1,4 @@ +import BigNumber from 'bignumber.js' import { useEffect, useMemo } from 'react' import type { UseFormReturn } from 'react-hook-form' import { useForm } from 'react-hook-form' @@ -9,14 +10,18 @@ import { type RemoveCollateralOptions, useRemoveCollateralMutation, } from '@/llamalend/mutations/remove-collateral.mutation' +import { useMarketRates } from '@/llamalend/queries/market-rates' import { useRemoveCollateralBands } from '@/llamalend/queries/remove-collateral/remove-collateral-bands.query' import { useRemoveCollateralEstimateGas } from '@/llamalend/queries/remove-collateral/remove-collateral-gas-estimate.query' import { getRemoveCollateralHealthOptions } from '@/llamalend/queries/remove-collateral/remove-collateral-health.query' import { useMaxRemovableCollateral } from '@/llamalend/queries/remove-collateral/remove-collateral-max-removable.query' import { useRemoveCollateralPrices } from '@/llamalend/queries/remove-collateral/remove-collateral-prices.query' +import { getUserHealthOptions } from '@/llamalend/queries/user-health.query' +import { useUserState } from '@/llamalend/queries/user-state.query' +import { mapQuery } from '@/llamalend/queries/utils' import type { CollateralParams } from '@/llamalend/queries/validation/manage-loan.types' import { - collateralFormValidationSuite, + removeCollateralFormValidationSuite, type CollateralForm, } from '@/llamalend/queries/validation/manage-loan.validation' import type { IChainId as LlamaChainId, INetworkName as LlamaNetworkId } from '@curvefi/llamalend-api/lib/interfaces' @@ -24,7 +29,9 @@ import { vestResolver } from '@hookform/resolvers/vest' import type { BaseConfig } from '@ui/utils' import { useDebouncedValue } from '@ui-kit/hooks/useDebounce' import { formDefaultOptions } from '@ui-kit/lib/model' +import { Decimal, decimal } from '@ui-kit/utils/decimal' import { useFormErrors } from '../../borrow/react-form.utils' +import { useLoanToValueFromUserState } from './useLoanToValueFromUserState' const useCallbackAfterFormUpdate = (form: UseFormReturn, callback: () => void) => useEffect(() => form.subscribe({ formState: { values: true }, callback }), [form, callback]) @@ -38,12 +45,14 @@ export const useRemoveCollateralForm = < networks, enabled, onRemoved, + isAccordionOpen, }: { market: LlamaMarketTemplate | undefined network: BaseConfig networks: NetworkDict enabled?: boolean onRemoved: NonNullable + isAccordionOpen: boolean }) => { const { address: userAddress } = useConnection() const { chainId } = network @@ -55,24 +64,28 @@ export const useRemoveCollateralForm = < const form = useForm({ ...formDefaultOptions, - resolver: vestResolver(collateralFormValidationSuite), + resolver: vestResolver(removeCollateralFormValidationSuite), defaultValues: { userCollateral: undefined, + maxCollateral: undefined, }, }) const values = form.watch() + const { isValid } = form.formState - const params = useDebouncedValue( + const { params, isValid: debouncedIsValid } = useDebouncedValue( useMemo( - () => - ({ + () => ({ + params: { chainId, marketId, userAddress, userCollateral: values.userCollateral, - }) as CollateralParams, - [chainId, marketId, userAddress, values.userCollateral], + } as CollateralParams, + isValid, + }), + [chainId, marketId, userAddress, values.userCollateral, isValid], ), ) @@ -86,14 +99,61 @@ export const useRemoveCollateralForm = < useCallbackAfterFormUpdate(form, action.reset) + const userState = useUserState(params, enabled) + const prices = useRemoveCollateralPrices(params, enabled && debouncedIsValid) const maxRemovable = useMaxRemovableCollateral(params, enabled) - const bands = useRemoveCollateralBands(params, enabled) - const health = useHealthQueries((isFull) => getRemoveCollateralHealthOptions({ ...params, isFull }, enabled)) - const prices = useRemoveCollateralPrices(params, enabled) - const gas = useRemoveCollateralEstimateGas(networks, params, enabled) + const health = useHealthQueries((isFull) => + getRemoveCollateralHealthOptions({ ...params, isFull }, enabled && debouncedIsValid), + ) + const gas = useRemoveCollateralEstimateGas(networks, params, enabled && debouncedIsValid) + const prevHealth = useHealthQueries((isFull) => + getUserHealthOptions({ ...params, isFull }, enabled && debouncedIsValid), + ) + const bands = useRemoveCollateralBands(params, enabled && isAccordionOpen && debouncedIsValid) + const prevLoanToValue = useLoanToValueFromUserState({ + chainId, + marketId: params.marketId, + userAddress: params.userAddress, + collateralToken, + borrowToken, + enabled: isAccordionOpen && debouncedIsValid, + expectedBorrowed: userState.data?.debt, + }) + const loanToValue = useLoanToValueFromUserState({ + chainId: params.chainId!, + marketId: params.marketId, + userAddress: params.userAddress, + collateralToken, + borrowToken, + enabled: isAccordionOpen && !!values.userCollateral && debouncedIsValid, + collateralDelta: values.userCollateral == null ? undefined : (`-${values.userCollateral}` as Decimal), + expectedBorrowed: userState.data?.debt, + }) + const marketRates = useMarketRates(params, isAccordionOpen) + + const expectedCollateral = useMemo( + () => + // An error will be thrown by the validation suite, the "max" is just for preventing negative collateral in the UI + mapQuery( + userState, + (state) => + state.collateral && + values.userCollateral && { + value: decimal( + BigNumber.max('0', new BigNumber(state.collateral).minus(new BigNumber(values.userCollateral))), + ) as Decimal, + tokenSymbol: collateralToken?.symbol, + }, + ), + [collateralToken?.symbol, userState, values.userCollateral], + ) const formErrors = useFormErrors(form.formState) + useEffect(() => { + form.setValue('maxCollateral', maxRemovable.data, { shouldValidate: true }) + }, [form, maxRemovable.data]) + return { form, values, @@ -110,5 +170,11 @@ export const useRemoveCollateralForm = < collateralToken, borrowToken, formErrors, + userState, + expectedCollateral, + prevHealth, + marketRates, + prevLoanToValue, + loanToValue, } } diff --git a/apps/main/src/llamalend/helpers.ts b/apps/main/src/llamalend/helpers.ts new file mode 100644 index 0000000000..929f09f0d2 --- /dev/null +++ b/apps/main/src/llamalend/helpers.ts @@ -0,0 +1,21 @@ +import { t } from '@ui-kit/lib/i18n' + +interface CustomError extends Error { + data?: { message: string } + code?: string +} + +export function getErrorMessage(error: CustomError | null, defaultErrorMessage?: string): string { + let errorMessage = defaultErrorMessage ?? '' + + if (error?.message) { + if (error.message.startsWith('user rejected transaction') || error?.code === 'ACTION_REJECTED') { + errorMessage = t`User rejected transaction` + } else if ('data' in error && typeof error.data?.message === 'string') { + errorMessage = error.data.message + } else { + errorMessage = error.message + } + } + return errorMessage +} diff --git a/apps/main/src/llamalend/mutations/useLlammaMutation.ts b/apps/main/src/llamalend/mutations/useLlammaMutation.ts index 3c6bf19c54..0619c79c45 100644 --- a/apps/main/src/llamalend/mutations/useLlammaMutation.ts +++ b/apps/main/src/llamalend/mutations/useLlammaMutation.ts @@ -6,6 +6,7 @@ import type { IChainId as LlamaChainId, INetworkName as LlamaNetworkId } from '@ import { useMutation } from '@tanstack/react-query' import { notify, useCurve } from '@ui-kit/features/connect-wallet' import { assertValidity, logError, logMutation, logSuccess } from '@ui-kit/lib' +import { t } from '@ui-kit/lib/i18n' import { waitForTransactionReceipt } from '@wagmi/core' import { getLlamaMarket, updateUserEventsApi } from '../llama.utils' import type { LlamaMarketTemplate } from '../llamalend.types' @@ -135,7 +136,7 @@ export function useLlammaMutation { console.error(`Error in mutation ${mutationKey}:`, error) logError(mutationKey, { error, variables, marketId: context?.market.id }) - notify(error.message, 'error') + notify(t`Transaction failed`, 'error') // hide the actual error message, it can be too long - display it in the form }, onSettled: (_data, _error, _variables, context) => context?.pendingNotification?.dismiss(), }) diff --git a/apps/main/src/llamalend/queries/add-collateral/add-collateral-gas-estimate.query.ts b/apps/main/src/llamalend/queries/add-collateral/add-collateral-gas-estimate.query.ts index c9787f0fd8..add3fe9d71 100644 --- a/apps/main/src/llamalend/queries/add-collateral/add-collateral-gas-estimate.query.ts +++ b/apps/main/src/llamalend/queries/add-collateral/add-collateral-gas-estimate.query.ts @@ -1,7 +1,7 @@ import { useEstimateGas } from '@/llamalend/hooks/useEstimateGas' import { getLlamaMarket } from '@/llamalend/llama.utils' import { type NetworkDict } from '@/llamalend/llamalend.types' -import type { IChainId } from '@curvefi/llamalend-api/lib/interfaces' +import type { IChainId, TGas } from '@curvefi/llamalend-api/lib/interfaces' import { type FieldsOf } from '@ui-kit/lib' import { queryFactory, rootKeys } from '@ui-kit/lib/model' import type { CollateralQuery } from '../validation/manage-loan.types' @@ -19,7 +19,17 @@ const { useQuery: useAddCollateralGasEstimate } = queryFactory({ ] as const, queryFn: async ({ marketId, userCollateral }: AddCollateralGasQuery) => { const market = getLlamaMarket(marketId) - return await market.estimateGas.addCollateralApprove(userCollateral) + const isApproved = await market.addCollateralIsApproved(userCollateral) + + if (isApproved) { + return market.estimateGas.addCollateral(userCollateral) + } + // When not approved, sum both approval gas and addCollateral gas + const [approveGas, addCollateralGas] = await Promise.all([ + market.estimateGas.addCollateralApprove(userCollateral), + market.estimateGas.addCollateral(userCollateral), + ]) + return (Number(approveGas) + Number(addCollateralGas)) as TGas }, validationSuite: collateralValidationSuite, }) diff --git a/apps/main/src/llamalend/queries/user-state.query.ts b/apps/main/src/llamalend/queries/user-state.query.ts index e747922f09..acb6e12d47 100644 --- a/apps/main/src/llamalend/queries/user-state.query.ts +++ b/apps/main/src/llamalend/queries/user-state.query.ts @@ -1,6 +1,7 @@ import { getLlamaMarket } from '@/llamalend/llama.utils' import { queryFactory, rootKeys, type UserMarketParams, type UserMarketQuery } from '@ui-kit/lib/model' import { userMarketValidationSuite } from '@ui-kit/lib/model/query/user-market-validation' +import type { QueryData } from '@ui-kit/lib/queries' import type { Decimal } from '@ui-kit/utils' export const { useQuery: useUserState, invalidate: invalidateUserState } = queryFactory({ @@ -29,3 +30,5 @@ export const { useQuery: useUserState, invalidate: invalidateUserState } = query }, validationSuite: userMarketValidationSuite, }) + +export type UserState = QueryData diff --git a/apps/main/src/llamalend/queries/utils.ts b/apps/main/src/llamalend/queries/utils.ts new file mode 100644 index 0000000000..ee41c3ddac --- /dev/null +++ b/apps/main/src/llamalend/queries/utils.ts @@ -0,0 +1,18 @@ +import type { Query } from '@ui-kit/types/util' + +/** + * Maps a Query type to extract partial data from it. + * Preserves error and loading states while transforming the data. + */ +export const mapQuery = (query: Query, selector: (data: TSource) => TResult) => ({ + ...query, + data: query.data && selector(query.data), +}) + +export const formatQueryValue = (query: Query | undefined, format: (value: NonNullable) => string) => + query?.data != null ? format(query.data as NonNullable) : undefined + +export const combineQueryState = (queries: (Query | undefined)[]) => ({ + error: queries && queries.reduce['error']>((acc, x) => acc ?? x?.error, undefined), + loading: queries && queries.reduce['isLoading']>((acc, x) => acc || !!x?.isLoading, false), +}) diff --git a/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts b/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts index 55348eba37..e34aa29318 100644 --- a/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts +++ b/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts @@ -8,9 +8,11 @@ export const validateUserBorrowed = (userBorrowed: Decimal | null | undefined) = }) } -export const validateUserCollateral = (userCollateral: Decimal | undefined | null) => { +export const validateUserCollateral = (userCollateral: Decimal | undefined | null, required: boolean = true) => { test('userCollateral', `Collateral amount must be a positive number`, () => { - enforce(userCollateral).isNumeric().gt(0) + if (required || userCollateral != null) { + enforce(userCollateral).isNumeric().gt(0) + } }) } @@ -63,9 +65,10 @@ export const validateLeverageEnabled = (leverageEnabled: boolean | undefined | n export const validateMaxCollateral = ( userCollateral: Decimal | undefined | null, maxCollateral: Decimal | undefined | null, + errorMessage?: string, ) => { skipWhen(userCollateral == null || maxCollateral == null, () => { - test('userCollateral', 'Collateral must be less than or equal to your wallet balance', () => { + test('userCollateral', errorMessage ?? 'Collateral must be less than or equal to your wallet balance', () => { enforce(userCollateral).lte(maxCollateral) }) }) diff --git a/apps/main/src/llamalend/queries/validation/manage-loan.validation.ts b/apps/main/src/llamalend/queries/validation/manage-loan.validation.ts index d148b514ab..3ccc049d25 100644 --- a/apps/main/src/llamalend/queries/validation/manage-loan.validation.ts +++ b/apps/main/src/llamalend/queries/validation/manage-loan.validation.ts @@ -1,6 +1,7 @@ import { group } from 'vest' import { validateIsFull, + validateMaxCollateral, validateUserBorrowed, validateUserCollateral, } from '@/llamalend/queries/validation/borrow-fields.validation' @@ -17,7 +18,7 @@ import { marketIdValidationSuite } from '@ui-kit/lib/model/query/market-id-valid import { userAddressValidationGroup } from '@ui-kit/lib/model/query/user-address-validation' import type { Decimal } from '@ui-kit/utils' -export type CollateralForm = FieldsOf<{ userCollateral: Decimal }> +export type CollateralForm = FieldsOf<{ userCollateral: Decimal; maxCollateral: Decimal }> export type RepayForm = FieldsOf<{ stateCollateral: Decimal @@ -37,10 +38,19 @@ export const collateralValidationSuite = createValidationSuite((params: Collater collateralValidationGroup(params), ) -export const collateralFormValidationSuite = createValidationSuite((params: CollateralForm) => { - validateUserCollateral(params.userCollateral) +export const addCollateralFormValidationSuite = createValidationSuite((params: CollateralForm) => { + validateUserCollateral(params.userCollateral, false) + validateMaxCollateral(params.userCollateral, params.maxCollateral) }) +export const removeCollateralFormValidationSuite = createValidationSuite((params: CollateralForm) => { + validateUserCollateral(params.userCollateral, false) + validateMaxCollateral( + params.userCollateral, + params.maxCollateral, + 'Collateral must be less than or equal to your position balance', + ) +}) export const collateralHealthValidationSuite = createValidationSuite(({ isFull, ...rest }: CollateralHealthParams) => { collateralValidationGroup(rest) validateIsFull(isFull) diff --git a/apps/main/src/llamalend/widgets/manage-loan/LoanFormAlerts.tsx b/apps/main/src/llamalend/widgets/manage-loan/LoanFormAlerts.tsx index bf8d16f073..06b952d18b 100644 --- a/apps/main/src/llamalend/widgets/manage-loan/LoanFormAlerts.tsx +++ b/apps/main/src/llamalend/widgets/manage-loan/LoanFormAlerts.tsx @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@/llamalend/helpers' import type { INetworkName } from '@curvefi/llamalend-api/lib/interfaces' import type { Hex } from '@curvefi/prices-api' import Alert from '@mui/material/Alert' @@ -56,7 +57,7 @@ export const LoanFormAlerts = ({ data-testid={'loan-form-error'} > {t`An error occurred`} - {error.message} + {getErrorMessage(error)} )} diff --git a/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx b/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx index 1fb4bb46bc..44fb8ac880 100644 --- a/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx +++ b/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx @@ -7,10 +7,15 @@ import type { LlamaNetwork } from '@/llamalend/llamalend.types' import type { INetworkName } from '@curvefi/llamalend-api/lib/interfaces' import type { PartialRecord } from '@curvefi/prices-api/objects.util' import { useTokenBalance } from '@ui-kit/hooks/useTokenBalance' +import { useTokenUsdRate } from '@ui-kit/lib/model/entities/token-usd-rate' +import { LlamaIcon } from '@ui-kit/shared/icons/LlamaIcon' import { HelperMessage, LargeTokenInput } from '@ui-kit/shared/ui/LargeTokenInput' +import type { LargeTokenInputProps } from '@ui-kit/shared/ui/LargeTokenInput' import { TokenLabel } from '@ui-kit/shared/ui/TokenLabel' import type { Query } from '@ui-kit/types/util' -import { Decimal } from '@ui-kit/utils' +import { decimal, Decimal } from '@ui-kit/utils' + +type WalletBalanceProps = NonNullable /** * A large token input field for loan forms, with balance and max handling. @@ -29,6 +34,7 @@ export const LoanFormTokenInput = < testId, message, network, + positionBalance, }: { label: string token: { address: Address; symbol?: string } | undefined @@ -42,6 +48,16 @@ export const LoanFormTokenInput = < form: UseFormReturn // the form, used to set the value and get errors testId: string message?: ReactNode + /** + * Optional, displays the position balance instead of the wallet balance. + */ + positionBalance?: { + position: Query + tooltip?: WalletBalanceProps['tooltip'] + } + /** + * The network of the token. + */ network: LlamaNetwork }) => { const { address: userAddress } = useConnection() @@ -50,11 +66,29 @@ export const LoanFormTokenInput = < isLoading: isBalanceLoading, error: balanceError, } = useTokenBalance({ chainId: network?.chainId, userAddress }, token) + const { data: usdRate } = useTokenUsdRate({ + chainId: network?.chainId, + tokenAddress: token?.address, + }) + + const { position, tooltip } = positionBalance ?? {} + const walletBalance = useMemo( + // todo: support separate isLoading for balance and for maxBalance in LargeTokenInput + () => ({ + balance: position?.data ?? balance, + symbol: token?.symbol, + loading: position?.isLoading ?? isBalanceLoading, + usdRate, + tooltip: tooltip, + prefix: position && LlamaIcon, + }), + [balance, isBalanceLoading, token?.symbol, usdRate, tooltip, position], + ) const errors = form.formState.errors as PartialRecord, Error> const relatedMaxFieldError = max?.data && max?.fieldName && errors[max.fieldName] const error = errors[name] || max?.error || balanceError || relatedMaxFieldError - + const value = form.getValues(name) return ( } - balance={form.getValues(name)} + balance={value} onBalance={useCallback( (v?: Decimal) => { form.setValue(name, v as FieldPathValue, setValueOptions) @@ -78,12 +112,9 @@ export const LoanFormTokenInput = < )} isError={!!error} message={error?.message} - walletBalance={useMemo( - // todo: support separate isLoading for balance and for maxBalance in LargeTokenInput - () => ({ balance, symbol: token?.symbol, loading: isBalanceLoading }), - [balance, isBalanceLoading, token?.symbol], - )} + walletBalance={walletBalance} maxBalance={useMemo(() => max && { balance: max.data, chips: 'max' }, [max])} + inputBalanceUsd={decimal(usdRate && usdRate * +(value ?? 0))} > {message && } diff --git a/apps/main/src/llamalend/widgets/manage-loan/LoanFormWrapper.tsx b/apps/main/src/llamalend/widgets/manage-loan/LoanFormWrapper.tsx index ea9637f8b2..1c74e0f1eb 100644 --- a/apps/main/src/llamalend/widgets/manage-loan/LoanFormWrapper.tsx +++ b/apps/main/src/llamalend/widgets/manage-loan/LoanFormWrapper.tsx @@ -23,7 +23,7 @@ export const LoanFormWrapper = ) => (
- + t.design.Layer[1].Fill }}> {children} diff --git a/apps/main/src/llamalend/widgets/manage-loan/LoanInfoAccordion.tsx b/apps/main/src/llamalend/widgets/manage-loan/LoanInfoAccordion.tsx index 46edfe8fd9..0843996ef1 100644 --- a/apps/main/src/llamalend/widgets/manage-loan/LoanInfoAccordion.tsx +++ b/apps/main/src/llamalend/widgets/manage-loan/LoanInfoAccordion.tsx @@ -1,14 +1,20 @@ +import { UserState } from '@/llamalend/queries/user-state.query' +import { combineQueryState, formatQueryValue } from '@/llamalend/queries/utils' import Box from '@mui/material/Box' import Stack from '@mui/material/Stack' import { useTheme } from '@mui/material/styles' import { t } from '@ui-kit/lib/i18n' +import { FireIcon } from '@ui-kit/shared/icons/FireIcon' import { Accordion } from '@ui-kit/shared/ui/Accordion' import ActionInfo from '@ui-kit/shared/ui/ActionInfo' +import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' import type { Query } from '@ui-kit/types/util' import { Decimal, formatNumber, formatPercent, formatUsd } from '@ui-kit/utils' import { getHealthValueColor } from '../../features/market-position-details/utils' import { LoanLeverageActionInfo, type LoanLeverageActionInfoProps } from './LoanLeverageActionInfo' +const { Spacing } = SizesAndSpaces + export type LoanInfoGasData = { estGasCostUsd?: number | Decimal | `${number}` tooltip?: string @@ -30,15 +36,17 @@ type LoanInfoAccordionProps = { range?: number health: Query prevHealth?: Query - bands: Query<[number, number]> - prices: Query + bands?: Query<[number, number]> + prices?: Query rates: Query<{ borrowApr?: Decimal } | null> prevRates?: Query<{ borrowApr?: Decimal } | null> loanToValue: Query prevLoanToValue?: Query gas: Query - debt?: Query & { tokenSymbol: string } - prevDebt?: Query + debt?: Query<{ value: Decimal; tokenSymbol?: string } | null> + collateral?: Query<{ value: Decimal; tokenSymbol?: string } | null> + // userState values are used as prev values if collateral or debt are available + userState?: Query & { borrowTokenSymbol?: string; collateralTokenSymbol?: string } leverage?: LoanLeverageActionInfoProps & { enabled: boolean } } @@ -56,83 +64,110 @@ export const LoanInfoAccordion = ({ prevLoanToValue, gas, debt, - prevDebt, + collateral, leverage, -}: LoanInfoAccordionProps) => ( - // error tooltip isn't displayed correctly because accordion takes the mouse focus. Use title for now. - - - } - expanded={isOpen} - toggle={toggle} - > - - {leverage?.enabled && } - {debt && ( - - )} - - formatNumber(p, { abbreviate: false })).join(' - ') ?? '-'} - error={prices.error} - loading={prices.isLoading} - testId="borrow-price-range" - /> - {range != null && ( - - )} - - {loanToValue && ( + userState, +}: LoanInfoAccordionProps) => { + const prevDebt = userState?.data?.debt + const prevCollateral = userState?.data?.collateral + return ( + // error tooltip isn't displayed correctly because accordion takes the mouse focus. Use title for now. + + formatNumber(v, { abbreviate: false }))} + prevValue={formatQueryValue(prevHealth, (v) => formatNumber(v, { abbreviate: false }))} + emptyValue="∞" + {...combineQueryState([health, prevHealth])} + valueColor={getHealthValueColor(Number(health.data ?? prevHealth?.data ?? 100), useTheme())} + testId="borrow-health" /> - )} - - - - -) + } + expanded={isOpen} + toggle={toggle} + > + + + {(debt || prevDebt) && ( + formatNumber(v.value, { abbreviate: false }))} + prevValue={prevDebt && formatNumber(prevDebt, { abbreviate: false })} + {...combineQueryState([debt, userState])} + valueRight={debt?.data?.tokenSymbol ?? userState?.borrowTokenSymbol} + testId="borrow-debt" + /> + )} + {(collateral || prevCollateral) && ( + formatNumber(v.value, { abbreviate: false }))} + prevValue={prevCollateral && formatNumber(prevCollateral, { abbreviate: false })} + {...combineQueryState([collateral, userState])} + valueRight={collateral?.data?.tokenSymbol ?? userState?.collateralTokenSymbol} + testId="borrow-collateral" + /> + )} + {bands && ( + + )} + {prices && ( + formatNumber(p, { abbreviate: false })).join(' - ')} + error={prices.error} + loading={prices.isLoading} + testId="borrow-price-range" + /> + )} + {range != null && ( + + )} + + {(loanToValue || prevLoanToValue) && ( + + )} + + {leverage?.enabled && } + {/* TODO: add router provider and slippage */} + + {/* TODO: add gas estimate steps (1. approve, 2. add collateral) */} + } + /> + + + + + ) +} diff --git a/apps/main/src/llamalend/widgets/manage-loan/LoanLeverageActionInfo.tsx b/apps/main/src/llamalend/widgets/manage-loan/LoanLeverageActionInfo.tsx index c0e8c68012..6b37602588 100644 --- a/apps/main/src/llamalend/widgets/manage-loan/LoanLeverageActionInfo.tsx +++ b/apps/main/src/llamalend/widgets/manage-loan/LoanLeverageActionInfo.tsx @@ -1,4 +1,5 @@ import { notFalsy } from 'router-api/src/router.utils' +import Stack from '@mui/material/Stack' import { t } from '@ui-kit/lib/i18n' import ActionInfo from '@ui-kit/shared/ui/ActionInfo' import type { Query } from '@ui-kit/types/util' @@ -42,7 +43,7 @@ export const LoanLeverageActionInfo = ({ const isHighImpact = priceImpactPercent != null && priceImpactPercent > +slippage return ( - <> + - + ) } diff --git a/apps/main/src/loan/components/PageLoanManage/index.tsx b/apps/main/src/loan/components/PageLoanManage/index.tsx index 991b5dd60e..f333f900fa 100644 --- a/apps/main/src/loan/components/PageLoanManage/index.tsx +++ b/apps/main/src/loan/components/PageLoanManage/index.tsx @@ -87,6 +87,7 @@ const LoanManage = ({ curve, isReady, llamma, llammaId, params, rChainId, rColla onChange={setSubTab} options={subTabs} fullWidth + sx={{ backgroundColor: (t) => t.design.Layer[1].Fill }} /> {subTab === 'loan-increase' && ( diff --git a/apps/main/src/loan/lib/apiCrvusd.ts b/apps/main/src/loan/lib/apiCrvusd.ts index d12aff2abd..8a4e2a56d9 100644 --- a/apps/main/src/loan/lib/apiCrvusd.ts +++ b/apps/main/src/loan/lib/apiCrvusd.ts @@ -1,11 +1,12 @@ import { cloneDeep } from 'lodash' +import { getErrorMessage } from '@/llamalend/helpers' import { getIsUserCloseToLiquidation, getLiquidationStatus, reverseBands, sortBandsMint } from '@/llamalend/llama.utils' import type { MaxRecvLeverage as MaxRecvLeverageForm } from '@/loan/components/PageLoanCreate/types' import type { FormDetailInfo as FormDetailInfoDeleverage } from '@/loan/components/PageLoanManage/LoanDeleverage/types' import networks from '@/loan/networks' import type { LiqRange, MaxRecvLeverage, Provider } from '@/loan/store/types' import { ChainId, LlamaApi, Llamma, UserLoanDetails, type BandBalance } from '@/loan/types/loan.types' -import { fulfilledValue, getErrorMessage, log } from '@/loan/utils/helpers' +import { fulfilledValue, log } from '@/loan/utils/helpers' import { hasV2Leverage } from '@/loan/utils/leverage' import type { TGas } from '@curvefi/llamalend-api/lib/interfaces' import PromisePool from '@supercharge/promise-pool' diff --git a/apps/main/src/loan/utils/helpers.ts b/apps/main/src/loan/utils/helpers.ts index a9ebedbef6..9ea5ddca0f 100644 --- a/apps/main/src/loan/utils/helpers.ts +++ b/apps/main/src/loan/utils/helpers.ts @@ -1,6 +1,5 @@ import networks from '@/loan/networks' import { type ChainId, LlamaApi } from '@/loan/types/loan.types' -import { t } from '@ui-kit/lib/i18n' interface CustomError extends Error { data?: { message: string } @@ -14,21 +13,6 @@ export function log(fnName: string, ...args: unknown[]) { } } -export function getErrorMessage(error: CustomError, defaultErrorMessage: string) { - let errorMessage = defaultErrorMessage - - if (error?.message) { - if (error.message.startsWith('user rejected transaction')) { - errorMessage = t`User rejected transaction` - } else if ('data' in error && typeof error.data?.message === 'string') { - errorMessage = error.data.message - } else { - errorMessage = error.message - } - } - return errorMessage -} - export function fulfilledValue(result: PromiseSettledResult) { if (result.status === 'fulfilled') { return result.value diff --git a/packages/curve-ui-kit/src/lib/queries/types.ts b/packages/curve-ui-kit/src/lib/queries/types.ts index 70e5a3dd35..9b872e38dd 100644 --- a/packages/curve-ui-kit/src/lib/queries/types.ts +++ b/packages/curve-ui-kit/src/lib/queries/types.ts @@ -10,3 +10,6 @@ export type PartialQueryResult = Pick< UseQueryResult, 'data' | 'isLoading' | 'isPending' | 'isError' | 'isFetching' > + +/** Extracts the data type from a useQuery hook */ +export type QueryData any> = NonNullable['data']> diff --git a/packages/curve-ui-kit/src/shared/ui/ActionInfo.tsx b/packages/curve-ui-kit/src/shared/ui/ActionInfo.tsx index 8e24303b17..6f81dc91f0 100644 --- a/packages/curve-ui-kit/src/shared/ui/ActionInfo.tsx +++ b/packages/curve-ui-kit/src/shared/ui/ActionInfo.tsx @@ -41,9 +41,11 @@ export type ActionInfoProps = { /** Tooltip text to display when hovering over the value */ valueTooltip?: ReactNode /** Previous value (if needed for comparison) */ - prevValue?: string + prevValue?: ReactNode /** Custom color for the previous value text */ prevValueColor?: TypographyProps['color'] + /** Placeholder when no value or previous value is provided */ + emptyValue?: ReactNode /** URL to navigate to when clicking the external link button */ link?: string /** Value to be copied (will display a copy button). */ @@ -86,12 +88,15 @@ const valueSize = { large: 'headingSBold', } as const satisfies Record +const isSet = (v: ReactNode) => v || v === 0 + const ActionInfo = ({ label, labelColor, prevValue, prevValueColor, value, + emptyValue = '-', valueColor, valueLeft, valueRight, @@ -114,6 +119,9 @@ const ActionInfo = ({ }, [copyValue, openSnackbar]) const errorMessage = (typeof error === 'object' && error?.message) || (typeof error === 'string' && error) + const showPrevValue = isSet(value) && isSet(prevValue) + value ??= prevValue ?? emptyValue + return ( - {prevValue && ( + {showPrevValue && ( )} - {prevValue && ( + {showPrevValue && ( diff --git a/packages/curve-ui-kit/src/shared/ui/stories/ActionInfo.stories.tsx b/packages/curve-ui-kit/src/shared/ui/stories/ActionInfo.stories.tsx index 86cad9c3f4..310e31603d 100644 --- a/packages/curve-ui-kit/src/shared/ui/stories/ActionInfo.stories.tsx +++ b/packages/curve-ui-kit/src/shared/ui/stories/ActionInfo.stories.tsx @@ -41,6 +41,10 @@ const meta: Meta = { control: 'color', description: 'Custom color for the previous value text', }, + emptyValue: { + control: 'text', + description: 'Placeholder rendered when neither current nor previous value is provided', + }, link: { control: 'text', description: 'The URL to navigate to when clicking the external link button', diff --git a/packages/curve-ui-kit/src/utils/decimal.ts b/packages/curve-ui-kit/src/utils/decimal.ts index 53a13d0bfd..b7beed8802 100644 --- a/packages/curve-ui-kit/src/utils/decimal.ts +++ b/packages/curve-ui-kit/src/utils/decimal.ts @@ -21,8 +21,8 @@ export type Decimal = `${number}` export type Amount = number | Decimal /** Converts a string to a Decimal typed string, returning undefined for null, undefined, empty strings, or non-finite values. */ -export const decimal = (value: number | string | undefined | null): Decimal | undefined => { - if (typeof value === 'number') { +export const decimal = (value: number | string | undefined | null | BigNumber): Decimal | undefined => { + if (typeof value === 'number' || value instanceof BigNumber) { value = value.toString() } diff --git a/packages/ui/src/AppForm/styles.ts b/packages/ui/src/AppForm/styles.ts index 79535a6df4..49f9a947a0 100644 --- a/packages/ui/src/AppForm/styles.ts +++ b/packages/ui/src/AppForm/styles.ts @@ -10,7 +10,6 @@ export const AppFormContentWrapper = styled(Box)` display: grid; grid-row-gap: var(--spacing-3); padding: var(--spacing-3); - min-height: 17.125rem; width: ${MaxWidth.actionCard}; max-width: ${MaxWidth.actionCard}; // let the action card take the full width below the tablet breakpoint