Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add recover modal to help users deal with bad debt #879

Merged
merged 1 commit into from
Jun 27, 2024
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
160 changes: 160 additions & 0 deletions earn/src/components/common/AddressDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from 'react';

import DropdownArrowDown from 'shared/lib/assets/svg/DropdownArrowDown';
import DropdownArrowUp from 'shared/lib/assets/svg/DropdownArrowUp';
import { Text } from 'shared/lib/components/common/Typography';
import { GREY_800 } from 'shared/lib/data/constants/Colors';
import styled from 'styled-components';
import { Address } from 'viem';

const DEFAULT_BACKGROUND_COLOR = GREY_800;
const DEFAULT_BACKGROUND_COLOR_HOVER = 'rgb(18, 32, 41)';
const DROPDOWN_HEADER_BORDER_COLOR = 'rgba(34, 54, 69, 1)';
const DROPDOWN_LIST_SHADOW_COLOR = 'rgba(0, 0, 0, 0.12)';
const DROPDOWN_PADDING_SIZES = {
S: '8px 38px 8px 12px',
M: '10px 40px 10px 16px',
L: '12px 42px 12px 20px',
};

const DropdownWrapper = styled.div.attrs((props: { compact?: boolean }) => props)`
display: flex;
flex-direction: column;
align-items: start;
justify-content: space-evenly;
position: relative;
overflow: visible;
width: ${(props) => (props.compact ? 'max-content' : '100%')};
`;

const DropdownHeader = styled.button.attrs(
(props: { size: 'S' | 'M' | 'L'; backgroundColor?: string; compact?: boolean }) => props
)`
position: relative;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: ${(props) => DROPDOWN_PADDING_SIZES[props.size]};
width: ${(props) => (props.compact ? 'max-content' : '100%')};
background-color: ${(props) => props.backgroundColor || DEFAULT_BACKGROUND_COLOR};
border: 1px solid ${DROPDOWN_HEADER_BORDER_COLOR};
border-radius: 8px;
cursor: pointer;
&.active {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
`;

const DropdownList = styled.div.attrs((props: { backgroundColor?: string }) => props)`
display: flex;
flex-direction: column;
position: absolute;
top: 100%;
right: 0;
min-width: 100%;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
z-index: 1;
background-color: ${(props) => props.backgroundColor || DEFAULT_BACKGROUND_COLOR};
border: 1px solid ${DROPDOWN_HEADER_BORDER_COLOR};
border-top: 0;
box-shadow: 0px 4px 8px ${DROPDOWN_LIST_SHADOW_COLOR};
`;

const DropdownListItem = styled.button.attrs(
(props: { size: 'S' | 'M' | 'L'; backgroundColor?: string; backgroundColorHover?: string }) => props
)`
text-align: start;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
white-space: nowrap;
width: 100%;
background-color: ${(props) => props.backgroundColor || DEFAULT_BACKGROUND_COLOR};
padding: ${(props) => DROPDOWN_PADDING_SIZES[props.size]};
cursor: pointer;

&:last-child {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
&:hover {
background-color: ${(props) => props.backgroundColorHover || DEFAULT_BACKGROUND_COLOR_HOVER};
}
`;

export type AddressDropdownProps = {
options: Address[];
selectedOption: Address;
onSelect: (option: Address) => void;
size: 'S' | 'M' | 'L';
backgroundColor?: string;
backgroundColorHover?: string;
compact?: boolean;
};

export default function AddressDropdown(props: AddressDropdownProps) {
const { options, selectedOption, onSelect, size, backgroundColor, backgroundColorHover, compact } = props;
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
});

return (
<DropdownWrapper ref={dropdownRef} compact={compact}>
<DropdownHeader
onClick={() => setIsOpen(!isOpen)}
size={size}
backgroundColor={backgroundColor}
compact={compact}
className={isOpen ? 'active' : ''}
>
<div className='flex items-center gap-2'>
<Text size='S' weight='medium'>
{selectedOption}
</Text>
</div>
{isOpen ? (
<DropdownArrowUp className='w-5 absolute right-3' />
) : (
<DropdownArrowDown className='w-5 absolute right-3' />
)}
</DropdownHeader>
{isOpen && (
<DropdownList backgroundColor={backgroundColor}>
{options.map((option) => (
<DropdownListItem
key={option}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
size={size}
backgroundColor={backgroundColor}
backgroundColorHover={backgroundColorHover}
>
<div className='flex items-center gap-2'>
<Text size='S' weight='medium'>
{option}
</Text>
</div>
</DropdownListItem>
))}
</DropdownList>
)}
</DropdownWrapper>
);
}
207 changes: 207 additions & 0 deletions earn/src/components/markets/modal/RecoverModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { useEffect, useState } from 'react';

import { type WriteContractReturnType } from '@wagmi/core';
import { badDebtProcessorAbi } from 'shared/lib/abis/BadDebtProcessor';
import { lenderAbi } from 'shared/lib/abis/Lender';
import { FilledStylizedButton } from 'shared/lib/components/common/Buttons';
import Modal from 'shared/lib/components/common/Modal';
import { Text } from 'shared/lib/components/common/Typography';
import { Token } from 'shared/lib/data/Token';
import useChain from 'shared/lib/hooks/UseChain';
import { PermitState, usePermit } from 'shared/lib/hooks/UsePermit';
import { Address, Hex } from 'viem';
import { base } from 'viem/chains';
import { useReadContract, useSimulateContract, useWriteContract } from 'wagmi';

import AddressDropdown from '../../common/AddressDropdown';
import { SupplyTableRow } from '../supply/SupplyTable';

const SECONDARY_COLOR = 'rgba(130, 160, 182, 1)';

export const ALOE_II_BAD_DEBT_LENDERS: { [chainId: number]: string[] } = {
[base.id]: ['0x25D3C4a59AC57D725dBB4a4EB42BADcF20F37bcD'.toLowerCase()],
};

const ALOE_II_BAD_DEBT_PROCESSOR: { [chainId: number]: Address } = {
[base.id]: '0x8B8eD03dcDa4A4582FD3D395a84F77A334335416',
};

const OPTIONS: { [chainId: number]: { borrower: Address; flashPool: Address }[] } = {
[base.id]: [
{
borrower: '0xC7Cdda63Bf761c663FD7058739be847b422aA5A2',
flashPool: '0x20E068D76f9E90b90604500B84c7e19dCB923e7e',
},
],
};

enum ConfirmButtonState {
READY_TO_SIGN,
READY_TO_REDEEM,
WAITING_FOR_TRANSACTION,
WAITING_FOR_USER,
LOADING,
DISABLED,
}

const PERMIT_STATE_TO_BUTTON_STATE = {
[PermitState.FETCHING_DATA]: ConfirmButtonState.LOADING,
[PermitState.READY_TO_SIGN]: ConfirmButtonState.READY_TO_SIGN,
[PermitState.ASKING_USER_TO_SIGN]: ConfirmButtonState.WAITING_FOR_USER,
[PermitState.ERROR]: ConfirmButtonState.DISABLED,
[PermitState.DONE]: ConfirmButtonState.DISABLED,
[PermitState.DISABLED]: ConfirmButtonState.DISABLED,
};

function getConfirmButton(state: ConfirmButtonState, token: Token): { text: string; enabled: boolean } {
switch (state) {
case ConfirmButtonState.READY_TO_SIGN:
return { text: `Permit Recovery`, enabled: true };
case ConfirmButtonState.READY_TO_REDEEM:
return { text: 'Confirm', enabled: true };
case ConfirmButtonState.WAITING_FOR_TRANSACTION:
return { text: 'Pending', enabled: false };
case ConfirmButtonState.WAITING_FOR_USER:
return { text: 'Check Wallet', enabled: false };
case ConfirmButtonState.LOADING:
return { text: 'Loading', enabled: false };
case ConfirmButtonState.DISABLED:
default:
return { text: 'Confirm', enabled: false };
}
}

export default function RecoverModal({
isOpen,
selectedRow,
userAddress,
setIsOpen,
setPendingTxn,
}: {
isOpen: boolean;
selectedRow: SupplyTableRow;
userAddress: Address;
setIsOpen: (isOpen: boolean) => void;
setPendingTxn: (pendingTxn: WriteContractReturnType | null) => void;
}) {
const activeChain = useChain();
const [selectedOption, setSelectedOption] = useState(OPTIONS[activeChain.id][0]);

const { data: balanceResult } = useReadContract({
abi: lenderAbi,
address: selectedRow.kitty.address,
functionName: 'balanceOf',
args: [userAddress],
query: {
enabled: isOpen,
refetchInterval: 3_000,
refetchIntervalInBackground: false,
},
});

const {
state: permitState,
action: permitAction,
result: permitResult,
} = usePermit(
activeChain.id,
selectedRow.kitty.address,
userAddress,
ALOE_II_BAD_DEBT_PROCESSOR[activeChain.id],
balanceResult?.toString(10) ?? '0',
isOpen && balanceResult !== undefined
);

const {
data: configRecover,
error: errorRecover,
isLoading: loadingRecover,
} = useSimulateContract({
chainId: activeChain.id,
abi: badDebtProcessorAbi,
address: ALOE_II_BAD_DEBT_PROCESSOR[activeChain.id],
functionName: 'processWithPermit',
args: [
selectedRow.kitty.address,
selectedOption.borrower,
selectedOption.flashPool,
10n,
balanceResult ?? 0n,
BigInt(permitResult.deadline),
permitResult.signature?.v ?? 0,
(permitResult.signature?.r ?? '0x0') as Hex,
(permitResult.signature?.s ?? '0x0') as Hex,
],
query: { enabled: isOpen && permitResult.signature !== undefined },
});

const { writeContract: recover, data: txn, isPending, reset: resetTxn } = useWriteContract();

useEffect(() => {
if (txn === undefined) return;
setPendingTxn(txn);
resetTxn();
setIsOpen(false);
}, [txn, setPendingTxn, resetTxn, setIsOpen]);

let confirmButtonState: ConfirmButtonState;
if (isPending || txn) {
confirmButtonState = ConfirmButtonState.WAITING_FOR_TRANSACTION;
} else if (configRecover !== undefined) {
confirmButtonState = ConfirmButtonState.READY_TO_REDEEM;
} else if (balanceResult === undefined || loadingRecover) {
confirmButtonState = ConfirmButtonState.LOADING;
} else {
confirmButtonState = PERMIT_STATE_TO_BUTTON_STATE[permitState];
}
const confirmButton = getConfirmButton(confirmButtonState, selectedRow.asset);

return (
<Modal isOpen={isOpen} setIsOpen={setIsOpen} title='Recover'>
<div className='w-full flex flex-col gap-4'>
<Text size='M' weight='bold'>
Select a borrower with bad debt:
</Text>
<AddressDropdown
size='M'
options={OPTIONS[activeChain.id].map((option) => option.borrower)}
selectedOption={selectedOption.borrower}
onSelect={(borrowerAddress) =>
setSelectedOption(OPTIONS[activeChain.id].find((option) => option.borrower === borrowerAddress)!)
}
/>
<div className='flex flex-col gap-1 w-full'>
<Text size='M' weight='bold'>
Explanation
</Text>
<Text size='XS' color={SECONDARY_COLOR} className='overflow-hidden text-ellipsis'>
Standard withdrawals are impossible right now due to bad debt. You can, however, get a portion of your funds
back by simultaenously withdrawing and liquidating the problematic borrower. Note that you'll receive a
combination of {selectedRow.collateralAssets[0].symbol} and {selectedRow.collateralAssets[1].symbol} instead
of just {selectedRow.kitty.underlying.symbol}. This method is experimental, so we encourage you to triple
check the transaction simulation results in a wallet like Rabby. Please reach out in Discord if you have
questions.
</Text>
</div>
<FilledStylizedButton
size='M'
onClick={() => {
if (permitAction) permitAction();
else if (configRecover) {
recover(configRecover.request);
}
}}
fillWidth={true}
disabled={!confirmButton.enabled}
>
{confirmButton.text}
</FilledStylizedButton>
</div>
{errorRecover && (
<Text size='XS' color={'rgba(234, 87, 87, 0.75)'} className='w-full mt-2'>
{errorRecover.message}
</Text>
)}
</Modal>
);
}
2 changes: 0 additions & 2 deletions earn/src/components/markets/modal/WithdrawModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,6 @@ export default function WithdrawModal(props: WithdrawModalProps) {
GNFormat.DECIMAL
);

// TODO: add a message if the use is not able to withdraw everything which explains why

return (
<Modal isOpen={isOpen} setIsOpen={setIsOpen} title='Withdraw'>
<div className='w-full flex flex-col gap-4'>
Expand Down
Loading