-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add recover modal to help users deal with bad debt
- Loading branch information
1 parent
3324727
commit c9f54b9
Showing
6 changed files
with
494 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.