Skip to content

Commit

Permalink
Add recover modal to help users deal with bad debt
Browse files Browse the repository at this point in the history
  • Loading branch information
haydenshively committed Jun 27, 2024
1 parent 3324727 commit c9f54b9
Show file tree
Hide file tree
Showing 6 changed files with 494 additions and 16 deletions.
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

0 comments on commit c9f54b9

Please sign in to comment.