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 b1d5148..e327451 100644 --- a/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/BorrowDialog/BorrowDialog.tsx @@ -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'; @@ -103,7 +104,7 @@ const BorrowDialogForm = () => { }; const handleEscapes = (e: Event) => { - borrowRequestStore.getState().reset(); + // borrowRequestStore.getState().reset(); e.preventDefault(); }; @@ -198,7 +199,7 @@ const BorrowDialogForm = () => { value={healthFactor.toNumber()} options={{ start: 1, - middleStart: 1.1, + middleStart: MINIMUM_HEALTH_FACTOR, middleEnd: 1.5, end: 2, }} 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 df474d8..0b2b1e6 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendDialog/LendDialog.tsx @@ -94,7 +94,7 @@ const LendDialogForm = () => { }; const handleEscapes = (e: Event) => { - lendRequestStore.getState().reset(); + // lendRequestStore.getState().reset(); e.preventDefault(); }; @@ -108,7 +108,8 @@ const LendDialogForm = () => { Lend Asset - Lending functionality is under development. + Supply assets to the money market to earn interest and use them as + collateral for borrowing. diff --git a/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx b/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx index f1ebd67..bef78f6 100644 --- a/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx +++ b/apps/web-app/src/components/MoneyMarket/components/LendPositionsList/components/AssetsTable/AssetsTable.tsx @@ -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'; @@ -35,6 +36,9 @@ export const AssetsTable: FC = ({ assets }) => { // ); }, []); + const withdrawSupply = (position: MoneyMarketPoolPosition) => + withdrawRequestStore.getState().setPosition(position); + return ( @@ -66,31 +70,31 @@ export const AssetsTable: FC = ({ assets }) => { - {items.map((asset, index) => ( - + {items.map((item, index) => ( +
{asset.token.name}

- {asset.token.symbol} + {item.token.symbol}

@@ -99,7 +103,7 @@ export const AssetsTable: FC = ({ assets }) => {

= ({ assets }) => {
toggleCollateral(asset.id)} + checked={item.collateral} + id={`collateral-${item.token.address}`} + onClick={() => toggleCollateral(item.id)} // disabled={!asset} />
@@ -122,6 +126,7 @@ export const AssetsTable: FC = ({ assets }) => { diff --git a/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx b/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx new file mode 100644 index 0000000..167eaba --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog.tsx @@ -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 ( +
+ e.preventDefault()} + > + + Withdraw Asset + + Withdraw your supplied assets from the money market. + + + + {(field) => ( + + )} + + + state.values.amount}> + {(withdrawAmount) => ( + + + Remaining supply: + + + + + + )} + + + + + + + + + + + + + ); +}; + +export const WithdrawDialog = () => { + const isOpen = useStoreWithEqualityFn( + withdrawRequestStore, + (state) => state.position !== null, + ); + + const handleClose = (open: boolean) => { + if (!open) { + withdrawRequestStore.getState().reset(); + } + }; + + return ( + + {isOpen && } + + ); +}; diff --git a/apps/web-app/src/components/MoneyMarket/constants.ts b/apps/web-app/src/components/MoneyMarket/constants.ts new file mode 100644 index 0000000..3a3fedc --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/constants.ts @@ -0,0 +1 @@ +export const MINIMUM_HEALTH_FACTOR = 1.1; diff --git a/apps/web-app/src/components/MoneyMarket/stores/withdraw-request.store.ts b/apps/web-app/src/components/MoneyMarket/stores/withdraw-request.store.ts new file mode 100644 index 0000000..72a2920 --- /dev/null +++ b/apps/web-app/src/components/MoneyMarket/stores/withdraw-request.store.ts @@ -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( + combine( + { + position: null as MoneyMarketPoolPosition | null, + }, + (set) => ({ + setPosition: (position: MoneyMarketPoolPosition) => set({ position }), + reset: () => set({ position: null }), + }), + ), +); diff --git a/apps/web-app/src/components/ui/health-factor-bar.tsx b/apps/web-app/src/components/ui/health-factor-bar.tsx index 94db68b..5d26264 100644 --- a/apps/web-app/src/components/ui/health-factor-bar.tsx +++ b/apps/web-app/src/components/ui/health-factor-bar.tsx @@ -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; @@ -13,7 +14,12 @@ type HealthFactorBarProps = { export const HealthFactorBar: FC = ({ 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) => { diff --git a/apps/web-app/src/lib/transactions/index.ts b/apps/web-app/src/lib/transactions/index.ts index bf015d3..f82ce6f 100644 --- a/apps/web-app/src/lib/transactions/index.ts +++ b/apps/web-app/src/lib/transactions/index.ts @@ -1,9 +1,12 @@ import type { SdkTransactionRequest } from '@sovryn/slayer-sdk'; +import debug from 'debug'; import { useCallback, useEffect } from 'react'; import type { Account, Chain } from 'viem'; import { useStore } from 'zustand'; import { txStore, type TxHandlers } from './store'; +const log = debug('slayer-app:useSlayerTx'); + export const useSlayerTx = ( handlers: TxHandlers = {}, ) => { @@ -12,14 +15,18 @@ export const useSlayerTx = ( const begin = useCallback( async (waitFor: () => Promise[]>) => { setIsFetching(true); + log('Beginning transaction preparation...'); if (waitFor) { const txs = await waitFor(); + log('Prepared transactions:', txs); setItems(txs); setHandlers(handlers); return new Promise((resolve) => { const originalOnClosed = handlers.onClosed; handlers.onClosed = (withSuccess: boolean) => { + log('Transaction modal closed with success:', withSuccess); if (originalOnClosed) { + log('Calling onClosed handler with success:', withSuccess); originalOnClosed(withSuccess); } resolve(withSuccess); diff --git a/apps/web-app/src/lib/utils.ts b/apps/web-app/src/lib/utils.ts index b2cb736..3a4814c 100644 --- a/apps/web-app/src/lib/utils.ts +++ b/apps/web-app/src/lib/utils.ts @@ -1,3 +1,4 @@ +import { Decimal, type Decimalish } from '@sovryn/slayer-shared'; import type { ClassValue } from 'clsx'; import { clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -5,3 +6,20 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: Array) { return twMerge(clsx(inputs)); } + +// if value is within percentageThreshold of total, return true +// e.g. value=99, total=100, percentageThreshold=1 => true +// it helps to decide whether to use full amount in cases like withdrawing nearly all supplied assets +export function shouldUseFullAmount( + value: T, + total: T, + // default to 0.5% (half percent) threshold + percentageThreshold = 0.5, +): boolean { + const decimalValue = Decimal.from(value); + const decimalTotal = Decimal.from(total); + const threshold = decimalTotal.mul( + Decimal.from(percentageThreshold).div(100), + ); + return decimalValue.gte(decimalTotal.sub(threshold)); +} diff --git a/apps/web-app/src/routes/money-market.tsx b/apps/web-app/src/routes/money-market.tsx index 9b9ecd5..8f643d4 100644 --- a/apps/web-app/src/routes/money-market.tsx +++ b/apps/web-app/src/routes/money-market.tsx @@ -8,6 +8,7 @@ import { BorrowDialog } from '@/components/MoneyMarket/components/BorrowDialog/B import { BorrowPositionsList } from '@/components/MoneyMarket/components/BorrowPositionsList/BorrowPositionsList'; import { LendAssetsList } from '@/components/MoneyMarket/components/LendAssetsList/LendAssetsList'; import { LendDialog } from '@/components/MoneyMarket/components/LendDialog/LendDialog'; +import { WithdrawDialog } from '@/components/MoneyMarket/components/WithdrawDialog/WithdrawDialog'; import { QUERY_KEY_MONEY_MARKET_POSITIONS, useMoneyMarketPositions, @@ -135,6 +136,7 @@ function RouteComponent() {
+ ); } diff --git a/packages/sdk/src/managers/money-market/money-market.manager.ts b/packages/sdk/src/managers/money-market/money-market.manager.ts index 9d4434a..6aacf0f 100644 --- a/packages/sdk/src/managers/money-market/money-market.manager.ts +++ b/packages/sdk/src/managers/money-market/money-market.manager.ts @@ -1,5 +1,7 @@ import { areAddressesEqual, Decimal, Decimalish } from '@sovryn/slayer-shared'; +import debug from 'debug'; import { Account, Address, encodeFunctionData, type Chain } from 'viem'; +import { bobSepolia } from 'viem/chains'; import { BaseClient, type SdkRequestOptions } from '../../lib/context.js'; import { buildQuery, toAddress } from '../../lib/helpers.js'; import { @@ -16,6 +18,12 @@ import { TransactionOpts, } from '../../types.js'; +const log = debug('slayer-sdk:managers:money-market'); + +const aWETH = { + [bobSepolia.id]: '0x63719589aC40057556a791FAa701264567b5b627', +} as const; + const poolAbi = [ { type: 'function', @@ -52,6 +60,17 @@ const poolAbi = [ ], outputs: [], }, + { + type: 'function', + name: 'withdraw', + stateMutability: 'nonpayable', + inputs: [ + { type: 'address', name: 'asset' }, + { type: 'uint256', name: 'amount' }, + { type: 'address', name: 'to' }, + ], + outputs: [], + }, { type: 'function', name: 'setUserUseReserveAsCollateral', @@ -101,6 +120,18 @@ const wethGatewayAbi = [ ], outputs: [], }, + + { + type: 'function', + name: 'withdrawETH', + stateMutability: 'nonpayable', + inputs: [ + { type: 'address', name: 'pool' }, + { type: 'uint256', name: 'amount' }, + { type: 'address', name: 'to' }, + ], + outputs: [], + }, ] as const; export class MoneyMarketManager extends BaseClient { @@ -290,4 +321,97 @@ export class MoneyMarketManager extends BaseClient { }, ]; } + + async withdraw( + reserve: MoneyMarketPoolReserve, + amount: Decimalish, + isMaxAmount: boolean, + opts: TransactionOpts, + ) { + const asset = reserve.token; + const pool = reserve.pool; + const value = Decimal.from(amount); + + log( + `Preparing withdraw of ${value.toString()} ${asset.symbol} from pool ${pool.id}`, + { reserve, amount, isMaxAmount, opts }, + ); + + if (asset.isNative || areAddressesEqual(asset.address, pool.weth)) { + const aWethAddress = aWETH[this.ctx.chainId as keyof typeof aWETH]; + if (!aWethAddress) { + throw new Error( + `aWETH address not configured for chain ${this.ctx.chainId}`, + ); + } + + const approval = await makeApprovalTransaction({ + token: aWethAddress, + spender: pool.wethGateway, + amount: isMaxAmount + ? Decimal.MAX_UINT_256.toBigInt() + : value.toBigInt(), + account: toAddress(opts.account), + client: this.ctx.publicClient, + }); + + return [ + ...(approval + ? [ + { + id: 'approve_withdraw_asset', + title: `Approve ${asset.symbol}`, + description: `Approve ${value.toString()} ${asset.symbol} for withdrawal`, + request: approval, + }, + ] + : []), + { + id: 'withdraw_native_asset', + title: `Withdraw ${asset.symbol}`, + description: `Withdraw ${value.toString()} ${asset.symbol}`, + request: makeTransactionRequest({ + to: pool.wethGateway, + value: 0n, + chain: this.ctx.publicClient.chain, + account: opts.account, + data: encodeFunctionData({ + abi: wethGatewayAbi, + functionName: 'withdrawETH', + args: [ + toAddress(pool.address), + isMaxAmount + ? Decimal.MAX_UINT_256.toBigInt() + : value.toBigInt(), + toAddress(opts.account), + ], + }), + }), + }, + ]; + } + + return [ + { + id: 'withdraw_asset', + title: `Withdraw ${asset.symbol}`, + description: `Withdraw ${value.toString()} ${asset.symbol}`, + request: makeTransactionRequest({ + to: pool.address, + value: 0n, + chain: this.ctx.publicClient.chain, + account: opts.account, + data: encodeFunctionData({ + abi: poolAbi, + functionName: 'withdraw', + args: [ + toAddress(asset.address), + isMaxAmount ? Decimal.MAX_UINT_256.toBigInt() : value.toBigInt(), + toAddress(opts.account), + ], + }), + }), + }, + ]; + } } diff --git a/packages/shared/src/lib/decimal.ts b/packages/shared/src/lib/decimal.ts index d0e0d5b..8715b40 100644 --- a/packages/shared/src/lib/decimal.ts +++ b/packages/shared/src/lib/decimal.ts @@ -11,6 +11,7 @@ D.set({ }); const DEFAULT_PRECISION = 18; +const MAX_UINT_128 = '0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'; const MAX_UINT_256 = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; @@ -24,11 +25,19 @@ export class Decimal { static ZERO = new Decimal('0'); static ONE = new Decimal('1'); - static INFINITY = new Decimal('Infinity', 0); + static INFINITY = new Decimal(MAX_UINT_256, 0); + static MAX_UINT_128 = new Decimal(MAX_UINT_128, 0); + static MAX_UINT_256 = new Decimal(MAX_UINT_256, 0); static DEFAULT_PRECISION = DEFAULT_PRECISION; constructor(value: string, precision: number = DEFAULT_PRECISION) { + if (value?.toLowerCase() === 'infinity') { + this.d = new D(MAX_UINT_256); + this.precision = 0; + return; + } + this.d = new D(value); this.precision = precision; } @@ -46,7 +55,7 @@ export class Decimal { } if (typeof value === 'string') { - if (value === 'Infinity') { + if (value?.toLowerCase() === 'infinity') { return new Decimal(MAX_UINT_256, 0); }