Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 @@ -103,7 +104,7 @@ const BorrowDialogForm = () => {
};

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

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 @@ -94,7 +94,7 @@ const LendDialogForm = () => {
};

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

Expand All @@ -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,224 @@
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 { shouldUseFullAmount } from '@/lib/utils';
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);
}, [
data,
position.collateral,
position.reserve.priceUsd,
position.supplied,
position.suppliedUsd,
position.token.decimals,
]);

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 position can be withdrawn in full and user entered near full amount, use full withdrawal to avoid dust issues
maximumWithdrawAmount.eq(position.supplied) &&
shouldUseFullAmount(value.amount, position.supplied),
{
account: address!,
},
),
);
},
onSubmitInvalid(props) {
console.log('Withdraw request submission invalid:', props);
},
onSubmitMeta() {
console.log('Withdraw request submission meta:', form);
},
});

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 }),
}),
),
);
Loading