@@ -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 (
+
+ );
+};
+
+export const WithdrawDialog = () => {
+ const isOpen = useStoreWithEqualityFn(
+ withdrawRequestStore,
+ (state) => state.position !== null,
+ );
+
+ const handleClose = (open: boolean) => {
+ if (!open) {
+ withdrawRequestStore.getState().reset();
+ }
+ };
+
+ return (
+
+ );
+};
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);
}