Instantly share code, notes, and snippets.
Created
July 19, 2024 02:19
-
Star
(0)
0
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save Yuripetusko/ed5c6ca6c3701da7246db16b5c066531 to your computer and use it in GitHub Desktop.
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
``` | |
import { useAccount, useReadContract } from 'wagmi'; | |
import { type Address, erc20Abi } from 'viem'; | |
import { SKYBREACH_LAND_CHAIN_ID } from 'lib/constants/app'; | |
type Props = { | |
tokenAddress?: Address; | |
allowedAddress?: Address; | |
enabled?: boolean; | |
chainId?: typeof SKYBREACH_LAND_CHAIN_ID; | |
}; | |
export const useErc20Allowance = ({ | |
tokenAddress, | |
allowedAddress, | |
enabled, | |
chainId = SKYBREACH_LAND_CHAIN_ID, | |
}: Props) => { | |
const { address } = useAccount(); | |
const isEnabled = !!address && !!allowedAddress && !!enabled && !!chainId; | |
const { | |
data: allowance, | |
isLoading, | |
isFetching, | |
isError, | |
isSuccess, | |
refetch: refetchAllowance, | |
} = useReadContract({ | |
chainId, | |
address: enabled ? tokenAddress : undefined, | |
args: isEnabled ? [address, allowedAddress] : undefined, | |
abi: erc20Abi, | |
functionName: 'allowance', | |
query: { enabled: isEnabled }, | |
}); | |
const refetch = enabled ? refetchAllowance : undefined; | |
return { | |
isLoading, | |
isFetching, | |
isSuccess, | |
isError, | |
allowance, | |
refetch, | |
}; | |
}; | |
``` | |
``` | |
import { useAccount } from 'wagmi'; | |
import { useNativeTokenBalance } from './use-native-token-balance'; | |
import type { SKYBREACH_LAND_CHAIN_ID } from 'lib/constants/app'; | |
import { type Address } from 'viem'; | |
import { useErc20Allowance } from 'lib/hooks/use-erc20-allowance'; | |
import { formatUnitsToNumber } from 'lib/utils/token-unit-utils/format-units-to-number'; | |
import type { ChainToken } from 'lib/types/chain-token'; | |
import { useFormattedTokenBalance } from 'lib/hooks/use-formatted-token-balance'; | |
type Props = { | |
balanceRequired: bigint; | |
currencyAddress?: Address; | |
enabled?: boolean; | |
chainId?: typeof SKYBREACH_LAND_CHAIN_ID; | |
contractToApprove?: Address; | |
isNativeCurrency?: boolean; | |
tokenInfo?: ChainToken; | |
}; | |
const getIsSufficientAllowance = ( | |
allowanceOwned: bigint | undefined, | |
allowanceRequired: bigint, | |
): boolean => { | |
return ( | |
allowanceRequired === BigInt(0) || (!!allowanceOwned && allowanceOwned >= allowanceRequired) | |
); | |
}; | |
export const getIsSufficientBalance = ( | |
balanceOwned: bigint | undefined, | |
balanceRequired: bigint, | |
) => { | |
return balanceRequired === BigInt(0) || (!!balanceOwned && balanceOwned >= balanceRequired); | |
}; | |
export const useIsSufficientTokenBalance = ({ | |
balanceRequired, | |
currencyAddress, | |
enabled, | |
contractToApprove, | |
chainId, | |
isNativeCurrency, | |
tokenInfo, | |
}: Props) => { | |
const { address } = useAccount(); | |
const { | |
allowance, | |
isLoading: isLoadingAllowance, | |
isFetching: isFetchingAllowance, | |
isError: isErrorAllowance, | |
refetch: refetchAllowance, | |
} = useErc20Allowance({ | |
enabled: enabled && !isNativeCurrency, | |
chainId, | |
tokenAddress: currencyAddress, | |
allowedAddress: contractToApprove, | |
}); | |
const { | |
tokenBalanceRaw, | |
refetch: refetchTokenBalance, | |
isLoading: isLoadingTokenBalance, | |
isFetching: isFetchingTokenBalance, | |
isError: isErrorTokenBalance, | |
} = useFormattedTokenBalance({ | |
tokenAddress: currencyAddress, | |
account: address, | |
enabled: enabled && !isNativeCurrency, | |
}); | |
const { data: balance, refetch: refetchNativeBalance } = useNativeTokenBalance( | |
{ account: address }, | |
{ enabled: isNativeCurrency }, | |
); | |
if (enabled && !isNativeCurrency && !tokenInfo) { | |
throw new Error('tokenInfo is required for erc20 currency'); | |
} | |
const tokenBalance = isNativeCurrency ? balance?.value : tokenBalanceRaw?.value; | |
const isSufficientAllowance = getIsSufficientAllowance(allowance, balanceRequired); | |
const isSufficientBalance = getIsSufficientBalance(tokenBalance, balanceRequired); | |
const parsedBalanceRequired = formatUnitsToNumber(balanceRequired, tokenInfo?.decimals) ?? 0; | |
const isLoading = isLoadingAllowance || isLoadingTokenBalance; | |
// unlike isLoading, isFetching toggles when doing refetch | |
const isFetching = isFetchingAllowance || isFetchingTokenBalance; | |
const isError = isErrorAllowance || isErrorTokenBalance; | |
return { | |
parsedBalanceRequired, | |
isSufficientAllowance, | |
isSufficientBalance, | |
refetchAllowance, | |
refetchTokenBalance, | |
refetchNativeBalance, | |
isLoading, | |
isFetching, | |
isError, | |
tokenSymbol: tokenInfo?.symbol, | |
}; | |
}; | |
``` | |
``` | |
import React, { forwardRef } from 'react'; | |
import { Button, type ButtonProps } from '@chakra-ui/react'; | |
import { type Address, type erc20Abi, zeroAddress } from 'viem'; | |
import type { SKYBREACH_LAND_CHAIN_ID } from 'lib/constants/app'; | |
import type { DecodedContractError } from 'lib/wagmi/decode-evm-transaction-error-result'; | |
import { formatCurrencyToDecimalPlaces } from 'lib/utils/token-unit-utils/numbers-and-math'; | |
import { useErc20Approve } from 'lib/hooks/use-erc20-approve'; | |
import type { ChainToken } from 'lib/types/chain-token'; | |
import type { ButtonStylingProps } from 'components/common/modal/action-button'; | |
type Props = { | |
currency: Address | undefined; | |
amountToApprove: number; | |
contractToApprove: Address; | |
onSuccess?: () => void; | |
onError?: (error: DecodedContractError<typeof erc20Abi>) => void; | |
displayRoundingPrecision?: number; | |
width?: ButtonProps['width']; | |
size?: ButtonProps['size']; | |
isLoading?: boolean; | |
isDisabled?: boolean; | |
chainId: typeof SKYBREACH_LAND_CHAIN_ID; | |
tokenInfo: ChainToken; | |
} & ButtonStylingProps; | |
export const Erc20ApproveButton = forwardRef<HTMLButtonElement, Props>( | |
( | |
{ | |
currency, | |
amountToApprove, | |
contractToApprove, | |
onSuccess, | |
onError, | |
isLoading = false, | |
isDisabled = false, | |
displayRoundingPrecision = 4, | |
width = '100%', | |
size = 'md', | |
chainId, | |
tokenInfo, | |
}, | |
ref, | |
) => { | |
const { setApproval, isLoading: isLoadingApprovalInternal } = useErc20Approve({ | |
// AddressZero is here only for type safety, it should never actually be used, because of the `enabled` prop | |
currencyAddress: currency || zeroAddress, | |
contractToApprove, | |
amount: amountToApprove, | |
onSuccess, | |
onError, | |
enabled: !!currency, | |
chainId, | |
decimals: tokenInfo.decimals, | |
}); | |
const amountString = formatCurrencyToDecimalPlaces( | |
amountToApprove, | |
tokenInfo.symbol, | |
displayRoundingPrecision, | |
); | |
return ( | |
<Button | |
onClick={setApproval} | |
isLoading={isLoading || isLoadingApprovalInternal} | |
isDisabled={isDisabled} | |
textOverflow={'ellipsis'} | |
overflow={'hidden'} | |
width={width} | |
size={size} | |
ref={ref} | |
> | |
Approve {amountString} | |
</Button> | |
); | |
}, | |
); | |
Erc20ApproveButton.displayName = 'Erc20ApproveButton'; | |
``` | |
``` | |
import { | |
Box, | |
Button, | |
type ButtonOptions, | |
type ButtonProps, | |
type ThemingProps, | |
Tooltip, | |
} from '@chakra-ui/react'; | |
import React, { type ReactNode } from 'react'; | |
import { type Address, erc20Abi } from 'viem'; | |
import type { SKYBREACH_LAND_CHAIN_ID } from 'lib/constants/app'; | |
import { useIsSufficientTokenBalance } from 'lib/hooks/use-is-sufficient-token-balance'; | |
import type { DecodedContractError } from 'lib/wagmi/decode-evm-transaction-error-result'; | |
import { NetworkAndAccountCheckButtonWrapper } from 'components/common/network-and-account-check-button-wrapper'; | |
import { Erc20ApproveButton } from 'components/common/erc20-approve-button'; | |
import type { ChainToken } from 'lib/types/chain-token'; | |
import type { StyleProps } from '@chakra-ui/styled-system'; | |
export type ButtonStylingProps = ThemingProps<'Button'> & StyleProps & ButtonOptions; | |
type Props = { | |
children: ReactNode; | |
/** | |
* Callback to be called when the button is clicked. | |
* | |
* If the action requires spending tokens, this will be called after the token approval is successful. | |
*/ | |
onClick: () => void; | |
/** | |
* Optional callback to be called after the token approval is successful. | |
* | |
* If async, the promise will be awaited before new allowance is checked and `onClick` callback is called. | |
*/ | |
onApproveSuccess?: () => Promise<unknown> | unknown; | |
/** | |
* If the action requires spending tokens, this is the amount and currency that is required. | |
* | |
* If provided, the user will be prompted to approve the marketplace to spend the tokens before calling `onClick`. | |
*/ | |
approvalDetails?: { | |
amount: bigint; | |
currency: Address | undefined; | |
/** | |
* Contract address, which approval state will checked and which contract user will be promted to approve. | |
*/ | |
contractToApprove?: Address; | |
}; | |
/** | |
* Used for checking the token allowance and balance. | |
*/ | |
chainId: typeof SKYBREACH_LAND_CHAIN_ID; | |
/** | |
* Any additional loading conditions. This is already set internally for transactions in progress or network loading. | |
*/ | |
isLoading?: boolean; | |
/** | |
* Any additional disabled conditions. This is already set internally for wrong network, unconnected wallet, or insufficient balance. | |
*/ | |
isDisabled?: boolean; | |
/** | |
* A reason to show in the tooltip when the button is disabled. | |
*/ | |
disableReason?: string; | |
/** | |
* Called whenever an error occurs on any of the steps. | |
*/ | |
onError?: (error: Error | undefined) => void; | |
variant?: ButtonProps['variant']; | |
colorScheme?: ButtonProps['colorScheme']; | |
width?: ButtonProps['width']; | |
size?: ButtonProps['size']; | |
icon?: ButtonProps['leftIcon']; | |
hideWhenDisabled?: boolean; | |
connectButtonCopy?: string; | |
connectButtonStylingProps?: ButtonStylingProps; | |
isNativeCurrency?: boolean; | |
tokenInfo?: ChainToken; | |
}; | |
export const ActionButton = ({ | |
onClick, | |
onError, | |
onApproveSuccess: onApproveSuccessProp, | |
isLoading: isLoadingProp = false, | |
isDisabled: isDisabledProp = false, | |
disableReason: disableReasonProp, | |
variant, | |
colorScheme, | |
size = 'md', | |
width = '100%', | |
icon, | |
hideWhenDisabled = true, | |
connectButtonCopy, | |
connectButtonStylingProps = {}, | |
approvalDetails, | |
chainId, | |
children, | |
isNativeCurrency, | |
tokenInfo, | |
}: Props) => { | |
const paymentCurrency = approvalDetails?.currency; | |
const isApprovalCheckRequired = !!approvalDetails && !!approvalDetails?.contractToApprove; | |
// TODO: consider calling onError when useIsSufficientTokenBalance returns an error | |
const { | |
parsedBalanceRequired, | |
isSufficientAllowance, | |
isSufficientBalance, | |
isLoading: isLoadingTokenAllowanceOrBalance, | |
isFetching: isFetchingTokenAllowanceOrBalance, | |
tokenSymbol, | |
refetchAllowance, | |
refetchTokenBalance, | |
} = useIsSufficientTokenBalance({ | |
balanceRequired: approvalDetails?.amount || BigInt(0), | |
currencyAddress: paymentCurrency, | |
contractToApprove: approvalDetails?.contractToApprove, | |
enabled: isApprovalCheckRequired, | |
tokenInfo, | |
}); | |
// This function will be called after the token approval transaction initiated by Erc20ApproveButton is successful. | |
const onApproveSuccess = async () => { | |
setTimeout(async () => { | |
await refetchAllowance?.(); | |
refetchTokenBalance?.(); | |
await onApproveSuccessProp?.(); | |
}, 500); | |
}; | |
let actionDisableReason: string | undefined = undefined; | |
if (disableReasonProp && isDisabledProp) { | |
actionDisableReason = disableReasonProp; | |
} | |
const approveIsDisabled = (isApprovalCheckRequired && !isSufficientBalance) || isDisabledProp; | |
const actionIsDisabled = | |
isDisabledProp || | |
(!!approvalDetails?.amount && !isSufficientBalance) || | |
(!!disableReasonProp && isApprovalCheckRequired && isSufficientBalance); | |
let approveDisableReason: string | undefined = undefined; | |
if (isApprovalCheckRequired && !isSufficientBalance) { | |
approveDisableReason = `Insufficient ${tokenSymbol ?? 'unknown token'} balance`; | |
} | |
const isApprovalRequired = | |
!!approvalDetails && | |
!isSufficientAllowance && | |
!isNativeCurrency && | |
!!approvalDetails.contractToApprove; | |
if (disableReasonProp && isApprovalRequired && !approveDisableReason && !actionIsDisabled) { | |
approveDisableReason = disableReasonProp; | |
} | |
const buttonStylingProps: ThemingProps<'Button'> & StyleProps & ButtonOptions = { | |
variant, | |
colorScheme, | |
width: '100%', | |
size, | |
textOverflow: 'ellipsis', | |
overflow: 'hidden', | |
leftIcon: icon, | |
...connectButtonStylingProps, | |
}; | |
const onApproveError = (error: DecodedContractError<typeof erc20Abi>) => { | |
onError?.(new Error(error.message)); | |
}; | |
return ( | |
<NetworkAndAccountCheckButtonWrapper | |
hideWhenDisabled={hideWhenDisabled} | |
buttonProps={{ width, isLoading: isLoadingProp, ...buttonStylingProps }} | |
chainId={chainId} | |
connectButtonCopy={connectButtonCopy} | |
> | |
{({ isLoading: isNetworkLoading }) => | |
isApprovalRequired && !!approvalDetails?.contractToApprove && !!tokenInfo ? ( | |
<Tooltip label={approveDisableReason} isDisabled={!approveIsDisabled}> | |
<Box width={width}> | |
<Erc20ApproveButton | |
currency={paymentCurrency} | |
amountToApprove={parsedBalanceRequired} | |
contractToApprove={approvalDetails.contractToApprove} | |
onSuccess={onApproveSuccess} | |
onError={onApproveError} | |
isLoading={isLoadingTokenAllowanceOrBalance || isFetchingTokenAllowanceOrBalance} | |
isDisabled={approveIsDisabled} | |
width={width} | |
size={size} | |
displayRoundingPrecision={4} | |
chainId={chainId} | |
tokenInfo={tokenInfo} | |
{...buttonStylingProps} | |
/> | |
</Box> | |
</Tooltip> | |
) : ( | |
<Tooltip | |
label={actionDisableReason || approveDisableReason} | |
isDisabled={!actionIsDisabled && !approveDisableReason} | |
> | |
<Box width={width}> | |
<Button | |
onClick={onClick} | |
isLoading={isLoadingProp || isNetworkLoading} | |
isDisabled={actionIsDisabled} | |
{...buttonStylingProps} | |
> | |
{children} | |
</Button> | |
</Box> | |
</Tooltip> | |
) | |
} | |
</NetworkAndAccountCheckButtonWrapper> | |
); | |
}; | |
``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment