Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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';

Expand Down Expand Up @@ -198,7 +199,7 @@ const BorrowDialogForm = () => {
value={healthFactor.toNumber()}
options={{
start: 1,
middleStart: 1.1,
middleStart: MINIMUM_HEALTH_FACTOR,
middleEnd: 1.5,
end: 2,
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ const LendDialogForm = () => {
<DialogHeader>
<DialogTitle>Lend Asset</DialogTitle>
<DialogDescription className="sr-only">
Lending functionality is under development.
Supply assets to the money market to earn interest and use them as
collateral for borrowing.
</DialogDescription>
</DialogHeader>
<form.AppField name="amount">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@/components/ui/table/table';
import { Fragment, useCallback, useMemo, type FC } from 'react';

import { withdrawRequestStore } from '@/components/MoneyMarket/stores/withdraw-request.store';
import { AmountRenderer } from '@/components/ui/amount-renderer';
import { Button } from '@/components/ui/button';
import { InfoButton } from '@/components/ui/info-button';
Expand Down Expand Up @@ -35,6 +36,9 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
// );
}, []);

const withdrawSupply = (position: MoneyMarketPoolPosition) =>
withdrawRequestStore.getState().setPosition(position);

return (
<Table className="w-full border-separate">
<TableHeader>
Expand Down Expand Up @@ -66,31 +70,31 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
</TableRow>
</TableHeader>
<TableBody>
{items.map((asset, index) => (
<Fragment key={asset.token.address}>
{items.map((item, index) => (
<Fragment key={item.token.address}>
<TableRow className="hover:bg-transparent">
<TableCell className="border-neutral-800 border-y border-l rounded-tl-[1.25rem] rounded-bl-[1.25rem]">
<div className="flex items-center min-w-24">
<img
src={asset.token.logoUrl}
alt={asset.token.name}
src={item.token.logoUrl}
alt={item.token.name}
className="w-8 h-8"
/>
<div className="ml-2">
<p className="text-gray-50 font-medium">
{asset.token.symbol}
{item.token.symbol}
</p>
</div>
</div>
</TableCell>
<TableCell className="border-neutral-800 border-y">
<AmountRenderer
value={asset.supplied}
suffix={asset.token.symbol}
value={item.supplied}
suffix={item.token.symbol}
/>
<p className="text-neutral-500 font-medium text-xs">
<AmountRenderer
value={asset.suppliedUsd}
value={item.suppliedUsd}
prefix="$"
showApproxSign
/>
Expand All @@ -99,7 +103,7 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
<TableCell className="border-neutral-800 border-y">
<div className="flex items-center">
<AmountRenderer
value={asset.supplyApy}
value={item.supplyApy}
suffix="%"
className="text-gray-50 font-medium"
showApproxSign
Expand All @@ -110,9 +114,9 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
<div className="flex items-center">
<Switch
className="cursor-pointer data-[state=checked]:bg-primary"
checked={asset.collateral}
id={`collateral-${asset.token.address}`}
onClick={() => toggleCollateral(asset.id)}
checked={item.collateral}
id={`collateral-${item.token.address}`}
onClick={() => toggleCollateral(item.id)}
// disabled={!asset}
/>
</div>
Expand All @@ -122,6 +126,7 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
<Button
className="rounded-full min-w-24 h-10 hover:cursor-pointer"
variant="secondary"
onClick={() => withdrawSupply(item)}
>
Withdraw
</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
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 { 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 { Decimal } from '@sovryn/slayer-shared';
import { useMemo } from 'react';
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';

const WithdrawDialogForm = () => {
const { address } = useAccount();

const position = useStore(withdrawRequestStore, (state) => state.position!);

const { data } = useMoneyMarketPositions({
pool: position.pool.id || 'default',
address: address!,
});

const { begin } = useSlayerTx({
onClosed: (ok: boolean) => {
if (ok) {
// close withdrawal dialog if tx was successful
withdrawRequestStore.getState().reset();
}
},
});

const maximumWithdrawAmount = useMemo(() => {
const summary = data?.data?.summary;
if (!summary) {
return Decimal.ZERO;
}

// if user has no borrows or this position is not used as collateral, allow full withdrawal
if (Decimal.from(summary.totalBorrowsUsd).eq(0) || !position.collateral) {
return Decimal.from(position.supplied, position.token.decimals);
}

// min collateral at which we reach minimum collateral ratio
const minCollateralUsd = Decimal.from(MINIMUM_HEALTH_FACTOR)
.mul(summary.totalBorrowsUsd)
.div(summary.currentLiquidationThreshold);
const maxWithdrawUsd = Decimal.from(summary.supplyBalanceUsd).sub(
minCollateralUsd,
);

if (maxWithdrawUsd.lte(0)) {
return Decimal.ZERO;
}

return maxWithdrawUsd.gt(position.suppliedUsd)
? Decimal.from(position.supplied, position.token.decimals)
: maxWithdrawUsd.div(position.reserve.priceUsd);
}, []);
Comment thread
creed-victor marked this conversation as resolved.
Outdated

const balance = useMemo(
() => ({
value: maximumWithdrawAmount.toBigInt(),
decimals: position.token.decimals,
symbol: position.token.symbol,
}),
[position, maximumWithdrawAmount],
);

const form = useAppForm({
defaultValues: {
amount: '',
},
validators: {
onChange: z.object({
amount: validateDecimal({
min: 1n,
max: balance.value ?? undefined,
}),
}),
},
onSubmit: ({ value }) => {
begin(() =>
sdk.moneyMarket.withdraw(
{
...position.reserve,
pool: position.pool,
token: position.token,
},
value.amount,
// if max amount + summary.borrowPowerUsed.eq(0) then flag it as true
false,
Comment thread
creed-victor marked this conversation as resolved.
Outdated
{
account: address!,
},
),
);
},
onSubmitInvalid(props) {
console.log('Withdraw request submission invalid:', props);
},
onSubmitMeta() {
console.log('Withdraw request submission meta:', form);
Comment thread
creed-victor marked this conversation as resolved.
},
});

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
};

const handleEscapes = (e: Event) => {
withdrawRequestStore.getState().reset();
e.preventDefault();
};

const calculateRemainingSupply = (withdrawAmount: string) => {
const amount = Decimal.from(withdrawAmount || '0', position.token.decimals);
const current = Decimal.from(position.supplied, position.token.decimals);
if (amount.gt(current)) {
return Decimal.ZERO.toString();
}
return Decimal.from(position.supplied, position.token.decimals)
.sub(withdrawAmount || '0')
.toString();
};

return (
<form onSubmit={handleSubmit} id={form.formId}>
<DialogContent
onInteractOutside={handleEscapes}
onEscapeKeyDown={handleEscapes}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Withdraw Asset</DialogTitle>
<DialogDescription className="sr-only">
Withdraw your supplied assets from the money market.
</DialogDescription>
</DialogHeader>
<form.AppField name="amount">
{(field) => (
<field.AmountField
label="Amount to Withdraw"
placeholder="Amount"
balance={balance}
addonRight={balance.symbol}
/>
)}
</form.AppField>

<form.Subscribe selector={(state) => state.values.amount}>
{(withdrawAmount) => (
<ItemGroup>
<Item size="sm" className="py-1">
<ItemContent>Remaining supply:</ItemContent>
<ItemContent>
<AmountRenderer
value={calculateRemainingSupply(withdrawAmount)}
suffix={position.token.symbol}
showApproxSign
/>
</ItemContent>
</Item>
</ItemGroup>
)}
</form.Subscribe>

<DialogFooter>
<DialogClose asChild>
<Button variant="secondary" type="button">
Close
</Button>
</DialogClose>
<form.AppForm>
<form.SubscribeButton label="Withdraw" />
</form.AppForm>
</DialogFooter>
</DialogContent>
</form>
);
};

export const WithdrawDialog = () => {
const isOpen = useStoreWithEqualityFn(
withdrawRequestStore,
(state) => state.position !== null,
);

const handleClose = (open: boolean) => {
if (!open) {
withdrawRequestStore.getState().reset();
}
};

return (
<Dialog open={isOpen} onOpenChange={handleClose}>
{isOpen && <WithdrawDialogForm />}
</Dialog>
);
};
1 change: 1 addition & 0 deletions apps/web-app/src/components/MoneyMarket/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const MINIMUM_HEALTH_FACTOR = 1.1;
Original file line number Diff line number Diff line change
@@ -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 WithdrawRequestStore = State & Actions;

export const withdrawRequestStore = createStore<WithdrawRequestStore>(
combine(
{
position: null as MoneyMarketPoolPosition | null,
},
(set) => ({
setPosition: (position: MoneyMarketPoolPosition) => set({ position }),
reset: () => set({ position: null }),
}),
),
);
8 changes: 7 additions & 1 deletion apps/web-app/src/components/ui/health-factor-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import clsx from 'clsx';
import { useCallback, useMemo, type FC } from 'react';
import { MINIMUM_HEALTH_FACTOR } from '../MoneyMarket/constants';

type HealthFactorBarProps = {
value: number;
Expand All @@ -13,7 +14,12 @@ type HealthFactorBarProps = {

export const HealthFactorBar: FC<HealthFactorBarProps> = ({
value,
options = { start: 1, middleStart: 1.1, middleEnd: 1.5, end: 2 },
options = {
start: 1,
middleStart: MINIMUM_HEALTH_FACTOR,
middleEnd: 1.5,
end: 2,
},
}) => {
const getBlurWidth = useCallback(
(start: number, end: number) => {
Expand Down
Loading