Skip to content

Commit 34c351b

Browse files
creed-victorgrinry
andauthored
SOV-5270: allow withdrawing supplied balances (#20)
* feat: allow withdrawing supplied balances * fix: items * fix: decimals --------- Co-authored-by: Rytis Grincevicius <rytis.grincevicius@gmail.com>
1 parent 97745cc commit 34c351b

File tree

12 files changed

+443
-19
lines changed

12 files changed

+443
-19
lines changed

apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { useAccount } from 'wagmi';
2727
import z from 'zod';
2828
import { useStore } from 'zustand';
2929
import { useStoreWithEqualityFn } from 'zustand/traditional';
30+
import { MINIMUM_HEALTH_FACTOR } from '../../constants';
3031
import { useMoneyMarketPositions } from '../../hooks/use-money-positions';
3132
import { borrowRequestStore } from '../../stores/borrow-request.store';
3233

@@ -103,7 +104,7 @@ const BorrowDialogForm = () => {
103104
};
104105

105106
const handleEscapes = (e: Event) => {
106-
borrowRequestStore.getState().reset();
107+
// borrowRequestStore.getState().reset();
107108
e.preventDefault();
108109
};
109110

@@ -198,7 +199,7 @@ const BorrowDialogForm = () => {
198199
value={healthFactor.toNumber()}
199200
options={{
200201
start: 1,
201-
middleStart: 1.1,
202+
middleStart: MINIMUM_HEALTH_FACTOR,
202203
middleEnd: 1.5,
203204
end: 2,
204205
}}

apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const LendDialogForm = () => {
9494
};
9595

9696
const handleEscapes = (e: Event) => {
97-
lendRequestStore.getState().reset();
97+
// lendRequestStore.getState().reset();
9898
e.preventDefault();
9999
};
100100

@@ -108,7 +108,8 @@ const LendDialogForm = () => {
108108
<DialogHeader>
109109
<DialogTitle>Lend Asset</DialogTitle>
110110
<DialogDescription className="sr-only">
111-
Lending functionality is under development.
111+
Supply assets to the money market to earn interest and use them as
112+
collateral for borrowing.
112113
</DialogDescription>
113114
</DialogHeader>
114115
<form.AppField name="amount">

apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@/components/ui/table/table';
99
import { Fragment, useCallback, useMemo, type FC } from 'react';
1010

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

39+
const withdrawSupply = (position: MoneyMarketPoolPosition) =>
40+
withdrawRequestStore.getState().setPosition(position);
41+
3842
return (
3943
<Table className="w-full border-separate">
4044
<TableHeader>
@@ -66,31 +70,31 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
6670
</TableRow>
6771
</TableHeader>
6872
<TableBody>
69-
{items.map((asset, index) => (
70-
<Fragment key={asset.token.address}>
73+
{items.map((item, index) => (
74+
<Fragment key={item.token.address}>
7175
<TableRow className="hover:bg-transparent">
7276
<TableCell className="border-neutral-800 border-y border-l rounded-tl-[1.25rem] rounded-bl-[1.25rem]">
7377
<div className="flex items-center min-w-24">
7478
<img
75-
src={asset.token.logoUrl}
76-
alt={asset.token.name}
79+
src={item.token.logoUrl}
80+
alt={item.token.name}
7781
className="w-8 h-8"
7882
/>
7983
<div className="ml-2">
8084
<p className="text-gray-50 font-medium">
81-
{asset.token.symbol}
85+
{item.token.symbol}
8286
</p>
8387
</div>
8488
</div>
8589
</TableCell>
8690
<TableCell className="border-neutral-800 border-y">
8791
<AmountRenderer
88-
value={asset.supplied}
89-
suffix={asset.token.symbol}
92+
value={item.supplied}
93+
suffix={item.token.symbol}
9094
/>
9195
<p className="text-neutral-500 font-medium text-xs">
9296
<AmountRenderer
93-
value={asset.suppliedUsd}
97+
value={item.suppliedUsd}
9498
prefix="$"
9599
showApproxSign
96100
/>
@@ -99,7 +103,7 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
99103
<TableCell className="border-neutral-800 border-y">
100104
<div className="flex items-center">
101105
<AmountRenderer
102-
value={asset.supplyApy}
106+
value={item.supplyApy}
103107
suffix="%"
104108
className="text-gray-50 font-medium"
105109
showApproxSign
@@ -110,9 +114,9 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
110114
<div className="flex items-center">
111115
<Switch
112116
className="cursor-pointer data-[state=checked]:bg-primary"
113-
checked={asset.collateral}
114-
id={`collateral-${asset.token.address}`}
115-
onClick={() => toggleCollateral(asset.id)}
117+
checked={item.collateral}
118+
id={`collateral-${item.token.address}`}
119+
onClick={() => toggleCollateral(item.id)}
116120
// disabled={!asset}
117121
/>
118122
</div>
@@ -122,6 +126,7 @@ export const AssetsTable: FC<AssetsTableProps> = ({ assets }) => {
122126
<Button
123127
className="rounded-full min-w-24 h-10 hover:cursor-pointer"
124128
variant="secondary"
129+
onClick={() => withdrawSupply(item)}
125130
>
126131
Withdraw
127132
</Button>
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { AmountRenderer } from '@/components/ui/amount-renderer';
2+
import { Button } from '@/components/ui/button';
3+
import {
4+
Dialog,
5+
DialogClose,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from '@/components/ui/dialog';
12+
import { Item, ItemContent, ItemGroup } from '@/components/ui/item';
13+
import { useAppForm } from '@/hooks/app-form';
14+
import { sdk } from '@/lib/sdk';
15+
import { useSlayerTx } from '@/lib/transactions';
16+
import { shouldUseFullAmount } from '@/lib/utils';
17+
import { validateDecimal } from '@/lib/validations';
18+
import { Decimal } from '@sovryn/slayer-shared';
19+
import { useMemo } from 'react';
20+
import { useAccount } from 'wagmi';
21+
import z from 'zod';
22+
import { useStore } from 'zustand';
23+
import { useStoreWithEqualityFn } from 'zustand/traditional';
24+
import { MINIMUM_HEALTH_FACTOR } from '../../constants';
25+
import { useMoneyMarketPositions } from '../../hooks/use-money-positions';
26+
import { withdrawRequestStore } from '../../stores/withdraw-request.store';
27+
28+
const WithdrawDialogForm = () => {
29+
const { address } = useAccount();
30+
31+
const position = useStore(withdrawRequestStore, (state) => state.position!);
32+
33+
const { data } = useMoneyMarketPositions({
34+
pool: position.pool.id || 'default',
35+
address: address!,
36+
});
37+
38+
const { begin } = useSlayerTx({
39+
onClosed: (ok: boolean) => {
40+
if (ok) {
41+
// close withdrawal dialog if tx was successful
42+
withdrawRequestStore.getState().reset();
43+
}
44+
},
45+
});
46+
47+
const maximumWithdrawAmount = useMemo(() => {
48+
const summary = data?.data?.summary;
49+
if (!summary) {
50+
return Decimal.ZERO;
51+
}
52+
53+
// if user has no borrows or this position is not used as collateral, allow full withdrawal
54+
if (Decimal.from(summary.totalBorrowsUsd).eq(0) || !position.collateral) {
55+
return Decimal.from(position.supplied, position.token.decimals);
56+
}
57+
58+
// min collateral at which we reach minimum collateral ratio
59+
const minCollateralUsd = Decimal.from(MINIMUM_HEALTH_FACTOR)
60+
.mul(summary.totalBorrowsUsd)
61+
.div(summary.currentLiquidationThreshold);
62+
const maxWithdrawUsd = Decimal.from(summary.supplyBalanceUsd).sub(
63+
minCollateralUsd,
64+
);
65+
66+
if (maxWithdrawUsd.lte(0)) {
67+
return Decimal.ZERO;
68+
}
69+
70+
return maxWithdrawUsd.gt(position.suppliedUsd)
71+
? Decimal.from(position.supplied, position.token.decimals)
72+
: maxWithdrawUsd.div(position.reserve.priceUsd);
73+
}, [
74+
data,
75+
position.collateral,
76+
position.reserve.priceUsd,
77+
position.supplied,
78+
position.suppliedUsd,
79+
position.token.decimals,
80+
]);
81+
82+
const balance = useMemo(
83+
() => ({
84+
value: maximumWithdrawAmount.toBigInt(),
85+
decimals: position.token.decimals,
86+
symbol: position.token.symbol,
87+
}),
88+
[position, maximumWithdrawAmount],
89+
);
90+
91+
const form = useAppForm({
92+
defaultValues: {
93+
amount: '',
94+
},
95+
validators: {
96+
onChange: z.object({
97+
amount: validateDecimal({
98+
min: 1n,
99+
max: balance.value ?? undefined,
100+
}),
101+
}),
102+
},
103+
onSubmit: ({ value }) => {
104+
begin(() =>
105+
sdk.moneyMarket.withdraw(
106+
{
107+
...position.reserve,
108+
pool: position.pool,
109+
token: position.token,
110+
},
111+
value.amount,
112+
// if position can be withdrawn in full and user entered near full amount, use full withdrawal to avoid dust issues
113+
maximumWithdrawAmount.eq(position.supplied) &&
114+
shouldUseFullAmount(value.amount, position.supplied),
115+
{
116+
account: address!,
117+
},
118+
),
119+
);
120+
},
121+
onSubmitInvalid(props) {
122+
console.log('Withdraw request submission invalid:', props);
123+
},
124+
onSubmitMeta() {
125+
console.log('Withdraw request submission meta:', form);
126+
},
127+
});
128+
129+
const handleSubmit = (e: React.FormEvent) => {
130+
e.preventDefault();
131+
e.stopPropagation();
132+
form.handleSubmit();
133+
};
134+
135+
const handleEscapes = (e: Event) => {
136+
// withdrawRequestStore.getState().reset();
137+
e.preventDefault();
138+
};
139+
140+
const calculateRemainingSupply = (withdrawAmount: string) => {
141+
const amount = Decimal.from(withdrawAmount || '0', position.token.decimals);
142+
const current = Decimal.from(position.supplied, position.token.decimals);
143+
if (amount.gt(current)) {
144+
return Decimal.ZERO.toString();
145+
}
146+
return Decimal.from(position.supplied, position.token.decimals)
147+
.sub(withdrawAmount || '0')
148+
.toString();
149+
};
150+
151+
return (
152+
<form onSubmit={handleSubmit} id={form.formId}>
153+
<DialogContent
154+
onInteractOutside={handleEscapes}
155+
onEscapeKeyDown={handleEscapes}
156+
onOpenAutoFocus={(e) => e.preventDefault()}
157+
>
158+
<DialogHeader>
159+
<DialogTitle>Withdraw Asset</DialogTitle>
160+
<DialogDescription className="sr-only">
161+
Withdraw your supplied assets from the money market.
162+
</DialogDescription>
163+
</DialogHeader>
164+
<form.AppField name="amount">
165+
{(field) => (
166+
<field.AmountField
167+
label="Amount to Withdraw"
168+
placeholder="Amount"
169+
balance={balance}
170+
addonRight={balance.symbol}
171+
/>
172+
)}
173+
</form.AppField>
174+
175+
<form.Subscribe selector={(state) => state.values.amount}>
176+
{(withdrawAmount) => (
177+
<ItemGroup>
178+
<Item size="sm" className="py-1">
179+
<ItemContent>Remaining supply:</ItemContent>
180+
<ItemContent>
181+
<AmountRenderer
182+
value={calculateRemainingSupply(withdrawAmount)}
183+
suffix={position.token.symbol}
184+
showApproxSign
185+
/>
186+
</ItemContent>
187+
</Item>
188+
</ItemGroup>
189+
)}
190+
</form.Subscribe>
191+
192+
<DialogFooter>
193+
<DialogClose asChild>
194+
<Button variant="secondary" type="button">
195+
Close
196+
</Button>
197+
</DialogClose>
198+
<form.AppForm>
199+
<form.SubscribeButton label="Withdraw" />
200+
</form.AppForm>
201+
</DialogFooter>
202+
</DialogContent>
203+
</form>
204+
);
205+
};
206+
207+
export const WithdrawDialog = () => {
208+
const isOpen = useStoreWithEqualityFn(
209+
withdrawRequestStore,
210+
(state) => state.position !== null,
211+
);
212+
213+
const handleClose = (open: boolean) => {
214+
if (!open) {
215+
withdrawRequestStore.getState().reset();
216+
}
217+
};
218+
219+
return (
220+
<Dialog open={isOpen} onOpenChange={handleClose}>
221+
{isOpen && <WithdrawDialogForm />}
222+
</Dialog>
223+
);
224+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const MINIMUM_HEALTH_FACTOR = 1.1;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { MoneyMarketPoolPosition } from '@sovryn/slayer-sdk';
2+
import { createStore } from 'zustand';
3+
import { combine } from 'zustand/middleware';
4+
5+
type State = {
6+
position: MoneyMarketPoolPosition | null;
7+
};
8+
9+
type Actions = {
10+
setPosition: (position: MoneyMarketPoolPosition) => void;
11+
reset: () => void;
12+
};
13+
14+
type WithdrawRequestStore = State & Actions;
15+
16+
export const withdrawRequestStore = createStore<WithdrawRequestStore>(
17+
combine(
18+
{
19+
position: null as MoneyMarketPoolPosition | null,
20+
},
21+
(set) => ({
22+
setPosition: (position: MoneyMarketPoolPosition) => set({ position }),
23+
reset: () => set({ position: null }),
24+
}),
25+
),
26+
);

0 commit comments

Comments
 (0)