diff --git a/app/[locale]/about/page.tsx b/app/[locale]/about/page.tsx index 7dc16c3f4..84a8283bd 100644 --- a/app/[locale]/about/page.tsx +++ b/app/[locale]/about/page.tsx @@ -14,7 +14,7 @@ interface Props { export const dynamic = 'error'; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/address/[addressOrName]/page.tsx b/app/[locale]/address/[addressOrName]/page.tsx index 804ae0c45..0c90f0cf6 100644 --- a/app/[locale]/address/[addressOrName]/page.tsx +++ b/app/[locale]/address/[addressOrName]/page.tsx @@ -1,4 +1,5 @@ import AllowanceDashboard from 'components/allowances/dashboard/AllowanceDashboard'; +import { isNullish } from 'lib/utils'; import { getChainName } from 'lib/utils/chains'; import { shortenAddress } from 'lib/utils/formatting'; import { getAddressAndDomainName } from 'lib/utils/whois'; @@ -10,9 +11,15 @@ interface Props { locale: string; addressOrName: string; }; + searchParams: { + chainId?: string; + }; } -export const generateMetadata = async ({ params: { locale, addressOrName }, searchParams }): Promise => { +export const generateMetadata = async ({ + params: { locale, addressOrName }, + searchParams, +}: Props): Promise => { const t = await getTranslations({ locale }); const { address, domainName } = await getAddressAndDomainName(addressOrName); @@ -20,9 +27,9 @@ export const generateMetadata = async ({ params: { locale, addressOrName }, sear const chainName = getChainName(Number(searchParams.chainId || 1)); - const title = !!searchParams.chainId - ? t('address.meta.title_chain', { addressDisplay, chainName }) - : t('address.meta.title', { addressDisplay }); + const title = isNullish(searchParams.chainId) + ? t('address.meta.title', { addressDisplay }) + : t('address.meta.title_chain', { addressDisplay, chainName }); return { title, diff --git a/app/[locale]/blog/[...slug]/layout.tsx b/app/[locale]/blog/[...slug]/layout.tsx index a53381296..6fc670163 100644 --- a/app/[locale]/blog/[...slug]/layout.tsx +++ b/app/[locale]/blog/[...slug]/layout.tsx @@ -22,7 +22,7 @@ const BlogLayout = async ({ params, children }: Props) => { unstable_setRequestLocale(params.locale); const t = await getTranslations({ locale: params.locale }); - const { meta } = readAndParseContentFile(params.slug, params.locale, 'blog'); + const { meta } = readAndParseContentFile(params.slug, params.locale, 'blog')!; const posts = await getSidebar(params.locale, 'blog'); const translationUrl = await getTranslationUrl(params.slug, params.locale, 'blog'); diff --git a/app/[locale]/blog/[...slug]/page.tsx b/app/[locale]/blog/[...slug]/page.tsx index dca599201..2f4927328 100644 --- a/app/[locale]/blog/[...slug]/page.tsx +++ b/app/[locale]/blog/[...slug]/page.tsx @@ -20,7 +20,7 @@ export const generateStaticParams = () => { }; export const generateMetadata = async ({ params: { locale, slug } }: Props): Promise => { - const { meta } = readAndParseContentFile(slug, locale, 'blog'); + const { meta } = readAndParseContentFile(slug, locale, 'blog')!; return { title: meta.title, @@ -34,7 +34,7 @@ export const generateMetadata = async ({ params: { locale, slug } }: Props): Pro const BlogPostPage: NextPage = ({ params }) => { unstable_setRequestLocale(params.locale); - const { content } = readAndParseContentFile(params.slug, params.locale, 'blog'); + const { content } = readAndParseContentFile(params.slug, params.locale, 'blog')!; return ; }; diff --git a/app/[locale]/blog/page.tsx b/app/[locale]/blog/page.tsx index 35a6a3876..c2f250c9b 100644 --- a/app/[locale]/blog/page.tsx +++ b/app/[locale]/blog/page.tsx @@ -12,7 +12,7 @@ interface Props { export const dynamic = 'error'; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/disclaimer/page.tsx b/app/[locale]/disclaimer/page.tsx index 3e36a67ad..e6522d1e1 100644 --- a/app/[locale]/disclaimer/page.tsx +++ b/app/[locale]/disclaimer/page.tsx @@ -19,7 +19,7 @@ export const metadata = { const DisclaimerPage: NextPage = ({ params }) => { unstable_setRequestLocale(params.locale); - const { content } = readAndParseContentFile('disclaimer', params.locale, 'docs'); + const { content } = readAndParseContentFile('disclaimer', params.locale, 'docs')!; return ( diff --git a/app/[locale]/exploits/[slug]/ExploitChecker.tsx b/app/[locale]/exploits/[slug]/ExploitChecker.tsx index 1bcc034cb..c8e6ed0db 100644 --- a/app/[locale]/exploits/[slug]/ExploitChecker.tsx +++ b/app/[locale]/exploits/[slug]/ExploitChecker.tsx @@ -17,7 +17,7 @@ const ExploitCheckerWrapper = ({ exploit }: Props) => { return ( - + diff --git a/app/[locale]/exploits/[slug]/page.tsx b/app/[locale]/exploits/[slug]/page.tsx index bc03bb4f4..a0137f310 100644 --- a/app/[locale]/exploits/[slug]/page.tsx +++ b/app/[locale]/exploits/[slug]/page.tsx @@ -28,7 +28,7 @@ export const generateStaticParams = async () => { return locales.flatMap((locale) => exploits.map((exploit) => ({ locale, slug: exploit.slug }))); }; -export const generateMetadata = async ({ params: { locale, slug } }): Promise => { +export const generateMetadata = async ({ params: { locale, slug } }: Props): Promise => { const { exploit } = await getStaticProps({ locale, slug }); const t = await getTranslations(); diff --git a/app/[locale]/exploits/page.tsx b/app/[locale]/exploits/page.tsx index f6ab830fb..d7ae7f75b 100644 --- a/app/[locale]/exploits/page.tsx +++ b/app/[locale]/exploits/page.tsx @@ -16,7 +16,7 @@ interface Props { export const dynamic = 'error'; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); const exploits = await getAllExploits(); const { totalAmount, earliestYear } = getGlobalExploitStats(exploits); diff --git a/app/[locale]/extension/page.tsx b/app/[locale]/extension/page.tsx index e77b67b7b..05da158f5 100644 --- a/app/[locale]/extension/page.tsx +++ b/app/[locale]/extension/page.tsx @@ -16,7 +16,7 @@ interface Props { export const dynamic = 'error'; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 85075bf18..56a22864e 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -40,7 +40,7 @@ export const generateStaticParams = () => { return locales.map((locale) => ({ locale })); }; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/learn/[...slug]/page.tsx b/app/[locale]/learn/[...slug]/page.tsx index 55ddbf14b..0a26969fe 100644 --- a/app/[locale]/learn/[...slug]/page.tsx +++ b/app/[locale]/learn/[...slug]/page.tsx @@ -20,8 +20,8 @@ export const generateStaticParams = () => { return locales.flatMap((locale) => slugs.map((slug) => ({ locale, slug }))); }; -export const generateMetadata = async ({ params: { locale, slug } }): Promise => { - const { meta } = readAndParseContentFile(slug, locale, 'learn'); +export const generateMetadata = async ({ params: { locale, slug } }: Props): Promise => { + const { meta } = readAndParseContentFile(slug, locale, 'learn')!; return { title: meta.title, @@ -35,7 +35,7 @@ export const generateMetadata = async ({ params: { locale, slug } }): Promise = async ({ params }) => { unstable_setRequestLocale(params.locale); - const { content, meta } = readAndParseContentFile(params.slug, params.locale, 'learn'); + const { content, meta } = readAndParseContentFile(params.slug, params.locale, 'learn')!; const sidebar = await getSidebar(params.locale, 'learn'); const translationUrl = await getTranslationUrl(params.slug, params.locale, 'learn'); diff --git a/app/[locale]/learn/[category]/page.tsx b/app/[locale]/learn/[category]/page.tsx index b8debed0c..380b2d230 100644 --- a/app/[locale]/learn/[category]/page.tsx +++ b/app/[locale]/learn/[category]/page.tsx @@ -23,7 +23,7 @@ export const generateStaticParams = () => { return locales.flatMap((locale) => categorySlugs.map((category) => ({ locale, category }))); }; -export const generateMetadata = async ({ params: { locale, category } }): Promise => { +export const generateMetadata = async ({ params: { locale, category } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/learn/faq/page.tsx b/app/[locale]/learn/faq/page.tsx index 23d8be81a..3409cae50 100644 --- a/app/[locale]/learn/faq/page.tsx +++ b/app/[locale]/learn/faq/page.tsx @@ -16,7 +16,7 @@ interface Props { export const dynamic = 'error'; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { @@ -28,7 +28,7 @@ export const generateMetadata = async ({ params: { locale } }): Promise { +const FaqPage: NextPage = async ({ params }: Props) => { unstable_setRequestLocale(params.locale); const sidebar = await getSidebar(params.locale, 'learn'); diff --git a/app/[locale]/learn/page.tsx b/app/[locale]/learn/page.tsx index 049906078..5bfbf809c 100644 --- a/app/[locale]/learn/page.tsx +++ b/app/[locale]/learn/page.tsx @@ -15,7 +15,7 @@ interface Props { export const dynamic = 'error'; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkChainSelect.tsx b/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkChainSelect.tsx index 00752dd0d..597ea3ba6 100644 --- a/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkChainSelect.tsx +++ b/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkChainSelect.tsx @@ -10,7 +10,7 @@ interface Props { // This is a wrapper around ChainSelectHref because we cannot pass the getUrl function as a prop from a server component const AddNetworkChainSelect = ({ chainId }: Props) => { - const getUrl = useCallback((chainId) => `/learn/wallets/add-network/${getChainSlug(chainId)}`, []); + const getUrl = useCallback((chainId: number) => `/learn/wallets/add-network/${getChainSlug(chainId)}`, []); return ; }; diff --git a/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkForm.tsx b/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkForm.tsx index 977c5cf48..e52899c71 100644 --- a/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkForm.tsx +++ b/app/[locale]/learn/wallets/add-network/[slug]/AddNetworkForm.tsx @@ -20,7 +20,7 @@ const AddNetworkForm = ({ chainId }: Props) => { - +

{t('learn.add_network.step_2.paragraph_2')}

diff --git a/app/[locale]/learn/wallets/add-network/[slug]/page.tsx b/app/[locale]/learn/wallets/add-network/[slug]/page.tsx index e291e90a9..4760502da 100644 --- a/app/[locale]/learn/wallets/add-network/[slug]/page.tsx +++ b/app/[locale]/learn/wallets/add-network/[slug]/page.tsx @@ -24,7 +24,7 @@ export const generateStaticParams = () => { return locales.flatMap((locale) => slugs.map((slug) => ({ locale, slug }))); }; -export const generateMetadata = async ({ params: { locale, slug } }): Promise => { +export const generateMetadata = async ({ params: { locale, slug } }: Props): Promise => { const t = await getTranslations({ locale }); const chainId = getChainIdFromSlug(slug); const chainName = getChainName(chainId); diff --git a/app/[locale]/merchandise/page.tsx b/app/[locale]/merchandise/page.tsx index 2cbee9f60..1ce030cbb 100644 --- a/app/[locale]/merchandise/page.tsx +++ b/app/[locale]/merchandise/page.tsx @@ -13,7 +13,7 @@ interface Props { export const dynamic = 'error'; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/og.jpg/blog/[...slug]/route.tsx b/app/[locale]/og.jpg/blog/[...slug]/route.tsx index 41ac3a0cc..6ed31ca76 100644 --- a/app/[locale]/og.jpg/blog/[...slug]/route.tsx +++ b/app/[locale]/og.jpg/blog/[...slug]/route.tsx @@ -22,7 +22,7 @@ export const generateStaticParams = () => { }; export async function GET(req: Request, { params }: Props) { - const { meta } = readAndParseContentFile(params.slug, params.locale, 'blog'); + const { meta } = readAndParseContentFile(params.slug, params.locale, 'blog')!; const title = meta.overlay ? meta.sidebarTitle : undefined; const background = loadDataUrl(`public/assets/images/blog/${params.slug.join('/')}/cover.jpg`, 'image/jpeg'); diff --git a/app/[locale]/og.jpg/learn/[...slug]/route.tsx b/app/[locale]/og.jpg/learn/[...slug]/route.tsx index 962f4cfde..c1818894d 100644 --- a/app/[locale]/og.jpg/learn/[...slug]/route.tsx +++ b/app/[locale]/og.jpg/learn/[...slug]/route.tsx @@ -22,7 +22,7 @@ export const generateStaticParams = () => { }; export async function GET(req: Request, { params }: Props) { - const { meta } = readAndParseContentFile(params.slug, params.locale, 'learn'); + const { meta } = readAndParseContentFile(params.slug, params.locale, 'learn')!; const title = meta.overlay ? meta.sidebarTitle : undefined; const background = loadDataUrl(`public/assets/images/learn/${params.slug.join('/')}/cover.jpg`, 'image/jpeg'); diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 7e67e5521..ad1eda04d 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -20,7 +20,7 @@ const jsonLd = { url: 'https://revoke.cash', }; -export const generateMetadata = async ({ params: { locale } }): Promise => { +export const generateMetadata = async ({ params: { locale } }: Props): Promise => { const t = await getTranslations({ locale }); return { diff --git a/app/[locale]/privacy-policy/page.tsx b/app/[locale]/privacy-policy/page.tsx index b1c158a13..ad50aac9f 100644 --- a/app/[locale]/privacy-policy/page.tsx +++ b/app/[locale]/privacy-policy/page.tsx @@ -19,7 +19,7 @@ export const metadata = { const PrivacyPolicyPage: NextPage = ({ params }) => { unstable_setRequestLocale(params.locale); - const { content } = readAndParseContentFile('privacy-policy', params.locale, 'docs'); + const { content } = readAndParseContentFile('privacy-policy', params.locale, 'docs')!; return ( diff --git a/app/[locale]/terms/page.tsx b/app/[locale]/terms/page.tsx index 1ec9756b9..80cb1ff15 100644 --- a/app/[locale]/terms/page.tsx +++ b/app/[locale]/terms/page.tsx @@ -19,7 +19,7 @@ export const metadata = { const TermsAndConditionsPage: NextPage = ({ params }) => { unstable_setRequestLocale(params.locale); - const { content } = readAndParseContentFile('terms', params.locale, 'docs'); + const { content } = readAndParseContentFile('terms', params.locale, 'docs')!; return ( diff --git a/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerChainSelect.tsx b/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerChainSelect.tsx index 6a4d6e15d..fa896a1c7 100644 --- a/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerChainSelect.tsx +++ b/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerChainSelect.tsx @@ -10,7 +10,7 @@ interface Props { // This is a wrapper around ChainSelectHref because we cannot pass the getUrl function as a prop from a server component const TokenApprovalCheckerChainSelect = ({ chainId }: Props) => { - const getUrl = useCallback((chainId) => `/token-approval-checker/${getChainSlug(chainId)}`, []); + const getUrl = useCallback((chainId: number) => `/token-approval-checker/${getChainSlug(chainId)}`, []); return ; }; diff --git a/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerSearchBox.tsx b/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerSearchBox.tsx index fe1326d4e..81a53f959 100644 --- a/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerSearchBox.tsx +++ b/app/[locale]/token-approval-checker/[slug]/TokenApprovalCheckerSearchBox.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; interface Props { chainId: number; - placeholder?: string; + placeholder: string; } const TokenApprovalCheckerSearchBox: NextPage = ({ chainId, placeholder }) => { diff --git a/app/[locale]/token-approval-checker/[slug]/page.tsx b/app/[locale]/token-approval-checker/[slug]/page.tsx index 75e145bb1..e5890e9a5 100644 --- a/app/[locale]/token-approval-checker/[slug]/page.tsx +++ b/app/[locale]/token-approval-checker/[slug]/page.tsx @@ -26,7 +26,7 @@ export const generateStaticParams = () => { return locales.flatMap((locale) => slugs.map((slug) => ({ locale, slug }))); }; -export const generateMetadata = async ({ params: { locale, slug } }): Promise => { +export const generateMetadata = async ({ params: { locale, slug } }: Props): Promise => { const t = await getTranslations({ locale }); const chainId = getChainIdFromSlug(slug); const chainName = getChainName(chainId); diff --git a/app/api/[chainId]/merchandise/generate-code/route.tsx b/app/api/[chainId]/merchandise/generate-code/route.tsx index 53000fa45..4ceb5a552 100644 --- a/app/api/[chainId]/merchandise/generate-code/route.tsx +++ b/app/api/[chainId]/merchandise/generate-code/route.tsx @@ -65,5 +65,5 @@ export async function POST(req: NextRequest, { params }: Props) { // generate a random 9 digit code xxx-xxx-xxx const generateRandomMerchCode = () => { - return Math.random().toString().replace('.', '').slice(6, 15).match(/.{3}/g).join('-'); + return Math.random().toString().replace('.', '').slice(6, 15).match(/.{3}/g)!.join('-'); }; diff --git a/app/layout.tsx b/app/layout.tsx index edd34ab28..643de8c80 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,10 @@ import { Metadata } from 'next'; import { getTranslations } from 'next-intl/server'; +interface Props { + children: React.ReactNode; +} + export const generateMetadata = async (): Promise => { const t = await getTranslations({ locale: 'en' }); @@ -16,7 +20,7 @@ export const generateMetadata = async (): Promise => { }; }; -const RootLayout = ({ children }) => { +const RootLayout = ({ children }: Props) => { return <>{children}; }; diff --git a/components/address/AddressHeader.tsx b/components/address/AddressHeader.tsx index 719d6071d..d061dac17 100644 --- a/components/address/AddressHeader.tsx +++ b/components/address/AddressHeader.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import ChainSelect from 'components/common/select/ChainSelect'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; import { getNativeTokenPrice } from 'lib/price/utils'; +import { isNullish } from 'lib/utils'; import { usePublicClient } from 'wagmi'; import AddressDisplay from './AddressDisplay'; import AddressSocialShareButtons from './AddressSocialShareButtons'; @@ -13,18 +14,18 @@ import AddressNavigation from './navigation/AddressNavigation'; const AddressHeader = () => { const { address, domainName, selectedChainId, selectChain } = useAddressPageContext(); - const publicClient = usePublicClient({ chainId: selectedChainId }); + const publicClient = usePublicClient({ chainId: selectedChainId })!; const { data: balance, isLoading: balanceIsLoading } = useQuery({ queryKey: ['balance', address, publicClient.chain?.id], - queryFn: () => publicClient.getBalance({ address }), - enabled: !!address && !!publicClient.chain, + queryFn: () => publicClient.getBalance({ address: address! }), + enabled: !isNullish(address) && !isNullish(publicClient.chain), }); const { data: nativeAssetPrice, isLoading: nativeAssetPriceIsLoading } = useQuery({ queryKey: ['nativeAssetPrice', publicClient.chain.id], queryFn: () => getNativeTokenPrice(publicClient.chain.id, publicClient), - enabled: !!publicClient, + enabled: !isNullish(publicClient), }); return ( diff --git a/components/address/BalanceDisplay.tsx b/components/address/BalanceDisplay.tsx index f365c06d1..17e5b225e 100644 --- a/components/address/BalanceDisplay.tsx +++ b/components/address/BalanceDisplay.tsx @@ -1,5 +1,6 @@ import Loader from 'components/common/Loader'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; +import { Nullable } from 'lib/interfaces'; import { isNullish } from 'lib/utils'; import { getChainNativeToken } from 'lib/utils/chains'; import { formatFiatBalance, formatFixedPointBigInt } from 'lib/utils/formatting'; @@ -7,8 +8,8 @@ import { twMerge } from 'tailwind-merge'; interface Props { isLoading: boolean; - balance: bigint; - price?: number; + balance?: bigint; + price?: Nullable; className?: string; } @@ -25,7 +26,7 @@ const BalanceDisplay = ({ isLoading, balance, price, className }: Props) => { return (
- {formatFixedPointBigInt(balance, 18)} + {balance ? formatFixedPointBigInt(balance, 18) : null} {nativeToken} {fiatBalanceText ? ({fiatBalanceText}) : null}
diff --git a/components/address/navigation/AddressNavigation.tsx b/components/address/navigation/AddressNavigation.tsx index a071dc597..166e43e2d 100644 --- a/components/address/navigation/AddressNavigation.tsx +++ b/components/address/navigation/AddressNavigation.tsx @@ -5,7 +5,7 @@ import { useParams } from 'next/navigation'; import AddressNavigationTab from './AddressNavigationTab'; const AddressNavigation = () => { - const { addressOrName } = useParams(); + const { addressOrName } = useParams() as { addressOrName: string }; const t = useTranslations(); const basePath = `/address/${addressOrName}`; diff --git a/components/allowances/controls/ControlsSection.tsx b/components/allowances/controls/ControlsSection.tsx index f29b54006..944c1c05f 100644 --- a/components/allowances/controls/ControlsSection.tsx +++ b/components/allowances/controls/ControlsSection.tsx @@ -1,18 +1,18 @@ import RevokeButton from 'components/allowances/controls/RevokeButton'; -import { AllowanceData, TransactionSubmitted } from 'lib/interfaces'; -import { getAllowanceI18nValues } from 'lib/utils/allowances'; +import { TransactionSubmitted } from 'lib/interfaces'; +import { getAllowanceI18nValues, TokenAllowanceData } from 'lib/utils/allowances'; import ControlsWrapper from './ControlsWrapper'; import UpdateControls from './UpdateControls'; interface Props { - allowance: AllowanceData; - update?: (newAmount?: string) => Promise; + allowance: TokenAllowanceData; + update?: (newAmount: string) => Promise; reset?: () => void; - revoke?: () => Promise; + revoke?: () => Promise; } const ControlsSection = ({ allowance, revoke, update, reset }: Props) => { - if (!allowance.spender) return null; + if (!allowance.payload) return null; const { amount } = getAllowanceI18nValues(allowance); diff --git a/components/allowances/controls/ControlsWrapper.tsx b/components/allowances/controls/ControlsWrapper.tsx index 8a431b305..391d488d5 100644 --- a/components/allowances/controls/ControlsWrapper.tsx +++ b/components/allowances/controls/ControlsWrapper.tsx @@ -1,4 +1,5 @@ import WithHoverTooltip from 'components/common/WithHoverTooltip'; +import { isNullish } from 'lib/utils'; import { getChainName } from 'lib/utils/chains'; import { useTranslations } from 'next-intl'; import { ReactElement } from 'react'; @@ -9,7 +10,7 @@ interface Props { chainId: number; address: string; switchChainSize?: 'sm' | 'md' | 'lg'; - children?: (disabled: boolean) => ReactElement; + children: (disabled: boolean) => ReactElement; overrideDisabled?: boolean; disabledReason?: string; } @@ -20,13 +21,14 @@ const ControlsWrapper = ({ chainId, address, switchChainSize, children, override const chainName = getChainName(chainId); - const isConnected = !!account; + const isConnected = !isNullish(account); const isConnectedAddress = isConnected && address === account; const needsToSwitchChain = isConnected && chainId !== chain?.id; const canSwitchChain = connector?.type === 'injected'; - const isChainSwitchEnabled = switchChainSize !== undefined; + const isChainSwitchEnabled = !isNullish(switchChainSize); const shouldRenderSwitchChainButton = needsToSwitchChain && canSwitchChain && isChainSwitchEnabled; - const disabled = !isConnectedAddress || (needsToSwitchChain && !shouldRenderSwitchChainButton) || overrideDisabled; + const disabled = + !isConnectedAddress || (needsToSwitchChain && !shouldRenderSwitchChainButton) || !isNullish(overrideDisabled); if (shouldRenderSwitchChainButton) { return ; diff --git a/components/allowances/controls/RevokeButton.tsx b/components/allowances/controls/RevokeButton.tsx index cbb9926d9..29b7708e6 100644 --- a/components/allowances/controls/RevokeButton.tsx +++ b/components/allowances/controls/RevokeButton.tsx @@ -1,12 +1,12 @@ -import { AllowanceData, TransactionSubmitted } from 'lib/interfaces'; +import { TransactionSubmitted } from 'lib/interfaces'; import { useTransactionStore } from 'lib/stores/transaction-store'; -import { getAllowanceKey } from 'lib/utils/allowances'; +import { getAllowanceKey, TokenAllowanceData } from 'lib/utils/allowances'; import { useTranslations } from 'next-intl'; import Button from '../../common/Button'; interface Props { - allowance: AllowanceData; - revoke: () => Promise; + allowance: TokenAllowanceData; + revoke: () => Promise; disabled: boolean; } diff --git a/components/allowances/controls/UpdateControls.tsx b/components/allowances/controls/UpdateControls.tsx index 4694be497..a2b664276 100644 --- a/components/allowances/controls/UpdateControls.tsx +++ b/components/allowances/controls/UpdateControls.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import { useAsyncCallback } from 'react-async-hook'; interface Props { - update: (newAllowance: string) => Promise; + update: (newAllowance: string) => Promise; disabled: boolean; defaultValue?: string; reset: () => void; diff --git a/components/allowances/controls/batch-revoke/BatchRevokeControls.tsx b/components/allowances/controls/batch-revoke/BatchRevokeControls.tsx index ff7d69cc4..196b30439 100644 --- a/components/allowances/controls/batch-revoke/BatchRevokeControls.tsx +++ b/components/allowances/controls/batch-revoke/BatchRevokeControls.tsx @@ -2,14 +2,14 @@ import Button from 'components/common/Button'; import TipSection from 'components/common/donate/TipSection'; import { useDonate } from 'lib/hooks/ethereum/useDonate'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; -import { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { track } from 'lib/utils/analytics'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; import ControlsWrapper from '../ControlsWrapper'; interface Props { - selectedAllowances: AllowanceData[]; + selectedAllowances: TokenAllowanceData[]; isRevoking: boolean; isAllConfirmed: boolean; setOpen: (open: boolean) => void; @@ -24,6 +24,8 @@ const BatchRevokeControls = ({ selectedAllowances, isRevoking, isAllConfirmed, s const [tipAmount, setTipAmount] = useState(null); const revokeAndTip = async (tipAmount: string | null) => { + if (!tipAmount) throw new Error('Tip amount is required'); + const getTipSelection = () => { if (tipAmount === '0') return 'none'; if (Number(tipAmount) < Number(defaultAmount)) return 'low'; diff --git a/components/allowances/controls/batch-revoke/BatchRevokeModalWithButton.tsx b/components/allowances/controls/batch-revoke/BatchRevokeModalWithButton.tsx index a3bd1c32d..922236907 100644 --- a/components/allowances/controls/batch-revoke/BatchRevokeModalWithButton.tsx +++ b/components/allowances/controls/batch-revoke/BatchRevokeModalWithButton.tsx @@ -3,7 +3,7 @@ import Button from 'components/common/Button'; import Modal from 'components/common/Modal'; import { useRevokeBatch } from 'lib/hooks/ethereum/useRevokeBatch'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; -import { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { useTranslations } from 'next-intl'; import { useEffect, useMemo, useState } from 'react'; import ControlsWrapper from '../ControlsWrapper'; @@ -12,7 +12,7 @@ import BatchRevokeHeader from './BatchRevokeHeader'; import BatchRevokeTable from './BatchRevokeTable'; interface Props { - table: Table; + table: Table; } const BatchRevokeModalWithButton = ({ table }: Props) => { @@ -26,7 +26,7 @@ const BatchRevokeModalWithButton = ({ table }: Props) => { const { results, revoke, pause, isRevoking, isAllConfirmed } = useRevokeBatch( selectedAllowances, - table.options.meta.onUpdate, + table.options.meta!.onUpdate, ); useEffect(() => { diff --git a/components/allowances/controls/batch-revoke/BatchRevokeTable.tsx b/components/allowances/controls/batch-revoke/BatchRevokeTable.tsx index 4dd265a19..93030f861 100644 --- a/components/allowances/controls/batch-revoke/BatchRevokeTable.tsx +++ b/components/allowances/controls/batch-revoke/BatchRevokeTable.tsx @@ -2,13 +2,12 @@ import AssetCell from 'components/allowances/dashboard/cells/AssetCell'; import SpenderCell from 'components/allowances/dashboard/cells/SpenderCell'; import StatusCell from 'components/allowances/dashboard/cells/StatusCell'; import TransactionHashCell from 'components/allowances/dashboard/cells/TransactionHashCell'; -import { AllowanceData } from 'lib/interfaces'; import { TransactionResults } from 'lib/stores/transaction-store'; -import { getAllowanceKey } from 'lib/utils/allowances'; +import { getAllowanceKey, TokenAllowanceData } from 'lib/utils/allowances'; import { useTranslations } from 'next-intl'; interface Props { - selectedAllowances: AllowanceData[]; + selectedAllowances: TokenAllowanceData[]; results: TransactionResults; } diff --git a/components/allowances/dashboard/AllowanceDashboard.tsx b/components/allowances/dashboard/AllowanceDashboard.tsx index 54c6c6bd7..e944d5f0a 100644 --- a/components/allowances/dashboard/AllowanceDashboard.tsx +++ b/components/allowances/dashboard/AllowanceDashboard.tsx @@ -4,19 +4,20 @@ import { getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable import { ColumnId, columns } from 'components/allowances/dashboard/columns'; import Table from 'components/common/table/Table'; import { useAddressAllowances } from 'lib/hooks/page-context/AddressPageContext'; -import type { AllowanceData } from 'lib/interfaces'; +import { isNullish } from 'lib/utils'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { useEffect, useMemo, useState } from 'react'; import NoAllowancesFound from './NoAllowancesFound'; import AllowanceTableControls from './controls/AllowanceTableControls'; -const getRowId = (row: AllowanceData) => { - return `${row.chainId}-${row.contract.address}-${row.spender}-${row.tokenId}`; +const getRowId = (row: TokenAllowanceData) => { + return `${row.chainId}-${row.contract.address}-${row.payload?.spender}-${(row.payload as any)?.tokenId}`; }; const AllowanceDashboard = () => { const { allowances, isLoading, error, onUpdate } = useAddressAllowances(); - const [rowSelection, setRowSelection] = useState({}); + const [rowSelection, setRowSelection] = useState>({}); // We fall back to an empty array because the table crashes if the data is undefined // and we use useMemo to prevent the table from infinite re-rendering @@ -27,7 +28,7 @@ const AllowanceDashboard = () => { // When rows are deleted, the row selection state is not updated automatically (see https://github.com/TanStack/table/issues/4369) // This effect manually syncs the row selection state with the new table data useEffect(() => { - setRowSelection((currentSelection) => { + setRowSelection((currentSelection: Record) => { if (!data || data.length === 0) return {}; if (Object.keys(currentSelection).length === 0) return {}; @@ -45,11 +46,11 @@ const AllowanceDashboard = () => { rowSelection, }, debugTable: true, - enableRowSelection: (row) => row.original.spender !== undefined, + enableRowSelection: (row) => !isNullish(row.original.payload), onRowSelectionChange: setRowSelection, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFilteredRowModel: getFilteredRowModel(), + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), getRowId, // TODO: Because of declaration merging in @tanstack/table-core we can't have multiple custom fields and need to type as any // See https://github.com/TanStack/table/discussions/4220 @@ -69,7 +70,7 @@ const AllowanceDashboard = () => { table={table} loading={isLoading} error={error} - emptyChildren={} + emptyChildren={} /> ); diff --git a/components/allowances/dashboard/NoAllowancesFound.tsx b/components/allowances/dashboard/NoAllowancesFound.tsx index f45105adc..57673a6c6 100644 --- a/components/allowances/dashboard/NoAllowancesFound.tsx +++ b/components/allowances/dashboard/NoAllowancesFound.tsx @@ -1,8 +1,8 @@ -import type { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { useTranslations } from 'next-intl'; interface Props { - allowances: Array; + allowances: Array; } const NoAllowancesFound = ({ allowances }: Props) => { diff --git a/components/allowances/dashboard/cells/AllowanceCell.tsx b/components/allowances/dashboard/cells/AllowanceCell.tsx index 2b6a233e1..47d9ea118 100644 --- a/components/allowances/dashboard/cells/AllowanceCell.tsx +++ b/components/allowances/dashboard/cells/AllowanceCell.tsx @@ -3,8 +3,13 @@ import ControlsWrapper from 'components/allowances/controls/ControlsWrapper'; import Button from 'components/common/Button'; import WithHoverTooltip from 'components/common/WithHoverTooltip'; import { useRevoke } from 'lib/hooks/ethereum/useRevoke'; -import type { AllowanceData, OnUpdate } from 'lib/interfaces'; -import { getAllowanceI18nValues } from 'lib/utils/allowances'; +import { + AllowanceType, + getAllowanceI18nValues, + isErc20Allowance, + OnUpdate, + TokenAllowanceData, +} from 'lib/utils/allowances'; import { SECOND } from 'lib/utils/time'; import { useLocale, useTranslations } from 'next-intl'; import { useState } from 'react'; @@ -13,7 +18,7 @@ import * as timeago from 'timeago.js'; import ControlsSection from '../../controls/ControlsSection'; interface Props { - allowance: AllowanceData; + allowance: TokenAllowanceData; onUpdate: OnUpdate; } @@ -25,7 +30,7 @@ const AllowanceCell = ({ allowance, onUpdate }: Props) => { const { i18nKey, amount, tokenId, symbol } = getAllowanceI18nValues(allowance); const classes = twMerge( - !allowance.spender && 'text-zinc-500 dark:text-zinc-400', + !allowance.payload && 'text-zinc-500 dark:text-zinc-400', 'flex items-center gap-2', ['ru', 'es'].includes(locale) ? 'w-48' : 'w-40', ); @@ -38,7 +43,10 @@ const AllowanceCell = ({ allowance, onUpdate }: Props) => { ); } - const inTime = allowance.expiration > 0 ? timeago.format(allowance.expiration * SECOND, locale) : null; + const inTime = + allowance.payload?.type === AllowanceType.PERMIT2 + ? timeago.format(allowance.payload.expiration * SECOND, locale) + : null; return (
@@ -52,7 +60,7 @@ const AllowanceCell = ({ allowance, onUpdate }: Props) => { ) : null}
- {allowance.amount && ( + {isErc20Allowance(allowance.payload) && ( {(disabled) => (
diff --git a/components/allowances/dashboard/cells/AssetCell.tsx b/components/allowances/dashboard/cells/AssetCell.tsx index 3d943c4d8..5d81e6a5c 100644 --- a/components/allowances/dashboard/cells/AssetCell.tsx +++ b/components/allowances/dashboard/cells/AssetCell.tsx @@ -3,17 +3,17 @@ import ChainOverlayLogo from 'components/common/ChainOverlayLogo'; import Href from 'components/common/Href'; import WithHoverTooltip from 'components/common/WithHoverTooltip'; -import type { BaseTokenData } from 'lib/interfaces'; import { getChainExplorerUrl } from 'lib/utils/chains'; import { formatBalance, formatFiatBalance } from 'lib/utils/formatting'; +import { TokenData } from 'lib/utils/tokens'; import { useLayoutEffect, useRef, useState } from 'react'; interface Props { - asset: BaseTokenData; + asset: TokenData; } const AssetCell = ({ asset }: Props) => { - const ref = useRef(null); + const ref = useRef(null); const [showTooltip, setShowTooltip] = useState(false); // This is pretty hacky, but it works to detect that we're on the address page, so single chain usage diff --git a/components/allowances/dashboard/cells/ControlsCell.tsx b/components/allowances/dashboard/cells/ControlsCell.tsx index ad2808dfe..8a28a8d68 100644 --- a/components/allowances/dashboard/cells/ControlsCell.tsx +++ b/components/allowances/dashboard/cells/ControlsCell.tsx @@ -1,9 +1,9 @@ import { useRevoke } from 'lib/hooks/ethereum/useRevoke'; -import { AllowanceData, OnUpdate } from 'lib/interfaces'; +import { OnUpdate, TokenAllowanceData } from 'lib/utils/allowances'; import ControlsSection from '../../controls/ControlsSection'; interface Props { - allowance: AllowanceData; + allowance: TokenAllowanceData; onUpdate: OnUpdate; } diff --git a/components/allowances/dashboard/cells/GlobalSelectCell.tsx b/components/allowances/dashboard/cells/GlobalSelectCell.tsx index b10d8cd72..9291b4518 100644 --- a/components/allowances/dashboard/cells/GlobalSelectCell.tsx +++ b/components/allowances/dashboard/cells/GlobalSelectCell.tsx @@ -2,10 +2,10 @@ import { Table } from '@tanstack/react-table'; import ControlsWrapper from 'components/allowances/controls/ControlsWrapper'; import Checkbox from 'components/common/Checkbox'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; -import { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; interface Props { - table: Table; + table: Table; } const GlobalSelectCell = ({ table }: Props) => { diff --git a/components/allowances/dashboard/cells/LastUpdatedCell.tsx b/components/allowances/dashboard/cells/LastUpdatedCell.tsx index 565760bcc..cf483da7c 100644 --- a/components/allowances/dashboard/cells/LastUpdatedCell.tsx +++ b/components/allowances/dashboard/cells/LastUpdatedCell.tsx @@ -1,7 +1,7 @@ import Href from 'components/common/Href'; import WithHoverTooltip from 'components/common/WithHoverTooltip'; -import type { TimeLog } from 'lib/interfaces'; import { getChainExplorerUrl } from 'lib/utils/chains'; +import { TimeLog } from 'lib/utils/events'; import { SECOND, formatDateNormalised } from 'lib/utils/time'; import { useLocale, useTranslations } from 'next-intl'; import TimeAgo from 'timeago-react'; @@ -15,7 +15,7 @@ const LastUpdatedCell = ({ chainId, lastUpdated }: Props) => { const t = useTranslations(); const locale = useLocale(); - if (!lastUpdated) return null; + if (!lastUpdated?.timestamp) return null; const lastUpdatedDate = new Date(lastUpdated.timestamp * SECOND); const explorerUrl = getChainExplorerUrl(chainId); diff --git a/components/allowances/dashboard/cells/SelectCell.tsx b/components/allowances/dashboard/cells/SelectCell.tsx index 1fa6e428d..9d42f2ea5 100644 --- a/components/allowances/dashboard/cells/SelectCell.tsx +++ b/components/allowances/dashboard/cells/SelectCell.tsx @@ -2,10 +2,10 @@ import { Row } from '@tanstack/react-table'; import ControlsWrapper from 'components/allowances/controls/ControlsWrapper'; import Checkbox from 'components/common/Checkbox'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; -import { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; interface Props { - row: Row; + row: Row; } const SelectCell = ({ row }: Props) => { diff --git a/components/allowances/dashboard/cells/SpenderCell.tsx b/components/allowances/dashboard/cells/SpenderCell.tsx index ba45de0a0..e15e8f613 100644 --- a/components/allowances/dashboard/cells/SpenderCell.tsx +++ b/components/allowances/dashboard/cells/SpenderCell.tsx @@ -3,45 +3,47 @@ import CopyButton from 'components/common/CopyButton'; import Href from 'components/common/Href'; import Loader from 'components/common/Loader'; import WithHoverTooltip from 'components/common/WithHoverTooltip'; -import type { AllowanceData } from 'lib/interfaces'; +import { isNullish } from 'lib/utils'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { getChainExplorerUrl } from 'lib/utils/chains'; import { shortenAddress } from 'lib/utils/formatting'; import { getSpenderData } from 'lib/utils/whois'; import RiskTooltip from '../wallet-health/RiskTooltip'; interface Props { - allowance: AllowanceData; + allowance: TokenAllowanceData; } const SpenderCell = ({ allowance }: Props) => { // TODO: Expose this data to react-table const { data: spenderData, isLoading } = useQuery({ - queryKey: ['spenderData', allowance.spender, allowance.chainId], - queryFn: () => getSpenderData(allowance.spender, allowance.chainId), + queryKey: ['spenderData', allowance.payload?.spender, allowance.chainId], + queryFn: () => getSpenderData(allowance.payload!.spender, allowance.chainId), // Chances of this data changing while the user is on the page are very slim staleTime: Infinity, + enabled: !isNullish(allowance.payload?.spender), }); - const explorerUrl = `${getChainExplorerUrl(allowance.chainId)}/address/${allowance.spender}`; + const explorerUrl = `${getChainExplorerUrl(allowance.chainId)}/address/${allowance.payload?.spender}`; - if (!allowance.spender) { - return null; - } + if (!allowance.payload) return null; return (
- + -
{spenderData?.name ?? shortenAddress(allowance.spender, 6)}
+
+ {spenderData?.name ?? shortenAddress(allowance.payload.spender, 6)} +
- {spenderData?.name ? shortenAddress(allowance.spender, 6) : null} + {spenderData?.name ? shortenAddress(allowance.payload.spender, 6) : null}
- +
diff --git a/components/allowances/dashboard/cells/TransactionHashCell.tsx b/components/allowances/dashboard/cells/TransactionHashCell.tsx index 04620ff2b..78e4b675e 100644 --- a/components/allowances/dashboard/cells/TransactionHashCell.tsx +++ b/components/allowances/dashboard/cells/TransactionHashCell.tsx @@ -5,7 +5,7 @@ import { shortenAddress } from 'lib/utils/formatting'; interface Props { chainId: number; - transactionHash: string; + transactionHash?: string; } const TransactionHashCell = ({ chainId, transactionHash }: Props) => { diff --git a/components/allowances/dashboard/cells/ValueAtRiskCell.tsx b/components/allowances/dashboard/cells/ValueAtRiskCell.tsx index 17108fa6f..db183e063 100644 --- a/components/allowances/dashboard/cells/ValueAtRiskCell.tsx +++ b/components/allowances/dashboard/cells/ValueAtRiskCell.tsx @@ -1,17 +1,16 @@ -import type { AllowanceData } from 'lib/interfaces'; -import { calculateValueAtRisk } from 'lib/utils'; +import { TokenAllowanceData, calculateValueAtRisk } from 'lib/utils/allowances'; import { formatFiatAmount } from 'lib/utils/formatting'; import { useTranslations } from 'next-intl'; import { twMerge } from 'tailwind-merge'; interface Props { - allowance: AllowanceData; + allowance: TokenAllowanceData; } const ValueAtRiskCell = ({ allowance }: Props) => { const t = useTranslations(); - if (!allowance.spender) return null; + if (!allowance.payload) return null; const valueAtRisk = calculateValueAtRisk(allowance); const fiatBalanceText = formatFiatAmount(valueAtRisk); diff --git a/components/allowances/dashboard/columns.tsx b/components/allowances/dashboard/columns.tsx index 47cfd5e18..3fea1301f 100644 --- a/components/allowances/dashboard/columns.tsx +++ b/components/allowances/dashboard/columns.tsx @@ -1,7 +1,13 @@ import { createColumnHelper, filterFns, Row, RowData, sortingFns } from '@tanstack/react-table'; -import { AllowanceData, OnUpdate } from 'lib/interfaces'; -import { calculateValueAtRisk, isNullish } from 'lib/utils'; -import { formatErc20Allowance } from 'lib/utils/allowances'; +import { isNullish } from 'lib/utils'; +import { + AllowanceType, + calculateValueAtRisk, + formatErc20Allowance, + isErc20Allowance, + OnUpdate, + TokenAllowanceData, +} from 'lib/utils/allowances'; import { formatFixedPointBigInt } from 'lib/utils/formatting'; import { isErc721Contract } from 'lib/utils/tokens'; import BatchRevokeModalWithButton from '../controls/batch-revoke/BatchRevokeModalWithButton'; @@ -35,27 +41,35 @@ export enum ColumnId { } export const accessors = { - allowance: (allowance: AllowanceData) => { - if (!allowance.spender) return undefined; + allowance: (allowance: TokenAllowanceData) => { + if (!allowance.payload) return undefined; - if (allowance.amount) { - return formatErc20Allowance(allowance.amount, allowance.metadata.decimals, allowance.metadata.totalSupply); + if (isErc20Allowance(allowance.payload)) { + return formatErc20Allowance( + allowance.payload.amount, + allowance.metadata.decimals, + allowance.metadata.totalSupply, + ); } - return allowance.tokenId ?? 'Unlimited'; + if (allowance.payload.type === AllowanceType.ERC721_SINGLE) { + return allowance.payload.tokenId; + } + + return 'Unlimited'; }, - balance: (allowance: AllowanceData) => { + balance: (allowance: TokenAllowanceData) => { return allowance.balance === 'ERC1155' ? 'ERC1155' : formatFixedPointBigInt(allowance.balance, allowance.metadata.decimals); }, - assetType: (allowance: AllowanceData) => { + assetType: (allowance: TokenAllowanceData) => { if (isErc721Contract(allowance.contract)) return 'NFT'; return 'Token'; }, - valueAtRisk: (allowance: AllowanceData) => { + valueAtRisk: (allowance: TokenAllowanceData) => { // No approvals should be sorted separately through `sortUndefined` - if (!allowance.spender) return undefined; + if (!allowance.payload) return undefined; // No balance means no risk (even if we don't know the price) if (allowance.balance === 0n) return 0; @@ -69,10 +83,10 @@ export const accessors = { }; export const customSortingFns = { - timestamp: (rowA: Row, rowB: Row, columnId: string) => { + timestamp: (rowA: Row, rowB: Row, columnId: string) => { return sortingFns.basic(rowA, rowB, columnId); }, - allowance: (rowA: Row, rowB: Row, columnId: string) => { + allowance: (rowA: Row, rowB: Row, columnId: string) => { if (rowA.getValue(columnId) === rowB.getValue(columnId)) return 0; if (rowA.getValue(columnId) === 'Unlimited') return 1; if (rowB.getValue(columnId) === 'Unlimited') return -1; @@ -81,14 +95,14 @@ export const customSortingFns = { }; export const customFilterFns = { - assetType: (row: Row, columnId: string, filterValues: string[]) => { + assetType: (row: Row, columnId: string, filterValues: string[]) => { const results = filterValues.map((filterValue) => { return row.getValue(columnId) === filterValue; }); return results.some((result) => result); }, - balance: (row: Row, columnId: string, filterValues: string[]) => { + balance: (row: Row, columnId: string, filterValues: string[]) => { const results = filterValues.map((filterValue) => { if (filterValue === 'Zero') return row.getValue(columnId) === '0'; if (filterValue === 'Non-Zero') return row.getValue(columnId) !== '0'; @@ -96,7 +110,7 @@ export const customFilterFns = { return results.some((result) => result); }, - allowance: (row: Row, columnId: string, filterValues: string[]) => { + allowance: (row: Row, columnId: string, filterValues: string[]) => { const results = filterValues.map((filterValue) => { if (filterValue === 'Unlimited') return row.getValue(columnId) === 'Unlimited'; if (filterValue === 'None') return row.getValue(columnId) === undefined; @@ -107,7 +121,7 @@ export const customFilterFns = { return results.some((result) => result); }, - spender: (row: Row, columnId: string, filterValues: string[]) => { + spender: (row: Row, columnId: string, filterValues: string[]) => { const results = filterValues.map((filterValue) => { return filterFns.includesString(row, columnId, filterValue, () => {}); }); @@ -116,7 +130,7 @@ export const customFilterFns = { }, }; -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper(); export const columns = [ columnHelper.display({ id: ColumnId.SELECT, @@ -149,7 +163,7 @@ export const columns = [ columnHelper.accessor(accessors.allowance, { id: ColumnId.ALLOWANCE, header: () => , - cell: (info) => , + cell: (info) => , enableSorting: true, sortingFn: customSortingFns.allowance, sortUndefined: 'last', @@ -164,7 +178,7 @@ export const columns = [ sortingFn: sortingFns.basic, sortUndefined: 'last', }), - columnHelper.accessor('spender', { + columnHelper.accessor('payload.spender', { id: ColumnId.SPENDER, header: () => , cell: (info) => , @@ -172,10 +186,12 @@ export const columns = [ enableColumnFilter: true, filterFn: customFilterFns.spender, }), - columnHelper.accessor('lastUpdated.timestamp', { + columnHelper.accessor('payload.lastUpdated.timestamp', { id: ColumnId.LAST_UPDATED, header: () => , - cell: (info) => , + cell: (info) => ( + + ), enableSorting: true, sortingFn: customSortingFns.timestamp, sortUndefined: 'last', @@ -183,6 +199,6 @@ export const columns = [ columnHelper.display({ id: ColumnId.ACTIONS, header: () => , - cell: (info) => , + cell: (info) => , }), ]; diff --git a/components/allowances/dashboard/controls/AllowanceSearchBox.tsx b/components/allowances/dashboard/controls/AllowanceSearchBox.tsx index a6b013fd7..9bdca2581 100644 --- a/components/allowances/dashboard/controls/AllowanceSearchBox.tsx +++ b/components/allowances/dashboard/controls/AllowanceSearchBox.tsx @@ -5,7 +5,7 @@ import { Table } from '@tanstack/react-table'; import Button from 'components/common/Button'; import FocusTrap from 'components/common/FocusTrap'; import SearchBox from 'components/common/SearchBox'; -import { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { updateTableFilters } from 'lib/utils/table'; import { useTranslations } from 'next-intl'; import { useSearchParams } from 'next/navigation'; @@ -13,11 +13,11 @@ import { ChangeEventHandler, useEffect, useState } from 'react'; import { ColumnId } from '../columns'; interface Props { - table: Table; + table: Table; } const AllowanceSearchBox = ({ table }: Props) => { - const searchParams = useSearchParams(); + const searchParams = useSearchParams()!; const t = useTranslations(); const [searchValues, setSearchValues] = useState([]); diff --git a/components/allowances/dashboard/controls/AllowanceTableControls.tsx b/components/allowances/dashboard/controls/AllowanceTableControls.tsx index 2ef00ee2a..096305407 100644 --- a/components/allowances/dashboard/controls/AllowanceTableControls.tsx +++ b/components/allowances/dashboard/controls/AllowanceTableControls.tsx @@ -1,6 +1,6 @@ import { Table } from '@tanstack/react-table'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; -import type { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { Suspense } from 'react'; import WalletHealthSection from '../wallet-health/WalletHealthSection'; import AllowanceSearchBox from './AllowanceSearchBox'; @@ -8,7 +8,7 @@ import FilterSelect from './FilterSelect'; import SortSelect from './SortSelect'; interface Props { - table: Table; + table: Table; } const AllowanceTableControls = ({ table }: Props) => { diff --git a/components/allowances/dashboard/controls/FilterSelect.tsx b/components/allowances/dashboard/controls/FilterSelect.tsx index e6aff47e8..78460a5c2 100644 --- a/components/allowances/dashboard/controls/FilterSelect.tsx +++ b/components/allowances/dashboard/controls/FilterSelect.tsx @@ -4,12 +4,12 @@ import Label from 'components/common/Label'; import Select from 'components/common/select/Select'; import { useColorTheme } from 'lib/hooks/useColorTheme'; import { useMounted } from 'lib/hooks/useMounted'; -import { AllowanceData } from 'lib/interfaces'; import { normaliseLabel } from 'lib/utils'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { updateTableFilters } from 'lib/utils/table'; import { useTranslations } from 'next-intl'; import { useEffect } from 'react'; -import { FormatOptionLabelMeta } from 'react-select'; +import { FormatOptionLabelMeta, ValueContainerProps } from 'react-select'; import useLocalStorage from 'use-local-storage'; import { ColumnId } from '../columns'; @@ -29,7 +29,7 @@ interface OptionGroupWithSelected extends OptionGroup { } interface Props { - table: Table; + table: Table; } const options = [ @@ -75,7 +75,7 @@ const FilterSelect = ({ table }: Props) => { const displayOption = (option: Option, { selectValue }: FormatOptionLabelMeta
)} {image ?
{image}
: null} - +
{children}
diff --git a/components/common/ChainDescription.tsx b/components/common/ChainDescription.tsx index c3dcecfd8..cb7a7cc3b 100644 --- a/components/common/ChainDescription.tsx +++ b/components/common/ChainDescription.tsx @@ -1,3 +1,4 @@ +import { isNullish } from 'lib/utils'; import { CHAIN_SELECT_TESTNETS, getChainInfoUrl, @@ -33,7 +34,7 @@ const ChainDescription = ({ chainId, headingElement }: Props) => { return ( <> - {!!headingElement && createElement(headingElement, {}, t('networks.title', { chainName }))} + {!isNullish(headingElement) && createElement(headingElement, {}, t('networks.title', { chainName }))}

{isTestnet && {t(`networks.is_testnet`, { chainName, mainnetChainName })} } diff --git a/components/common/ChainOverlayLogo.tsx b/components/common/ChainOverlayLogo.tsx index 9a6cfbbe3..3b6346680 100644 --- a/components/common/ChainOverlayLogo.tsx +++ b/components/common/ChainOverlayLogo.tsx @@ -2,7 +2,7 @@ import ChainLogo from './ChainLogo'; import Logo from './Logo'; interface Props { - src: string; + src?: string; alt: string; chainId?: number; size?: number; diff --git a/components/common/ImageWithFallback.tsx b/components/common/ImageWithFallback.tsx index b0fb72354..6ba576745 100644 --- a/components/common/ImageWithFallback.tsx +++ b/components/common/ImageWithFallback.tsx @@ -8,7 +8,7 @@ interface Props extends ImageProps { } const ImageWithFallback = ({ fallbackSrc, ...props }: Props) => { - const [src, setSrc] = useState(props.src); + const [src, setSrc] = useState(props.src ?? fallbackSrc); return ( { lg: 'h-12 px-6 text-lg rounded-xl', }; - const classes = twMerge(classMapping.common, size !== 'none' && classMapping[size], className); + const classes = twMerge(classMapping.common, size && size !== 'none' && classMapping[size], className); return ; }; diff --git a/components/common/Logo.tsx b/components/common/Logo.tsx index 3b3916e0b..fc309c1df 100644 --- a/components/common/Logo.tsx +++ b/components/common/Logo.tsx @@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'; import PlaceholderIcon from './PlaceholderIcon'; interface Props { - src: string; + src?: string; alt: string; size?: number; square?: boolean; diff --git a/components/common/MarkdownProse.tsx b/components/common/MarkdownProse.tsx index 0c9ebf89f..4182269c9 100644 --- a/components/common/MarkdownProse.tsx +++ b/components/common/MarkdownProse.tsx @@ -30,11 +30,11 @@ const MarkdownProse = ({ content, className }: Props) => { a: ({ href, children, rel }) => { return ( {children} diff --git a/components/common/TransactionSubmittedToast.tsx b/components/common/TransactionSubmittedToast.tsx index ec85a563f..fe553d93c 100644 --- a/components/common/TransactionSubmittedToast.tsx +++ b/components/common/TransactionSubmittedToast.tsx @@ -1,18 +1,15 @@ import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'; -import DonateButton from 'components/common/donate/DonateButton'; import { getChainExplorerUrl } from 'lib/utils/chains'; import { useTranslations } from 'next-intl'; -import type { MutableRefObject } from 'react'; -import { Id, toast } from 'react-toastify'; +import { toast } from 'react-toastify'; import Href from './Href'; interface Props { chainId: number; transactionHash: string; - ref: MutableRefObject; } -const TransactionSubmittedToast = ({ chainId, transactionHash, ref }: Props) => { +const TransactionSubmittedToast = ({ chainId, transactionHash }: Props) => { const t = useTranslations(); const explorerUrl = getChainExplorerUrl(chainId); @@ -25,25 +22,14 @@ const TransactionSubmittedToast = ({ chainId, transactionHash, ref }: Props) => -

- -
); }; export default TransactionSubmittedToast; -export const displayTransactionSubmittedToast = ( - chainId: number, - transactionHash: string, - ref: MutableRefObject, -) => { - ref.current = toast.info( - , - { - closeOnClick: false, - }, - ); - console.log(ref.current); +export const displayTransactionSubmittedToast = (chainId: number, transactionHash: string) => { + toast.info(, { + closeOnClick: false, + }); }; diff --git a/components/common/donate/DonateButton.tsx b/components/common/donate/DonateButton.tsx index ac8bdaa35..6df778898 100644 --- a/components/common/donate/DonateButton.tsx +++ b/components/common/donate/DonateButton.tsx @@ -2,32 +2,25 @@ import Button from 'components/common/Button'; import { useTranslations } from 'next-intl'; -import type { MutableRefObject, ReactText } from 'react'; import { useState } from 'react'; -import { toast } from 'react-toastify'; import DonateModal, { DonateButtonType } from './DonateModal'; interface Props { size: 'sm' | 'md' | 'lg' | 'none' | 'menu'; style?: 'primary' | 'secondary' | 'tertiary' | 'none'; className?: string; - parentToastRef?: MutableRefObject; type: DonateButtonType; } -const DonateButton = ({ size, style, className, parentToastRef, type }: Props) => { +const DonateButton = ({ size, style, className, type }: Props) => { const t = useTranslations(); const [open, setOpen] = useState(false); const handleOpen = () => { - if (parentToastRef) { - toast.update(parentToastRef.current, { autoClose: false, closeButton: false, draggable: false }); - } setOpen(true); }; const handleClose = () => { - if (parentToastRef) toast.dismiss(parentToastRef.current); setOpen(false); }; diff --git a/components/common/donate/DonateModal.tsx b/components/common/donate/DonateModal.tsx index 8e9f97a1f..e0bbd553f 100644 --- a/components/common/donate/DonateModal.tsx +++ b/components/common/donate/DonateModal.tsx @@ -4,7 +4,6 @@ import { Dialog } from '@headlessui/react'; import Button from 'components/common/Button'; import Modal from 'components/common/Modal'; import { useDonate } from 'lib/hooks/ethereum/useDonate'; -import { getDefaultDonationAmount } from 'lib/utils/chains'; import { useTranslations } from 'next-intl'; import { useEffect, useState } from 'react'; import { useAsyncCallback } from 'react-async-hook'; @@ -17,7 +16,7 @@ interface Props { type: DonateButtonType; } -export type DonateButtonType = 'transaction-toast' | 'menu-button' | 'batch-revoke-tip'; +export type DonateButtonType = 'menu-button' | 'batch-revoke-tip'; const DonateModal = ({ open, setOpen, type }: Props) => { const t = useTranslations(); @@ -27,8 +26,8 @@ const DonateModal = ({ open, setOpen, type }: Props) => { const [amount, setAmount] = useState(defaultAmount); useEffect(() => { - setAmount(getDefaultDonationAmount(nativeToken)); - }, [nativeToken]); + setAmount(defaultAmount); + }, [defaultAmount]); const sendDonation = async () => { try { diff --git a/components/common/donate/TipSection.tsx b/components/common/donate/TipSection.tsx index 31a46195d..7a1032c83 100644 --- a/components/common/donate/TipSection.tsx +++ b/components/common/donate/TipSection.tsx @@ -56,7 +56,7 @@ const TipSection = ({ midAmount, nativeToken, onSelect }: Props) => { { loading: boolean; table: ReactTable; - error?: Error; + error?: Nullable; emptyChildren?: React.ReactNode; loaderRows?: number; className?: string; diff --git a/components/exploits/AddressForm.tsx b/components/exploits/AddressForm.tsx index 00902be81..dbc8532f4 100644 --- a/components/exploits/AddressForm.tsx +++ b/components/exploits/AddressForm.tsx @@ -25,10 +25,10 @@ export const AddressForm = ({ chainIds, onSubmit }: Props) => { const { selectedChainId, selectChain } = useAddressPageContext(); const handleSubmit = async (event: React.FormEvent) => { - const address = event.target[0].value; - const parsedAddress = await parseInputAddress(address); + const inputElement = (event.target as HTMLFormElement)[0] as HTMLInputElement; + const address = await parseInputAddress(inputElement.value); - onSubmit(parsedAddress); + if (address) onSubmit(address); }; const onFocus = () => { @@ -41,8 +41,10 @@ export const AddressForm = ({ chainIds, onSubmit }: Props) => { }; const onClick = () => { - setValue(address); - handleSubmit({ target: [{ value: address }] } as any); + if (address) { + setValue(address); + handleSubmit({ target: [{ value: address }] } as any); + } }; return ( diff --git a/components/exploits/ExploitChecker.tsx b/components/exploits/ExploitChecker.tsx index 798cc4cee..b31b1f300 100644 --- a/components/exploits/ExploitChecker.tsx +++ b/components/exploits/ExploitChecker.tsx @@ -8,6 +8,7 @@ import { useAddressEvents, useAddressPageContext, } from 'lib/hooks/page-context/AddressPageContext'; +import { isNullish } from 'lib/utils'; import { getAllowanceKey } from 'lib/utils/allowances'; import { track } from 'lib/utils/analytics'; import { getEventKey } from 'lib/utils/events'; @@ -27,11 +28,11 @@ const ExploitChecker = ({ exploit }: Props) => { const { data: status } = useQuery({ queryKey: ['exploit-status', exploit.slug, allowances?.map(getAllowanceKey), events?.map(getEventKey)], queryFn: () => { - const status = getExploitStatus(events, allowances, exploit); + const status = getExploitStatus(events!, allowances!, exploit); track('Exploit Checked', { exploit: exploit.slug, account: address, chainId: selectedChainId, status }); return status; }, - enabled: !!address && !!allowances && !!selectedChainId, + enabled: !isNullish(address) && !isNullish(events) && !isNullish(allowances) && !isNullish(selectedChainId), }); if (!address || !selectedChainId) { diff --git a/components/footer/ColorThemeSelect.tsx b/components/footer/ColorThemeSelect.tsx index f4e476973..5ed88a120 100644 --- a/components/footer/ColorThemeSelect.tsx +++ b/components/footer/ColorThemeSelect.tsx @@ -39,10 +39,11 @@ const ColorThemeSelect = () => { menuTheme="dark" value={options.find((option) => option.value === theme)} options={options} - onChange={selectTheme} + onChange={(option) => selectTheme(option!)} formatOptionLabel={displayOption} menuPlacement="top" isSearchable={false} + isMulti={false} /> ); }; diff --git a/components/footer/LanguageSelect.tsx b/components/footer/LanguageSelect.tsx index ad94969bb..ab6886ddc 100644 --- a/components/footer/LanguageSelect.tsx +++ b/components/footer/LanguageSelect.tsx @@ -60,11 +60,11 @@ const LanguageSelect = () => { menuTheme="dark" value={options.find((option) => option.value === locale)} options={options} - onChange={selectLanguage} + onChange={(option) => selectLanguage(option!)} formatOptionLabel={displayOption} menuPlacement="top" isSearchable={false} - size="md" + isMulti={false} /> ); }; diff --git a/components/header/ConnectButton.tsx b/components/header/ConnectButton.tsx index 6bfa13dc3..09b5bf57c 100644 --- a/components/header/ConnectButton.tsx +++ b/components/header/ConnectButton.tsx @@ -12,7 +12,7 @@ import { Connector, useAccount, useConnect } from 'wagmi'; interface Props { text?: string; - size: 'sm' | 'md' | 'lg' | 'none'; + size?: 'sm' | 'md' | 'lg' | 'none'; style?: 'primary' | 'secondary' | 'tertiary' | 'none'; className?: string; redirect?: boolean; diff --git a/components/learn/ArticleCard.tsx b/components/learn/ArticleCard.tsx index d0720775a..e04485459 100644 --- a/components/learn/ArticleCard.tsx +++ b/components/learn/ArticleCard.tsx @@ -14,7 +14,7 @@ const ArticleCard = ({ title, description, path, date, readingTime, coverImage } className="flex flex-col justify-between gap-4" image={ { const properties = [ - !!meta.author ? 'author' : undefined, - !!meta.translator && meta.language !== 'en' ? 'translator' : undefined, - !!meta.date ? 'date' : undefined, - !!meta.readingTime && !!meta.author ? 'reading_time' : undefined, // reading_time only if author is present (blog posts only) - ].filter((property) => !!property); + !isNullish(meta.author) ? 'author' : undefined, + !isNullish(meta.translator) && meta.language !== 'en' ? 'translator' : undefined, + !isNullish(meta.date) ? 'date' : undefined, + !isNullish(meta.readingTime) && !isNullish(meta.author) ? 'reading_time' : undefined, // reading_time only if author is present (blog posts only) + ].filter((property) => !isNullish(property)); if (properties.length === 0) return null; if (!properties.includes('translator') && !properties.includes('author')) return null; @@ -49,13 +50,13 @@ const MetaPropertyChild = ({ property, meta }: MetaPropertyProps) => { if (!property) return null; - if (property === 'date') { + if (property === 'date' && meta.date) { return
{formatArticleDate(meta.date)}
; } if (property === 'author' || property === 'translator') { - const personLink = (children) => - meta[property].url ? ( + const personLink = (children: React.ReactNode) => + meta[property]?.url ? ( {children} @@ -66,7 +67,7 @@ const MetaPropertyChild = ({ property, meta }: MetaPropertyProps) => { return (
{t.rich(`common.article_meta.${property}`, { - [property]: meta[property].name, + [property]: meta[property]?.name, 'person-link': personLink, })}
diff --git a/components/learn/SidebarSection.tsx b/components/learn/SidebarSection.tsx index c5893a688..229140e9c 100644 --- a/components/learn/SidebarSection.tsx +++ b/components/learn/SidebarSection.tsx @@ -18,7 +18,10 @@ const SidebarSection = ({ title, href, path, children }: Props) => { const classes = twMerge( 'text-lg font-bold', - isMounted && routerPath.startsWith(path) && 'text-black visited:text-black dark:text-white dark:visited:text-white', + isMounted && + path && + routerPath.startsWith(path) && + 'text-black visited:text-black dark:text-white dark:visited:text-white', ); return ( diff --git a/components/signatures/cells/CancelCell.tsx b/components/signatures/cells/CancelCell.tsx index ed718e8fe..8a719e287 100644 --- a/components/signatures/cells/CancelCell.tsx +++ b/components/signatures/cells/CancelCell.tsx @@ -1,8 +1,9 @@ import ControlsWrapper from 'components/allowances/controls/ControlsWrapper'; import Button from 'components/common/Button'; import { useMounted } from 'lib/hooks/useMounted'; -import { TimeLog, TransactionSubmitted } from 'lib/interfaces'; -import { waitForSubmittedTransactionConfirmation } from 'lib/utils'; +import { TransactionSubmitted } from 'lib/interfaces'; +import { isNullish, waitForSubmittedTransactionConfirmation } from 'lib/utils'; +import { TimeLog } from 'lib/utils/events'; import { HOUR, SECOND } from 'lib/utils/time'; import { useTranslations } from 'next-intl'; import { useAsyncCallback } from 'react-async-hook'; @@ -11,7 +12,7 @@ interface Props { chainId: number; address: string; lastCancelled?: TimeLog; - cancel: () => Promise; + cancel: () => Promise; } const CancelCell = ({ chainId, address, lastCancelled, cancel }: Props) => { @@ -19,7 +20,8 @@ const CancelCell = ({ chainId, address, lastCancelled, cancel }: Props) => { const t = useTranslations(); const { execute, loading } = useAsyncCallback(() => waitForSubmittedTransactionConfirmation(cancel())); - const recentlyCancelled = lastCancelled?.timestamp * SECOND > Date.now() - 24 * HOUR; + const recentlyCancelled = + !isNullish(lastCancelled?.timestamp) && lastCancelled.timestamp * SECOND > Date.now() - 24 * HOUR; return (
diff --git a/components/signatures/cells/CancelMarketplaceCell.tsx b/components/signatures/cells/CancelMarketplaceCell.tsx index 17e07a8e5..404f73841 100644 --- a/components/signatures/cells/CancelMarketplaceCell.tsx +++ b/components/signatures/cells/CancelMarketplaceCell.tsx @@ -14,12 +14,12 @@ interface Props { const CancelMarketplaceCell = ({ marketplace, onCancel }: Props) => { const { data: walletClient } = useWalletClient(); - const publicClient = usePublicClient(); + const publicClient = usePublicClient()!; const { address, selectedChainId } = useAddressPageContext(); const handleTransaction = useHandleTransaction(selectedChainId); const sendCancelTransaction = async (): Promise => { - const hash = await marketplace?.cancelSignatures(walletClient); + const hash = await marketplace?.cancelSignatures(walletClient!); track('Cancelled Marketplace Signatures', { chainId: selectedChainId, @@ -30,6 +30,7 @@ const CancelMarketplaceCell = ({ marketplace, onCancel }: Props) => { const waitForConfirmation = async () => { // TODO: Deduplicate this with the CancelPermitCell const transactionReceipt = await waitForTransactionConfirmation(hash, publicClient); + if (!transactionReceipt) return; const lastCancelled = await blocksDB.getTimeLog(publicClient, { ...transactionReceipt, blockNumber: Number(transactionReceipt.blockNumber), @@ -43,7 +44,7 @@ const CancelMarketplaceCell = ({ marketplace, onCancel }: Props) => { return { hash, confirmation: waitForConfirmation() }; }; - const cancel = async (): Promise => { + const cancel = async (): Promise => { return handleTransaction(sendCancelTransaction(), TransactionType.OTHER); }; diff --git a/components/signatures/cells/CancelPermitCell.tsx b/components/signatures/cells/CancelPermitCell.tsx index 48c4e71df..4f22c8a0a 100644 --- a/components/signatures/cells/CancelPermitCell.tsx +++ b/components/signatures/cells/CancelPermitCell.tsx @@ -2,11 +2,11 @@ import { DUMMY_ADDRESS } from 'lib/constants'; import blocksDB from 'lib/databases/blocks'; import { useHandleTransaction } from 'lib/hooks/ethereum/useHandleTransaction'; import { useAddressPageContext } from 'lib/hooks/page-context/AddressPageContext'; -import { OnCancel, PermitTokenData, TransactionSubmitted, TransactionType } from 'lib/interfaces'; +import { OnCancel, TransactionSubmitted, TransactionType } from 'lib/interfaces'; import { waitForTransactionConfirmation } from 'lib/utils'; import { track } from 'lib/utils/analytics'; import { permit } from 'lib/utils/permit'; -import { isErc721Contract } from 'lib/utils/tokens'; +import { isErc721Contract, PermitTokenData } from 'lib/utils/tokens'; import { usePublicClient, useWalletClient } from 'wagmi'; import CancelCell from './CancelCell'; @@ -17,19 +17,21 @@ interface Props { const CancelPermitCell = ({ token, onCancel }: Props) => { const { data: walletClient } = useWalletClient(); - const publicClient = usePublicClient(); + const publicClient = usePublicClient()!; const { address, selectedChainId } = useAddressPageContext(); const handleTransaction = useHandleTransaction(selectedChainId); - const sendCancelTransaction = async (): Promise => { + const sendCancelTransaction = async (): Promise => { if (isErc721Contract(token.contract)) return; - const hash = await permit(walletClient, token.contract, DUMMY_ADDRESS, 0n); + const hash = await permit(walletClient!, token.contract, DUMMY_ADDRESS, 0n); track('Cancelled Permit Signatures', { chainId: selectedChainId, account: address, token: token.contract.address }); const waitForConfirmation = async () => { // TODO: Deduplicate this with the CancelMarketplaceCell const transactionReceipt = await waitForTransactionConfirmation(hash, publicClient); + if (!transactionReceipt) return; + const lastCancelled = await blocksDB.getTimeLog(publicClient, { ...transactionReceipt, blockNumber: Number(transactionReceipt.blockNumber), @@ -42,7 +44,7 @@ const CancelPermitCell = ({ token, onCancel }: Props) => { return { hash, confirmation: waitForConfirmation() }; }; - const cancel = async (): Promise => { + const cancel = async (): Promise => { return handleTransaction(sendCancelTransaction(), TransactionType.OTHER); }; diff --git a/components/signatures/cells/LastCancelledCell.tsx b/components/signatures/cells/LastCancelledCell.tsx index 053503061..9d67b0f4a 100644 --- a/components/signatures/cells/LastCancelledCell.tsx +++ b/components/signatures/cells/LastCancelledCell.tsx @@ -1,7 +1,7 @@ import Href from 'components/common/Href'; import WithHoverTooltip from 'components/common/WithHoverTooltip'; -import type { TimeLog } from 'lib/interfaces'; import { getChainExplorerUrl } from 'lib/utils/chains'; +import type { TimeLog } from 'lib/utils/events'; import { SECOND, formatDateNormalised } from 'lib/utils/time'; import { useLocale } from 'next-intl'; import TimeAgo from 'timeago-react'; @@ -14,7 +14,7 @@ interface Props { const LastCancelledCell = ({ chainId, lastCancelled }: Props) => { const locale = useLocale(); - if (!lastCancelled) return
; + if (!lastCancelled?.timestamp) return
; const lastCancelledDate = new Date(lastCancelled.timestamp * SECOND); const explorerUrl = getChainExplorerUrl(chainId); diff --git a/components/signatures/marketplace/MarketplaceTable.tsx b/components/signatures/marketplace/MarketplaceTable.tsx index 2ea0b67ea..31533925a 100644 --- a/components/signatures/marketplace/MarketplaceTable.tsx +++ b/components/signatures/marketplace/MarketplaceTable.tsx @@ -4,12 +4,15 @@ import Table from 'components/common/table/Table'; import { useMarketplaces } from 'lib/hooks/ethereum/useMarketplaces'; import { Marketplace } from 'lib/interfaces'; import { useTranslations } from 'next-intl'; +import { useMemo } from 'react'; import { columns } from './columns'; const MarketplaceTable = () => { const t = useTranslations(); const { marketplaces, isLoading, error, onCancel } = useMarketplaces(); + const data = useMemo(() => marketplaces ?? [], [marketplaces]); + const title = (
{t('address.signatures.marketplaces.title')}
@@ -17,7 +20,7 @@ const MarketplaceTable = () => { ); const table = useReactTable({ - data: marketplaces, + data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), diff --git a/components/signatures/marketplace/columns.tsx b/components/signatures/marketplace/columns.tsx index 4d914e0fc..967482700 100644 --- a/components/signatures/marketplace/columns.tsx +++ b/components/signatures/marketplace/columns.tsx @@ -35,7 +35,7 @@ export const columns = [ id: ColumnId.ACTIONS, header: () => , cell: (info) => ( - + ), }), ]; diff --git a/components/signatures/permit/PermitsTable.tsx b/components/signatures/permit/PermitsTable.tsx index 23b54a70c..563801539 100644 --- a/components/signatures/permit/PermitsTable.tsx +++ b/components/signatures/permit/PermitsTable.tsx @@ -2,16 +2,19 @@ import { getCoreRowModel, getFilteredRowModel, getSortedRowModel, useReactTable import Card from 'components/common/Card'; import Table from 'components/common/table/Table'; import { usePermitTokens } from 'lib/hooks/ethereum/usePermitTokens'; -import { PermitTokenData } from 'lib/interfaces'; +import { PermitTokenData } from 'lib/utils/tokens'; import { useTranslations } from 'next-intl'; +import { useMemo } from 'react'; import { ColumnId, columns } from './columns'; const PermitsTable = () => { const t = useTranslations(); const { permitTokens, isLoading, error, onCancel } = usePermitTokens(); + const data = useMemo(() => permitTokens ?? [], [permitTokens]); + const table = useReactTable({ - data: permitTokens, + data, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), diff --git a/components/signatures/permit/columns.tsx b/components/signatures/permit/columns.tsx index 3830b0896..a7e2c3a64 100644 --- a/components/signatures/permit/columns.tsx +++ b/components/signatures/permit/columns.tsx @@ -1,8 +1,10 @@ import { createColumnHelper, RowData } from '@tanstack/react-table'; import AssetCell from 'components/allowances/dashboard/cells/AssetCell'; import HeaderCell from 'components/allowances/dashboard/cells/HeaderCell'; -import { AllowanceData, OnCancel, PermitTokenData } from 'lib/interfaces'; +import { OnCancel } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { formatFixedPointBigInt } from 'lib/utils/formatting'; +import { PermitTokenData } from 'lib/utils/tokens'; import CancelPermitCell from '../cells/CancelPermitCell'; import LastCancelledCell from '../cells/LastCancelledCell'; @@ -20,7 +22,7 @@ export enum ColumnId { } export const accessors = { - balance: (allowance: AllowanceData) => { + balance: (allowance: TokenAllowanceData) => { return allowance.balance === 'ERC1155' ? 'ERC1155' : formatFixedPointBigInt(allowance.balance, allowance.metadata.decimals); @@ -48,6 +50,6 @@ export const columns = [ columnHelper.display({ id: ColumnId.ACTIONS, header: () => , - cell: (info) => , + cell: (info) => , }), ]; diff --git a/cypress/e2e/chains.cy.ts b/cypress/e2e/chains.cy.ts index 6b9e98405..44373a0ab 100644 --- a/cypress/e2e/chains.cy.ts +++ b/cypress/e2e/chains.cy.ts @@ -195,7 +195,13 @@ describe('Chain Support', () => { .should('exist') .invoke('text') .then((text) => { - cy.writeFile(`cypress/temp/temp_${chainId}.txt`, text); + cy.writeFile(`cypress/downloads/temp_${chainId}_total_allowances.txt`, text); + }); + + cy.get(Selectors.ALLOWANCE_TABLE_ROW) + .its('length') + .then((length) => { + cy.writeFile(`cypress/downloads/temp_${chainId}_total_rows.txt`, `${length}`); }); } @@ -203,11 +209,11 @@ describe('Chain Support', () => { // To test that the explorer link works, we navigate to the "Last Updated" URL and check that the address is present const linkElement = cy.get(Selectors.LAST_UPDATED_LINK).first(); linkElement.invoke('attr', 'href').then((href) => { - cy.origin(href, { args: { href, fixtureAddress } }, ({ href, fixtureAddress }) => { + cy.origin(href!, { args: { href, fixtureAddress } }, ({ href, fixtureAddress }) => { // Suppress errors on the explorer page cy.on('uncaught:exception', () => false); - cy.visit(href); + cy.visit(href!); cy.get(`a[href*="${fixtureAddress}" i]`, { timeout: 10_000 }).should('exist'); }); }); @@ -225,12 +231,16 @@ describe('Chain Support', () => { cy.get(Selectors.CONTROLS_SECTION, { timeout: 4_000 }).should('exist'); // Check that the number of approvals is the same as the number of approvals on production - cy.readFile(`cypress/temp/temp_${chainId}.txt`).then((expectedNumberOfApprovals) => { + cy.readFile(`cypress/downloads/temp_${chainId}_total_allowances.txt`).then((expectedNumberOfApprovals) => { cy.get(Selectors.TOTAL_ALLOWANCES) .should('exist') .invoke('text') .should('equal', expectedNumberOfApprovals); }); + + cy.readFile(`cypress/downloads/temp_${chainId}_total_rows.txt`).then((expectedNumberOfRows) => { + cy.get(Selectors.ALLOWANCE_TABLE_ROW).its('length').should('equal', Number(expectedNumberOfRows)); + }); }); } }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 18a350e92..1fbf9a6fb 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,2 +1,3 @@ +// @ts-ignore import registerCypressGrep from '@cypress/grep/src/support'; registerCypressGrep(); diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index 792383eee..d46ef361e 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -13,4 +13,5 @@ export const Selectors = { EXPLOIT_CHECKER_LOADER: '.exploit-checker-loader', EXPLOIT_CHECKER_STATUS: '.exploit-checker-status', TOTAL_ALLOWANCES: '.total-allowances', + ALLOWANCE_TABLE_ROW: '.allowances-table > tbody > tr', }; diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 047869adf..77582e904 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -1,4 +1,6 @@ import { SessionOptions, getIronSession, unsealData } from 'iron-session'; +import { Nullable } from 'lib/interfaces'; +import { isNullish } from 'lib/utils'; import { NextApiRequest, NextApiResponse } from 'next'; import { NextRequest } from 'next/server'; import { RateLimiterMemory } from 'rate-limiter-flexible'; @@ -9,7 +11,7 @@ export interface RevokeSession { export const IRON_OPTIONS: SessionOptions = { cookieName: 'revoke_session', - password: process.env.IRON_SESSION_PASSWORD, + password: process.env.IRON_SESSION_PASSWORD!, ttl: 60 * 60 * 24, cookieOptions: { secure: true, // Change this to false when locally testing on Safari @@ -80,7 +82,7 @@ export const checkActiveSessionEdge = async (req: NextRequest) => { }; // Note: if ever moving to a different hosting / reverse proxy, then we need to update this -const getClientIp = (req: NextApiRequest) => { +const getClientIp = (req: NextApiRequest): string => { // Cloudflare if (isIp(req.headers['cf-connecting-ip'] as string)) return req.headers['cf-connecting-ip'] as string; @@ -94,12 +96,12 @@ const getClientIp = (req: NextApiRequest) => { throw new Error('Request headers malformed'); }; -const getClientIpEdge = (req: NextRequest) => { +const getClientIpEdge = (req: NextRequest): string => { // Cloudflare - if (isIp(req.headers.get('cf-connecting-ip'))) return req.headers.get('cf-connecting-ip'); + if (isIp(req.headers.get('cf-connecting-ip'))) return req.headers.get('cf-connecting-ip')!; // Vercel - if (isIp(req.headers.get('x-real-ip'))) return req.headers.get('x-real-ip'); + if (isIp(req.headers.get('x-real-ip'))) return req.headers.get('x-real-ip')!; // Other const xForwardedFor = req.headers.get('x-forwarded-for')?.split(',')?.at(0); @@ -114,6 +116,6 @@ const regexes = { ipv6: /^((?=.*::)(?!.*::.+::)(::)?([\dA-F]{1,4}:(:|\b)|){5}|([\dA-F]{1,4}:){6})((([\dA-F]{1,4}((?!\3)::|:\b|$))|(?!\2\3)){2}|(((2[0-4]|1\d|[1-9])?\d|25[0-5])\.?\b){4})$/i, }; -const isIp = (ip?: string) => { - return !!ip && (regexes.ipv4.test(ip) || regexes.ipv6.test(ip)); +const isIp = (ip?: Nullable): ip is string => { + return !isNullish(ip) && (regexes.ipv4.test(ip) || regexes.ipv6.test(ip)); }; diff --git a/lib/api/globals.ts b/lib/api/globals.ts index c9f0b93ef..6471af475 100644 --- a/lib/api/globals.ts +++ b/lib/api/globals.ts @@ -10,4 +10,4 @@ export const covalentEventGetter = new CovalentEventGetter( process.env.COVALENT_IS_PREMIUM === 'true', ); export const etherscanEventGetter = new EtherscanEventGetter(); -export const nodeEventGetter = new NodeEventGetter(JSON.parse(process.env.NODE_URLS)); +export const nodeEventGetter = new NodeEventGetter(JSON.parse(process.env.NODE_URLS ?? '{}')); diff --git a/lib/api/logs/CovalentEventGetter.ts b/lib/api/logs/CovalentEventGetter.ts index dea3f6f61..e7976e1c7 100644 --- a/lib/api/logs/CovalentEventGetter.ts +++ b/lib/api/logs/CovalentEventGetter.ts @@ -1,7 +1,7 @@ -import type { Filter, Log } from 'lib/interfaces'; import ky from 'lib/ky'; -import { splitBlockRangeInChunks } from 'lib/utils'; +import { isNullish, splitBlockRangeInChunks } from 'lib/utils'; import { isRateLimitError } from 'lib/utils/errors'; +import type { Filter, Log } from 'lib/utils/events'; import { getAddress } from 'viem'; import type { EventGetter } from './EventGetter'; import { RequestQueue } from './RequestQueue'; @@ -10,14 +10,16 @@ export class CovalentEventGetter implements EventGetter { private queue: RequestQueue; constructor( - private apiKey: string, - isPremium: boolean, + private apiKey?: string, + isPremium: boolean = false, ) { // Covalent's premium API has a rate limit of 50 (normal = 5) requests per second, which we underestimate to be safe this.queue = new RequestQueue(`covalent:${apiKey}`, { interval: 1000, intervalCap: isPremium ? 40 : 4 }); } async getEvents(chainId: number, filter: Filter): Promise { + if (!this.apiKey) throw new Error('Covalent API key is not set'); + const { topics, fromBlock, toBlock } = filter; const blockRangeChunks = splitBlockRangeInChunks([[fromBlock, toBlock]], 1e6); @@ -28,8 +30,13 @@ export class CovalentEventGetter implements EventGetter { return filterLogs(results.flat(), filter); } - private async getEventsInChunk(chainId: number, fromBlock: number, toBlock: number, topics: string[]) { - const [mainTopic, ...secondaryTopics] = topics.filter((topic) => !!topic); + private async getEventsInChunk( + chainId: number, + fromBlock: number, + toBlock: number, + topics: Array, + ): Promise { + const [mainTopic, ...secondaryTopics] = topics.filter((topic) => !isNullish(topic)); const apiUrl = `https://api.covalenthq.com/v1/${chainId}/events/topics/${mainTopic}/`; const searchParams = { @@ -54,7 +61,7 @@ export class CovalentEventGetter implements EventGetter { return this.getEventsInChunk(chainId, fromBlock, toBlock, topics); } - throw new Error(e.data?.error_message ?? e.message); + throw new Error((e as any).data?.error_message ?? (e as any).message); } } } diff --git a/lib/api/logs/EtherscanEventGetter.ts b/lib/api/logs/EtherscanEventGetter.ts index b4616f428..1222d37b5 100644 --- a/lib/api/logs/EtherscanEventGetter.ts +++ b/lib/api/logs/EtherscanEventGetter.ts @@ -1,4 +1,3 @@ -import type { Filter, Log } from 'lib/interfaces'; import ky from 'lib/ky'; import { isNullish } from 'lib/utils'; import { @@ -8,6 +7,7 @@ import { getChainApiRateLimit, getChainApiUrl, } from 'lib/utils/chains'; +import type { Filter, Log } from 'lib/utils/events'; import { getAddress } from 'viem'; import type { EventGetter } from './EventGetter'; import { RequestQueue } from './RequestQueue'; @@ -24,7 +24,7 @@ export class EtherscanEventGetter implements EventGetter { } async getEvents(chainId: number, filter: Filter, page: number = 1): Promise { - const apiUrl = getChainApiUrl(chainId); + const apiUrl = getChainApiUrl(chainId)!; const apiKey = getChainApiKey(chainId); const queue = this.queues[chainId]!; @@ -118,7 +118,7 @@ const prepareEtherscanGetLogsQuery = (filter: Filter, page: number, apiKey?: str const formatEtherscanEvent = (etherscanLog: any) => ({ address: getAddress(etherscanLog.address), - topics: etherscanLog.topics.filter((topic: string) => !!topic), + topics: etherscanLog.topics.filter((topic: string) => !isNullish(topic)), data: etherscanLog.data, transactionHash: etherscanLog.transactionHash, blockNumber: Number.parseInt(etherscanLog.blockNumber, 16), @@ -132,7 +132,7 @@ const retryOn429 = async (fn: () => Promise): Promise => { try { return await fn(); } catch (e) { - if (e.message.includes('429')) { + if ((e as any).message.includes('429')) { console.error('Etherscan: Rate limit reached, retrying...'); return retryOn429(fn); } diff --git a/lib/api/logs/EventGetter.ts b/lib/api/logs/EventGetter.ts index 956714189..db80af300 100644 --- a/lib/api/logs/EventGetter.ts +++ b/lib/api/logs/EventGetter.ts @@ -1,4 +1,4 @@ -import type { Filter, Log } from 'lib/interfaces'; +import type { Filter, Log } from 'lib/utils/events'; export interface EventGetter { getEvents: (chainId: number, filter: Filter) => Promise; diff --git a/lib/api/logs/NodeEventGetter.ts b/lib/api/logs/NodeEventGetter.ts index cef46c36f..ffee90738 100644 --- a/lib/api/logs/NodeEventGetter.ts +++ b/lib/api/logs/NodeEventGetter.ts @@ -1,13 +1,13 @@ -import type { Filter, Log, LogsProvider } from 'lib/interfaces'; -import { DivideAndConquerLogsProvider, ViemLogsProvider } from 'lib/providers'; +import { DivideAndConquerLogsProvider, type LogsProvider, ViemLogsProvider } from 'lib/providers'; +import type { Filter, Log } from 'lib/utils/events'; import type { EventGetter } from './EventGetter'; export class NodeEventGetter implements EventGetter { private logsProviders: { [chainId: number]: LogsProvider }; - constructor(urls: { [chainId: number]: string }) { + constructor(urls?: Record) { this.logsProviders = Object.fromEntries( - Object.entries(urls).map(([chainId, url]) => [ + Object.entries(urls ?? {}).map(([chainId, url]) => [ Number(chainId), new DivideAndConquerLogsProvider(new ViemLogsProvider(Number(chainId), url)), ]), diff --git a/lib/api/logs/RequestQueue.ts b/lib/api/logs/RequestQueue.ts index 18dd2a52a..9ba103d6b 100644 --- a/lib/api/logs/RequestQueue.ts +++ b/lib/api/logs/RequestQueue.ts @@ -15,13 +15,13 @@ export class RequestQueue { private preferredQueue?: 'upstash' | 'p-queue', ) { this.pQueue = new PQueue(rateLimit); - this.upstashQueue = - process.env.UPSTASH_REDIS_REST_URL && - new Ratelimit({ - redis: Redis.fromEnv(), - limiter: Ratelimit.slidingWindow(rateLimit.intervalCap, `${rateLimit.interval} ms`), - analytics: true, - }); + this.upstashQueue = process.env.UPSTASH_REDIS_REST_URL + ? new Ratelimit({ + redis: Redis.fromEnv(), + limiter: Ratelimit.slidingWindow(rateLimit.intervalCap, `${rateLimit.interval} ms`), + analytics: true, + }) + : undefined; } async add(fn: () => Promise): Promise { diff --git a/lib/chains/Chain.ts b/lib/chains/Chain.ts index b903861b1..3887eef9e 100644 --- a/lib/chains/Chain.ts +++ b/lib/chains/Chain.ts @@ -98,7 +98,7 @@ export class Chain { getRpcUrls(): string[] { const baseRpcUrls = - getChain(this.chainId)?.rpc?.map((url) => url.replace('${INFURA_API_KEY}', INFURA_API_KEY)) ?? []; + getChain(this.chainId)?.rpc?.map((url) => url.replace('${INFURA_API_KEY}', `${INFURA_API_KEY}`)) ?? []; const specifiedRpcUrls = [this.options.rpc?.main].flat().filter(Boolean) as string[]; const rpcOverrides = RPC_OVERRIDES[this.chainId] ? [RPC_OVERRIDES[this.chainId]] : []; return [...rpcOverrides, ...specifiedRpcUrls, ...baseRpcUrls]; @@ -129,13 +129,16 @@ export class Chain { getEtherscanCompatibleApiKey(): string | undefined { const platform = this.getEtherscanCompatiblePlatformNames(); - return ETHERSCAN_API_KEYS[`${platform?.subdomain}.${platform?.domain}`] ?? ETHERSCAN_API_KEYS[platform?.domain]; + const subdomainApiKey = ETHERSCAN_API_KEYS[`${platform?.subdomain}.${platform?.domain}`]; + const domainApiKey = ETHERSCAN_API_KEYS[`${platform?.domain}`]; + return subdomainApiKey ?? domainApiKey; } getEtherscanCompatibleApiRateLimit(): RateLimit { const platform = this.getEtherscanCompatiblePlatformNames(); - const customRateLimit = - ETHERSCAN_RATE_LIMITS[`${platform?.subdomain}.${platform?.domain}`] ?? ETHERSCAN_RATE_LIMITS[platform?.domain]; + const subdomainRateLimit = ETHERSCAN_RATE_LIMITS[`${platform?.subdomain}.${platform?.domain}`]; + const domainRateLimit = ETHERSCAN_RATE_LIMITS[`${platform?.domain}`]; + const customRateLimit = subdomainRateLimit ?? domainRateLimit; if (customRateLimit) { return { interval: 1000, intervalCap: customRateLimit }; @@ -160,7 +163,7 @@ export class Chain { const apiUrl = this.getEtherscanCompatibleApiUrl(); if (!apiUrl) return undefined; - const domain = new URL(apiUrl).hostname.split('.').at(-2); + const domain = new URL(apiUrl).hostname.split('.').at(-2)!; const subdomain = new URL(apiUrl).hostname.split('.').at(-3)?.split('-').at(-1); return { domain, subdomain }; }; @@ -176,7 +179,7 @@ export class Chain { getViemChainConfig(): ViemChain { const chainInfo = getChain(this.chainId); const chainName = this.getName(); - const fallbackNativeCurrency = { name: chainName, symbol: this.getNativeToken(), decimals: 18 }; + const fallbackNativeCurrency = { name: chainName, symbol: this.getNativeToken()!, decimals: 18 }; return defineChain({ id: this.chainId, @@ -199,14 +202,15 @@ export class Chain { } getAddEthereumChainParameter(): AddEthereumChainParameter { - const fallbackNativeCurrency = { name: this.getName(), symbol: this.getNativeToken(), decimals: 18 }; + const fallbackNativeCurrency = { name: this.getName(), symbol: this.getNativeToken()!, decimals: 18 }; + const iconUrl = getChain(this.chainId)?.iconURL; const addEthereumChainParameter = { chainId: String(this.chainId), chainName: this.getName(), nativeCurrency: getChain(this.chainId)?.nativeCurrency ?? fallbackNativeCurrency, rpcUrls: [this.getFreeRpcUrl()], blockExplorerUrls: [this.getExplorerUrl()], - iconUrls: [getChain(this.chainId)?.iconURL], + iconUrls: iconUrl ? [iconUrl] : [], }; return addEthereumChainParameter; diff --git a/lib/databases/blocks.ts b/lib/databases/blocks.ts index 04a120156..5f0d05930 100644 --- a/lib/databases/blocks.ts +++ b/lib/databases/blocks.ts @@ -1,5 +1,5 @@ import Dexie, { Table } from 'dexie'; -import { Log, TimeLog } from 'lib/interfaces'; +import { Log, TimeLog } from 'lib/utils/events'; import { PublicClient } from 'viem'; interface Block { @@ -20,7 +20,7 @@ class BlocksDB extends Dexie { async getBlockTimestamp(publicClient: PublicClient, blockNumber: number): Promise { try { - const chainId = publicClient.chain.id; + const chainId = publicClient.chain!.id; const storedBlock = await this.blocks.get([chainId, blockNumber]); if (storedBlock) return storedBlock.timestamp; diff --git a/lib/databases/events.ts b/lib/databases/events.ts index c47e2690a..5992585cd 100644 --- a/lib/databases/events.ts +++ b/lib/databases/events.ts @@ -1,14 +1,15 @@ import { ChainId } from '@revoke.cash/chains'; import Dexie, { Table } from 'dexie'; -import { Filter, Log, LogsProvider } from 'lib/interfaces'; +import { LogsProvider } from 'lib/providers'; import { isCovalentSupportedChain } from 'lib/utils/chains'; +import { Filter, Log } from 'lib/utils/events'; import { Address } from 'viem'; interface Events { chainId: number; address?: Address; topicsKey: string; - topics: string[]; + topics: Array; toBlock: number; logs: Log[]; } @@ -81,7 +82,7 @@ class EventsDB extends Dexie { // If the fromBlock is greater than the toBlock, it means that we already have all the events if (fromBlock > toBlock) { - return storedEvents.logs.filter( + return storedEvents!.logs.filter( (log) => log.blockNumber >= filter.fromBlock && log.blockNumber <= filter.toBlock, ); } diff --git a/lib/hooks/ethereum/EthereumProvider.tsx b/lib/hooks/ethereum/EthereumProvider.tsx index b96bb4e24..da7a29d28 100644 --- a/lib/hooks/ethereum/EthereumProvider.tsx +++ b/lib/hooks/ethereum/EthereumProvider.tsx @@ -2,7 +2,6 @@ import { usePathname, useRouter } from 'lib/i18n/navigation'; import { createViemPublicClientForChain, getViemChainConfig, ORDERED_CHAINS } from 'lib/utils/chains'; -import { SECOND } from 'lib/utils/time'; import { ReactNode, useEffect } from 'react'; import { Chain } from 'viem'; import { createConfig, useAccount, useConnect, WagmiProvider } from 'wagmi'; @@ -16,7 +15,7 @@ export const connectors = [ safe({ debug: false }), injected(), walletConnect({ - projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID, + projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, metadata: { name: 'Revoke.cash', description: @@ -36,8 +35,7 @@ export const wagmiConfig = createConfig({ return createViemPublicClientForChain(chain.id) as any; }, ssr: true, - batch: { multicall: true }, - cacheTime: 4 * SECOND, + batch: { multicall: true } as any, // For some reason, this is not typed correctly }); export const EthereumProvider = ({ children }: Props) => { diff --git a/lib/hooks/ethereum/events/useEvents.tsx b/lib/hooks/ethereum/events/useEvents.tsx index ef024daf6..ba542902a 100644 --- a/lib/hooks/ethereum/events/useEvents.tsx +++ b/lib/hooks/ethereum/events/useEvents.tsx @@ -1,7 +1,7 @@ import { ERC721_ABI } from 'lib/abis'; import { addressToTopic, sortTokenEventsChronologically } from 'lib/utils'; -import { generatePatchedAllowanceEvents } from 'lib/utils/allowances'; import { + generatePatchedAllowanceEvents, parseApprovalForAllLog, parseApprovalLog, parsePermit2Log, @@ -22,34 +22,34 @@ export const useEvents = (address: Address, chainId: number) => { }; const addressTopic = address ? addressToTopic(address) : undefined; - const transferToTopics = addressTopic && [getErc721EventSelector('Transfer'), null, addressTopic]; - const transferFromTopics = addressTopic && [getErc721EventSelector('Transfer'), addressTopic]; - const approvalTopics = addressTopic && [getErc721EventSelector('Approval'), addressTopic]; - const approvalForAllTopics = addressTopic && [getErc721EventSelector('ApprovalForAll'), addressTopic]; + const transferToFilter = addressTopic && { topics: [getErc721EventSelector('Transfer'), null, addressTopic] }; + const transferFromFilter = addressTopic && { topics: [getErc721EventSelector('Transfer'), addressTopic] }; + const approvalFilter = addressTopic && { topics: [getErc721EventSelector('Approval'), addressTopic] }; + const approvalForAllFilter = addressTopic && { topics: [getErc721EventSelector('ApprovalForAll'), addressTopic] }; const { data: transferTo, isLoading: isTransferToLoading, error: transferToError, - } = useLogsFullBlockRange('Transfer (to)', chainId, { topics: transferToTopics }); + } = useLogsFullBlockRange('Transfer (to)', chainId, transferToFilter); const { data: transferFrom, isLoading: isTransferFromLoading, error: transferFromError, - } = useLogsFullBlockRange('Transfer (from)', chainId, { topics: transferFromTopics }); + } = useLogsFullBlockRange('Transfer (from)', chainId, transferFromFilter); const { data: approval, isLoading: isApprovalLoading, error: approvalError, - } = useLogsFullBlockRange('Approval', chainId, { topics: approvalTopics }); + } = useLogsFullBlockRange('Approval', chainId, approvalFilter); const { data: approvalForAllUnpatched, isLoading: isApprovalForAllLoading, error: approvalForAllError, - } = useLogsFullBlockRange('ApprovalForAll', chainId, { topics: approvalForAllTopics }); + } = useLogsFullBlockRange('ApprovalForAll', chainId, approvalForAllFilter); const { events: permit2Approval, @@ -62,7 +62,7 @@ export const useEvents = (address: Address, chainId: number) => { if (!transferFrom || !transferTo || !approval || !approvalForAllUnpatched) return undefined; return [ ...approvalForAllUnpatched, - ...generatePatchedAllowanceEvents(address, openSeaProxyAddress, [ + ...generatePatchedAllowanceEvents(address, openSeaProxyAddress ?? undefined, [ ...approval, ...approvalForAllUnpatched, ...transferFrom, diff --git a/lib/hooks/ethereum/events/usePermit2Events.tsx b/lib/hooks/ethereum/events/usePermit2Events.tsx index 19921dd77..0ec229df3 100644 --- a/lib/hooks/ethereum/events/usePermit2Events.tsx +++ b/lib/hooks/ethereum/events/usePermit2Events.tsx @@ -11,27 +11,27 @@ export const usePermit2Events = (address: Address, chainId: number) => { const addressTopic = address ? addressToTopic(address) : undefined; - const approvalTopics = addressTopic && [getPermit2EventSelector('Approval'), addressTopic]; - const permitTopics = addressTopic && [getPermit2EventSelector('Permit'), addressTopic]; - const lockdownTopics = addressTopic && [getPermit2EventSelector('Lockdown'), addressTopic]; + const approvalFilter = addressTopic && { topics: [getPermit2EventSelector('Approval'), addressTopic] }; + const permitFilter = addressTopic && { topics: [getPermit2EventSelector('Permit'), addressTopic] }; + const lockdownFilter = addressTopic && { topics: [getPermit2EventSelector('Lockdown'), addressTopic] }; const { data: approval, isLoading: isApprovalLoading, error: approvalError, - } = useLogsFullBlockRange('Permit2 Approval', chainId, { topics: approvalTopics }); + } = useLogsFullBlockRange('Permit2 Approval', chainId, approvalFilter); const { data: permit, isLoading: isPermitLoading, error: permitError, - } = useLogsFullBlockRange('Permit2 Permit', chainId, { topics: permitTopics }); + } = useLogsFullBlockRange('Permit2 Permit', chainId, permitFilter); const { data: lockdown, isLoading: isLockdownLoading, error: lockdownError, - } = useLogsFullBlockRange('Permit2 Lockdown', chainId, { topics: lockdownTopics }); + } = useLogsFullBlockRange('Permit2 Lockdown', chainId, lockdownFilter); const isLoading = isPermitLoading || isApprovalLoading || isLockdownLoading; const error = permitError || approvalError || lockdownError; diff --git a/lib/hooks/ethereum/useAllowances.tsx b/lib/hooks/ethereum/useAllowances.tsx index 0b8bc9127..360baf1a8 100644 --- a/lib/hooks/ethereum/useAllowances.tsx +++ b/lib/hooks/ethereum/useAllowances.tsx @@ -1,31 +1,42 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import type { AllowanceData } from 'lib/interfaces'; -import { getAllowancesFromEvents, stripAllowanceData } from 'lib/utils/allowances'; +import { isNullish } from 'lib/utils'; +import { + AllowancePayload, + AllowanceType, + getAllowancesFromEvents, + stripAllowanceData, + type TokenAllowanceData, +} from 'lib/utils/allowances'; import { track } from 'lib/utils/analytics'; -import { getEventKey, TokenEvent } from 'lib/utils/events'; +import { getEventKey, TimeLog, TokenEvent } from 'lib/utils/events'; import { hasZeroBalance } from 'lib/utils/tokens'; import { useLayoutEffect, useState } from 'react'; import { Address } from 'viem'; import { usePublicClient } from 'wagmi'; import { queryClient } from '../QueryProvider'; -export const useAllowances = (address: Address, events: TokenEvent[], chainId: number) => { - const [allowances, setAllowances] = useState(); - const publicClient = usePublicClient({ chainId }); +interface AllowanceUpdateProperties { + amount?: bigint; + lastUpdated?: TimeLog; +} - const { data, isLoading, error } = useQuery({ +export const useAllowances = (address: Address, events: TokenEvent[] | undefined, chainId: number) => { + const [allowances, setAllowances] = useState(); + const publicClient = usePublicClient({ chainId })!; + + const { data, isLoading, error } = useQuery({ queryKey: ['allowances', address, chainId, events?.map(getEventKey)], queryFn: async () => { - const allowances = getAllowancesFromEvents(address, events, publicClient, chainId); + const allowances = getAllowancesFromEvents(address, events!, publicClient, chainId); track('Fetched Allowances', { account: address, chainId }); return allowances; }, // If events (transfers + approvals) don't change, derived allowances also shouldn't change, even if allowances // are used on-chain. The only exception would be incorrectly implemented tokens that don't emit correct events staleTime: Infinity, - enabled: !!address && !!chainId && !!events, + enabled: !isNullish(address) && !isNullish(chainId) && !isNullish(events), }); useLayoutEffect(() => { @@ -34,17 +45,24 @@ export const useAllowances = (address: Address, events: TokenEvent[], chainId: n } }, [data]); - const contractEquals = (a: AllowanceData, b: AllowanceData) => { + const contractEquals = (a: TokenAllowanceData, b: TokenAllowanceData) => { return a.contract.address === b.contract.address && a.chainId === b.chainId; }; - const allowanceEquals = (a: AllowanceData, b: AllowanceData) => { - return contractEquals(a, b) && a.spender === b.spender && a.tokenId === b.tokenId; + const allowanceEquals = (a: TokenAllowanceData, b: TokenAllowanceData) => { + if (!contractEquals(a, b)) return false; + if (a.payload?.spender !== b.payload?.spender) return false; + if (a.payload?.type !== b.payload?.type) return false; + if (a.payload?.type === AllowanceType.ERC721_SINGLE && b.payload?.type === AllowanceType.ERC721_SINGLE) { + return a.payload.tokenId === b.payload.tokenId; + } + + return true; }; - const onRevoke = (allowance: AllowanceData) => { + const onRevoke = (allowance: TokenAllowanceData) => { setAllowances((previousAllowances) => { - const newAllowances = previousAllowances.filter((other) => !allowanceEquals(other, allowance)); + const newAllowances = previousAllowances!.filter((other) => !allowanceEquals(other, allowance)); // If the token has a balance and we just revoked the last allowance, we need to add the token back to the list // TODO: This is kind of ugly, ideally this should be reactive @@ -58,10 +76,7 @@ export const useAllowances = (address: Address, events: TokenEvent[], chainId: n }); }; - const onUpdate = async ( - allowance: AllowanceData, - updatedProperties: Pick = {}, - ) => { + const onUpdate = async (allowance: TokenAllowanceData, updatedProperties: AllowanceUpdateProperties = {}) => { console.debug('Reloading data'); // Invalidate blockNumber query, which triggers a refetch of the events, which in turn triggers a refetch of the allowances @@ -82,10 +97,10 @@ export const useAllowances = (address: Address, events: TokenEvent[], chainId: n } setAllowances((previousAllowances) => { - return previousAllowances.map((other) => { + return previousAllowances!.map((other) => { if (!allowanceEquals(other, allowance)) return other; - const newAllowance = { ...other, ...updatedProperties }; + const newAllowance = { ...other, payload: { ...other.payload, ...updatedProperties } as AllowancePayload }; return newAllowance; }); }); diff --git a/lib/hooks/ethereum/useDonate.tsx b/lib/hooks/ethereum/useDonate.tsx index 493499160..e34933faf 100644 --- a/lib/hooks/ethereum/useDonate.tsx +++ b/lib/hooks/ethereum/useDonate.tsx @@ -11,10 +11,10 @@ import { usePublicClient, useWalletClient } from 'wagmi'; import { useHandleTransaction } from './useHandleTransaction'; export const useDonate = (chainId: number, type: DonateButtonType) => { - const nativeToken = getChainNativeToken(chainId); - const defaultAmount = getDefaultDonationAmount(nativeToken); + const nativeToken = getChainNativeToken(chainId)!; + const defaultAmount = getDefaultDonationAmount(nativeToken)!; const { data: walletClient } = useWalletClient({ chainId }); - const publicClient = usePublicClient({ chainId }); + const publicClient = usePublicClient({ chainId })!; const handleTransaction = useHandleTransaction(chainId); const sendDonation = async (amount: string): Promise => { diff --git a/lib/hooks/ethereum/useHandleTransaction.tsx b/lib/hooks/ethereum/useHandleTransaction.tsx index a30ec2d14..a112634f4 100644 --- a/lib/hooks/ethereum/useHandleTransaction.tsx +++ b/lib/hooks/ethereum/useHandleTransaction.tsx @@ -2,12 +2,10 @@ import { displayTransactionSubmittedToast } from 'components/common/TransactionS import { TransactionSubmitted, TransactionType } from 'lib/interfaces'; import { isRevertedError, isUserRejectionError, parseErrorMessage } from 'lib/utils/errors'; import { useTranslations } from 'next-intl'; -import { useRef } from 'react'; import { toast } from 'react-toastify'; import { stringify } from 'viem'; export const useHandleTransaction = (chainId: number) => { - const toastRef = useRef(); const t = useTranslations(); const checkError = (e: any, type: TransactionType): void => { @@ -49,7 +47,7 @@ export const useHandleTransaction = (chainId: number) => { if (type === TransactionType.DONATE) { toast.info(t('common.toasts.donation_sent')); } else { - displayTransactionSubmittedToast(chainId, transaction.hash, toastRef); + displayTransactionSubmittedToast(chainId, transaction.hash); } } diff --git a/lib/hooks/ethereum/useLogs.tsx b/lib/hooks/ethereum/useLogs.tsx index a41b4037e..c6f65286a 100644 --- a/lib/hooks/ethereum/useLogs.tsx +++ b/lib/hooks/ethereum/useLogs.tsx @@ -1,20 +1,26 @@ import { useQuery } from '@tanstack/react-query'; import eventsDB from 'lib/databases/events'; -import type { Filter, Log } from 'lib/interfaces'; import { getLogsProvider } from 'lib/providers'; +import { isNullish } from 'lib/utils'; +import type { Filter, Log } from 'lib/utils/events'; import { useEffect } from 'react'; import { useApiSession } from '../useApiSession'; -export const useLogs = (name: string, chainId: number, filter: Filter) => { +export const useLogs = (name: string, chainId: number, filter?: Filter) => { const { isLoggedIn, loggingIn, error: loginError } = useApiSession(); const result = useQuery({ queryKey: ['logs', filter, chainId, isLoggedIn], - queryFn: async () => eventsDB.getLogs(getLogsProvider(chainId), filter, chainId), + queryFn: async () => eventsDB.getLogs(getLogsProvider(chainId), filter!, chainId), refetchOnWindowFocus: false, // The same filter should always return the same logs staleTime: Infinity, - enabled: !!chainId && !!isLoggedIn && ![filter?.fromBlock, filter?.toBlock, filter?.topics].includes(undefined), + enabled: + !isNullish(chainId) && + !isNullish(isLoggedIn) && + !isNullish(filter?.fromBlock) && + !isNullish(filter?.toBlock) && + !isNullish(filter?.topics), }); useEffect(() => { diff --git a/lib/hooks/ethereum/useLogsFullBlockRange.tsx b/lib/hooks/ethereum/useLogsFullBlockRange.tsx index 2e8c1d2e5..eda12c424 100644 --- a/lib/hooks/ethereum/useLogsFullBlockRange.tsx +++ b/lib/hooks/ethereum/useLogsFullBlockRange.tsx @@ -1,9 +1,13 @@ -import type { Filter } from 'lib/interfaces'; +import { type Filter } from 'lib/utils/events'; import { useBlockNumber } from './useBlockNumber'; import { useLogs } from './useLogs'; -export const useLogsFullBlockRange = (name: string, chainId: number, filter: Pick) => { +export const useLogsFullBlockRange = (name: string, chainId: number, filter?: Pick) => { const { data: blockNumber, isLoading: isBlockNumberLoading, error: blockNumberError } = useBlockNumber(chainId); - const result = useLogs(name, chainId, { ...filter, fromBlock: 0, toBlock: blockNumber }); + const result = useLogs( + name, + chainId, + filter && blockNumber ? { ...filter, fromBlock: 0, toBlock: blockNumber } : undefined, + ); return { ...result, isLoading: result.isLoading || isBlockNumberLoading, error: result.error || blockNumberError }; }; diff --git a/lib/hooks/ethereum/useMarketplaces.tsx b/lib/hooks/ethereum/useMarketplaces.tsx index 3ad51ffeb..3851366e3 100644 --- a/lib/hooks/ethereum/useMarketplaces.tsx +++ b/lib/hooks/ethereum/useMarketplaces.tsx @@ -3,11 +3,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { BLUR_ABI, OPENSEA_SEAPORT_ABI } from 'lib/abis'; import blocksDB from 'lib/databases/blocks'; import eventsDB from 'lib/databases/events'; -import { Marketplace, MarketplaceConfig, OnCancel, TimeLog } from 'lib/interfaces'; +import { Marketplace, MarketplaceConfig, OnCancel } from 'lib/interfaces'; import ky from 'lib/ky'; import { getLogsProvider } from 'lib/providers'; -import { addressToTopic, getWalletAddress, logSorterChronological } from 'lib/utils'; +import { addressToTopic, getWalletAddress, isNullish, logSorterChronological } from 'lib/utils'; import { createViemPublicClientForChain } from 'lib/utils/chains'; +import { TimeLog } from 'lib/utils/events'; import { mapAsync } from 'lib/utils/promises'; import { MINUTE } from 'lib/utils/time'; import { useLayoutEffect, useState } from 'react'; @@ -17,7 +18,7 @@ import { useAddressAllowances, useAddressPageContext } from '../page-context/Add import { wagmiConfig } from './EthereumProvider'; export const useMarketplaces = () => { - const [marketplaces, setMarketplaces] = useState(); + const [marketplaces, setMarketplaces] = useState([]); const { selectedChainId, address } = useAddressPageContext(); const { allowances, isLoading: isAllowancesLoading, error: allowancesError } = useAddressAllowances(); @@ -141,7 +142,9 @@ export const useMarketplaces = () => { ...marketplace, chainId: selectedChainId, lastCancelled: lastCancelled ? { ...lastCancelled, timestamp } : undefined, - allowances: allowances.filter((allowance) => allowance.spender === marketplace.approvalFilterAddress), + allowances: allowances!.filter( + (allowance) => allowance.payload?.spender === marketplace.approvalFilterAddress, + ), }; }); @@ -149,7 +152,7 @@ export const useMarketplaces = () => { }, // TODO: This is a hack to ensure that the allowances are already loaded so we can filter on them. // But most of these calls could easily be done in parallel, so we should try to improve this down the line. - enabled: !isAllowancesLoading && !allowancesError && !!allowances, + enabled: !isAllowancesLoading && isNullish(allowancesError) && !isNullish(allowances), }); useLayoutEffect(() => { diff --git a/lib/hooks/ethereum/useNameLookup.tsx b/lib/hooks/ethereum/useNameLookup.tsx index 9406840e4..ee71d0608 100644 --- a/lib/hooks/ethereum/useNameLookup.tsx +++ b/lib/hooks/ethereum/useNameLookup.tsx @@ -1,27 +1,28 @@ import { useQuery } from '@tanstack/react-query'; +import { isNullish } from 'lib/utils'; import { HOUR } from 'lib/utils/time'; import { lookupAvvyName, lookupEnsName, lookupUnsName } from 'lib/utils/whois'; import { Address } from 'viem'; -export const useNameLookup = (address: Address) => { +export const useNameLookup = (address?: Address) => { const { data: ensName } = useQuery({ queryKey: ['ensName', address, { persist: true }], queryFn: () => lookupEnsName(address), - enabled: !!address, + enabled: !isNullish(address), staleTime: 12 * HOUR, }); const { data: unsName } = useQuery({ queryKey: ['unsName', address, { persist: true }], queryFn: () => lookupUnsName(address), - enabled: !!address, + enabled: !isNullish(address), staleTime: 12 * HOUR, }); - const { data: avvyName } = useQuery({ + const { data: avvyName } = useQuery({ queryKey: ['avvyName', address, { persist: true }], queryFn: () => lookupAvvyName(address), - enabled: !!address, + enabled: !isNullish(address), staleTime: 12 * HOUR, }); diff --git a/lib/hooks/ethereum/useOpenSeaProxyAddress.tsx b/lib/hooks/ethereum/useOpenSeaProxyAddress.tsx index e6c8ccae4..7dbe5d33c 100644 --- a/lib/hooks/ethereum/useOpenSeaProxyAddress.tsx +++ b/lib/hooks/ethereum/useOpenSeaProxyAddress.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { isNullish } from 'lib/utils'; import { DAY } from 'lib/utils/time'; import { getOpenSeaProxyAddress } from 'lib/utils/whois'; import { Address } from 'viem'; @@ -7,7 +8,7 @@ export const useOpenSeaProxyAddress = (address: Address) => { const { data: openSeaProxyAddress, isLoading } = useQuery({ queryKey: ['openSeaProxyAddress', address, { persist: true }], queryFn: () => getOpenSeaProxyAddress(address), - enabled: !!address, + enabled: !isNullish(address), // This data is very unlikely to ever change gcTime: 7 * DAY, staleTime: 5 * DAY, diff --git a/lib/hooks/ethereum/usePermitTokens.tsx b/lib/hooks/ethereum/usePermitTokens.tsx index eaa86b1da..fcffc5712 100644 --- a/lib/hooks/ethereum/usePermitTokens.tsx +++ b/lib/hooks/ethereum/usePermitTokens.tsx @@ -1,10 +1,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { OnCancel, PermitTokenData, TimeLog } from 'lib/interfaces'; -import { deduplicateArray } from 'lib/utils'; +import { OnCancel } from 'lib/interfaces'; +import { deduplicateArray, isNullish } from 'lib/utils'; import { getAllowanceKey, stripAllowanceData } from 'lib/utils/allowances'; +import { TimeLog } from 'lib/utils/events'; import { getLastCancelled } from 'lib/utils/permit'; import { filterAsync, mapAsync } from 'lib/utils/promises'; -import { hasSupportForPermit, hasZeroBalance } from 'lib/utils/tokens'; +import { hasSupportForPermit, hasZeroBalance, PermitTokenData } from 'lib/utils/tokens'; import { useLayoutEffect, useState } from 'react'; import { useAddressAllowances, useAddressEvents, useAddressPageContext } from '../page-context/AddressPageContext'; @@ -23,18 +24,18 @@ export const usePermitTokens = () => { } = useQuery({ queryKey: ['permitTokens', allowances?.map(getAllowanceKey)], queryFn: async () => { - const ownedTokens = deduplicateArray(allowances, (a, b) => a.contract.address === b.contract.address) + const ownedTokens = deduplicateArray(allowances!, (a, b) => a.contract.address === b.contract.address) .filter((token) => !hasZeroBalance(token.balance, token.metadata.decimals) && token) .map(stripAllowanceData); const permitTokens = await mapAsync( filterAsync(ownedTokens, (token) => hasSupportForPermit(token.contract)), - async (token) => ({ ...token, lastCancelled: await getLastCancelled(events, token) }), + async (token) => ({ ...token, lastCancelled: await getLastCancelled(events!, token) }), ); return permitTokens; }, - enabled: !!allowances, + enabled: !isNullish(allowances) && !isNullish(events), staleTime: Infinity, }); @@ -58,7 +59,7 @@ export const usePermitTokens = () => { }; setPermitTokens((previousPermitTokens) => { - return previousPermitTokens.map((other) => { + return previousPermitTokens!.map((other) => { if (!permitTokenEquals(other, token)) return other; const newPermitTokenData = { ...other, lastCancelled }; diff --git a/lib/hooks/ethereum/useRevoke.tsx b/lib/hooks/ethereum/useRevoke.tsx index e91bd3af6..c9bb31d89 100644 --- a/lib/hooks/ethereum/useRevoke.tsx +++ b/lib/hooks/ethereum/useRevoke.tsx @@ -1,7 +1,7 @@ 'use client'; -import { AllowanceData, OnUpdate, TransactionType } from 'lib/interfaces'; -import { revokeAllowance, updateErc20Allowance, wrapRevoke } from 'lib/utils/allowances'; +import { TransactionType } from 'lib/interfaces'; +import { OnUpdate, revokeAllowance, TokenAllowanceData, updateErc20Allowance, wrapRevoke } from 'lib/utils/allowances'; import { isErc721Contract } from 'lib/utils/tokens'; import { useWalletClient } from 'wagmi'; import { useTransactionStore } from '../../stores/transaction-store'; @@ -9,25 +9,25 @@ import { useHandleTransaction } from './useHandleTransaction'; // TODO: Add other kinds of transactions besides "revoke" transactions to the store -export const useRevoke = (allowance: AllowanceData, onUpdate: OnUpdate) => { +export const useRevoke = (allowance: TokenAllowanceData, onUpdate: OnUpdate) => { const { updateTransaction } = useTransactionStore(); const { data: walletClient } = useWalletClient(); const handleTransaction = useHandleTransaction(allowance.chainId); - if (!allowance.spender) { + if (!allowance.payload) { return { revoke: undefined }; } const revoke = wrapRevoke( allowance, - () => revokeAllowance(walletClient, allowance, onUpdate), + () => revokeAllowance(walletClient!, allowance, onUpdate), updateTransaction, handleTransaction, ); const update = async (newAmount: string) => { - const transactionPromise = updateErc20Allowance(walletClient, allowance, newAmount, onUpdate); + const transactionPromise = updateErc20Allowance(walletClient!, allowance, newAmount, onUpdate); return handleTransaction(transactionPromise, TransactionType.UPDATE); }; diff --git a/lib/hooks/ethereum/useRevokeBatch.tsx b/lib/hooks/ethereum/useRevokeBatch.tsx index 1ef144c8d..48215643f 100644 --- a/lib/hooks/ethereum/useRevokeBatch.tsx +++ b/lib/hooks/ethereum/useRevokeBatch.tsx @@ -1,7 +1,6 @@ 'use client'; -import { AllowanceData, OnUpdate } from 'lib/interfaces'; -import { getAllowanceKey, revokeAllowance, wrapRevoke } from 'lib/utils/allowances'; +import { getAllowanceKey, OnUpdate, revokeAllowance, TokenAllowanceData, wrapRevoke } from 'lib/utils/allowances'; import PQueue from 'p-queue'; import { useEffect, useMemo } from 'react'; import { useAsyncCallback } from 'react-async-hook'; @@ -11,7 +10,7 @@ import { useTransactionStore } from '../../stores/transaction-store'; // Limit to 50 concurrent revokes to avoid wallets crashing const REVOKE_QUEUE = new PQueue({ interval: 100, intervalCap: 1, concurrency: 50 }); -export const useRevokeBatch = (allowances: AllowanceData[], onUpdate: OnUpdate) => { +export const useRevokeBatch = (allowances: TokenAllowanceData[], onUpdate: OnUpdate) => { const { results, getTransaction, updateTransaction } = useTransactionStore(); const { data: walletClient } = useWalletClient(); @@ -31,7 +30,7 @@ export const useRevokeBatch = (allowances: AllowanceData[], onUpdate: OnUpdate) const revoke = wrapRevoke( allowance, - () => revokeAllowance(walletClient, allowance, onUpdate), + () => revokeAllowance(walletClient!, allowance, onUpdate), updateTransaction, ); diff --git a/lib/hooks/page-context/AddressPageContext.tsx b/lib/hooks/page-context/AddressPageContext.tsx index 8b27cacea..611385dc1 100644 --- a/lib/hooks/page-context/AddressPageContext.tsx +++ b/lib/hooks/page-context/AddressPageContext.tsx @@ -12,27 +12,28 @@ import { useAllowances } from '../ethereum/useAllowances'; import { useNameLookup } from '../ethereum/useNameLookup'; interface AddressContext { - address?: Address; + address: Address; domainName?: string; - selectedChainId?: number; - selectChain?: (chainId: number) => void; - eventContext?: ReturnType; - allowanceContext?: ReturnType; - signatureNoticeAcknowledged?: boolean; - acknowledgeSignatureNotice?: () => void; + selectedChainId: number; + selectChain: (chainId: number) => void; + eventContext: ReturnType; + allowanceContext: ReturnType; + signatureNoticeAcknowledged: boolean; + acknowledgeSignatureNotice: () => void; } interface Props { children: ReactNode; address: Address; - domainName?: string; + domainName?: string | null; initialChainId?: number; } -const AddressPageContext = React.createContext({}); +// We pass in undefined as the default value, since there should always be a provider for this context +const AddressPageContext = React.createContext(undefined as any); export const AddressPageContextProvider = ({ children, address, domainName, initialChainId }: Props) => { - const searchParams = useSearchParams(); + const searchParams = useSearchParams()!; const path = usePathname(); const router = useRouter(); const { chain } = useAccount(); @@ -40,7 +41,9 @@ export const AddressPageContextProvider = ({ children, address, domainName, init // The default selected chain ID is either the chainId query parameter, the connected chain ID, or 1 (Ethereum) const queryChainId = parseInt(searchParams.get('chainId') as string); - const defaultChainId = [initialChainId, queryChainId, chain?.id, 1].find((chainId) => isSupportedChain(chainId)); + const defaultChainId = [initialChainId, queryChainId, chain?.id, 1] + .filter(Boolean) + .find((chainId) => isSupportedChain(chainId!)) as number; const [selectedChainId, selectChain] = useState(defaultChainId); // Note: We use useLayoutEffect here, because this is the only setup that works with the "spenderSearch" query param as well @@ -66,7 +69,7 @@ export const AddressPageContextProvider = ({ children, address, domainName, init void; + darkMode: boolean; + theme: Theme; + setTheme: (theme: Theme) => void; } interface Props { children: ReactNode; } -const ColorThemeContext = React.createContext({}); +// We pass in undefined as the default value, since there should always be a provider for this context +const ColorThemeContext = React.createContext(undefined as any); export const ColorThemeProvider = ({ children }: Props) => { const [theme, setTheme] = useLocalStorage('theme', 'system'); diff --git a/lib/i18n/navigation.tsx b/lib/i18n/navigation.tsx index 7bff7c158..930ad4dd4 100644 --- a/lib/i18n/navigation.tsx +++ b/lib/i18n/navigation.tsx @@ -18,7 +18,7 @@ export function useRouter() { const push = ( href: string, options?: Parameters[1] & { showProgress?: boolean }, - ): ReturnType => { + ): ReturnType => { if (options?.showProgress !== false) nProgress.start(); return router.push(href, options); }; diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 206a074e2..2c4c95bc1 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -1,75 +1,6 @@ -import { ERC20_ABI, ERC721_ABI } from 'lib/abis'; -import { Abi, Address, Hash, Hex, PublicClient, TransactionReceipt, WalletClient } from 'viem'; -import type { useAllowances } from './hooks/ethereum/useAllowances'; - -export type Balance = bigint | 'ERC1155'; - -export interface BaseTokenData { - contract: Erc20TokenContract | Erc721TokenContract; - metadata: TokenMetadata; - chainId: number; - owner: Address; - balance: Balance; -} - -export interface BaseAllowanceData { - spender: Address; - lastUpdated: TimeLog; - amount?: bigint; // Only for ERC20 tokens - tokenId?: bigint; // Only for ERC721 tokens (single token) - permit2Address?: Address; // Only for Permit2 allowances - expiration?: number; // Only for Permit2 allowances -} - -export interface AllowanceData extends BaseTokenData { - spender?: Address; - lastUpdated?: TimeLog; - amount?: bigint; // Only for ERC20 tokens - tokenId?: bigint; // Only for ERC721 tokens (single token) - permit2Address?: Address; // Only for Permit2 allowances - expiration?: number; // Only for Permit2 allowances -} - -export interface PermitTokenData extends BaseTokenData { - lastCancelled?: TimeLog; -} - -export interface TokenFromList { - symbol: string; - decimals?: number; - logoURI?: string; - isSpam?: boolean; -} - -export interface TokenMapping { - [chainId: string]: ChainTokenMapping; -} - -export interface ChainTokenMapping { - [index: string]: TokenFromList; -} - -export type TokenStandard = 'ERC20' | 'ERC721'; - -export interface LogsProvider { - chainId: number; - getLogs(filter: Filter): Promise>; -} - -export type StateSetter = React.Dispatch>; - -export interface Log { - address: Address; - topics: [topic0: Hex, ...rest: Hex[]]; - data: Hex; - transactionHash: Hash; - blockNumber: number; - transactionIndex: number; - logIndex: number; - timestamp?: number; -} - -export type TimeLog = Pick; +import { Abi, Address, Hash, PublicClient, TransactionReceipt, WalletClient } from 'viem'; +import { TokenAllowanceData } from './utils/allowances'; +import { Filter, TimeLog } from './utils/events'; export interface RateLimit { interval: number; @@ -77,13 +8,6 @@ export interface RateLimit { timeout?: number; } -export interface Filter { - address?: Address; - topics: string[]; - fromBlock: number; - toBlock: number; -} - export enum TransactionType { REVOKE = 'revoke', UPDATE = 'update', @@ -106,7 +30,7 @@ export interface Marketplace { chainId: number; lastCancelled?: TimeLog; cancelSignatures: (walletClient: WalletClient) => Promise; - allowances: AllowanceData[]; + allowances: TokenAllowanceData[]; } export interface ISidebarEntry { @@ -158,7 +82,7 @@ export interface SpenderData extends SpenderRiskData { export interface SpenderRiskData { name?: string; - riskFactors?: Array; + riskFactors?: Nullable>; } export interface RiskFactor { @@ -175,28 +99,6 @@ export interface Contract { publicClient: PublicClient; } -export type TokenContract = Erc20TokenContract | Erc721TokenContract; - -export interface Erc20TokenContract extends Contract { - abi: typeof ERC20_ABI; -} - -export interface Erc721TokenContract extends Contract { - abi: typeof ERC721_ABI; -} - -export interface TokenMetadata { - // name: string; - symbol: string; - icon?: string; - decimals?: number; - totalSupply?: bigint; - price?: number; -} - -export type OnUpdate = ReturnType['onUpdate']; -export type OnCancel = (data: T, lastCancelled: TimeLog) => Promise; - export interface EtherscanPlatform { domain: string; subdomain?: string; @@ -208,3 +110,7 @@ export interface TransactionSubmitted { hash: Hash; confirmation: Promise; } + +export type OnCancel = (data: T, lastCancelled: TimeLog) => Promise; + +export type Nullable = T | null; diff --git a/lib/price/AbstractPriceStrategy.ts b/lib/price/AbstractPriceStrategy.ts index 5a9720461..7cacb9f82 100644 --- a/lib/price/AbstractPriceStrategy.ts +++ b/lib/price/AbstractPriceStrategy.ts @@ -1,5 +1,5 @@ import { ERC20_ABI } from 'lib/abis'; -import { TokenContract, TokenStandard } from 'lib/interfaces'; +import { TokenContract, TokenStandard } from 'lib/utils/tokens'; import { Address, PublicClient } from 'viem'; import { PriceStrategy } from './PriceStrategy'; import { strategySupportsToken } from './utils'; @@ -18,7 +18,7 @@ export abstract class AbstractPriceStrategy implements PriceStrategy { this.supportedAssets = options.supportedAssets; } - public async calculateNativeTokenPrice(publicClient: PublicClient): Promise { + public async calculateNativeTokenPrice(publicClient: PublicClient): Promise { if (!this.nativeAsset) { throw new Error('Native token type is not supported by this price strategy'); } @@ -32,7 +32,7 @@ export abstract class AbstractPriceStrategy implements PriceStrategy { return tokenPrice; } - public calculateTokenPrice(tokenContract: TokenContract): Promise { + public calculateTokenPrice(tokenContract: TokenContract): Promise { if (!strategySupportsToken(this, tokenContract)) { throw new Error('Token type is not supported by this price strategy'); } @@ -40,5 +40,5 @@ export abstract class AbstractPriceStrategy implements PriceStrategy { return this.calculateTokenPriceInternal(tokenContract); } - protected abstract calculateTokenPriceInternal(tokenContract: TokenContract): Promise; + protected abstract calculateTokenPriceInternal(tokenContract: TokenContract): Promise; } diff --git a/lib/price/AggregatePriceStrategy.ts b/lib/price/AggregatePriceStrategy.ts index 46443b588..40387da1e 100644 --- a/lib/price/AggregatePriceStrategy.ts +++ b/lib/price/AggregatePriceStrategy.ts @@ -1,6 +1,5 @@ -import { TokenContract, TokenStandard } from 'lib/interfaces'; -import { deduplicateArray } from 'lib/utils'; -import { isErc721Contract } from 'lib/utils/tokens'; +import { deduplicateArray, isNullish } from 'lib/utils'; +import { isErc721Contract, TokenContract, TokenStandard } from 'lib/utils/tokens'; import { PublicClient } from 'viem'; import { PriceStrategy } from './PriceStrategy'; @@ -32,12 +31,12 @@ export class AggregatePriceStrategy implements PriceStrategy { // Note: we only use the first strategy to calculate the native token price, so we only need to make sure that // the first strategy is able to calculate the native token price - public async calculateNativeTokenPrice(publicClient: PublicClient): Promise { + public async calculateNativeTokenPrice(publicClient: PublicClient): Promise { if (this.strategies.length === 0) throw new Error('No strategies provided'); - return this.strategies.at(0).calculateNativeTokenPrice(publicClient); + return this.strategies[0].calculateNativeTokenPrice(publicClient); } - public async calculateTokenPrice(tokenContract: TokenContract): Promise { + public async calculateTokenPrice(tokenContract: TokenContract): Promise { const supportedStrategies = this.getSupportedStrategies(tokenContract); if (supportedStrategies.length === 0) throw new Error('No supported strategies provided for this token type'); @@ -50,16 +49,21 @@ export class AggregatePriceStrategy implements PriceStrategy { supportedStrategies.map((strategy) => strategy.calculateTokenPrice(tokenContract)), ); - const sum = results.reduce((acc, curr) => acc + curr, 0); - return sum / results.length; + const validResults = results.filter((result) => !isNullish(result)); + + const sum = validResults.reduce((acc, curr) => acc + curr, 0); + return sum / validResults.length; } if (this.aggregationType === AggregationType.MAX) { const results = await Promise.all( supportedStrategies.map((strategy) => strategy.calculateTokenPrice(tokenContract)), ); - return Math.max(...results); + const validResults = results.filter((result) => !isNullish(result)); + return Math.max(...validResults); } + + throw new Error('Invalid aggregation type'); } public getSupportedStrategies(tokenContract: TokenContract): PriceStrategy[] { diff --git a/lib/price/BackendPriceStrategy.ts b/lib/price/BackendPriceStrategy.ts index c4d0d136b..497e80a1c 100644 --- a/lib/price/BackendPriceStrategy.ts +++ b/lib/price/BackendPriceStrategy.ts @@ -1,5 +1,5 @@ -import { TokenContract } from 'lib/interfaces'; import ky from 'lib/ky'; +import { TokenContract } from 'lib/utils/tokens'; import { AbstractPriceStrategy, AbstractPriceStrategyOptions } from './AbstractPriceStrategy'; import { PriceStrategy } from './PriceStrategy'; @@ -12,7 +12,7 @@ export class BackendPriceStrategy extends AbstractPriceStrategy implements Price protected async calculateTokenPriceInternal(tokenContract: TokenContract): Promise { const result = await ky - .get(`/api/${tokenContract.publicClient.chain.id}/floorPrice?contractAddress=${tokenContract.address}`) + .get(`/api/${tokenContract.publicClient.chain!.id}/floorPrice?contractAddress=${tokenContract.address}`) .json<{ floorPrice: number }>(); return result.floorPrice; diff --git a/lib/price/HardcodedPriceStrategy.ts b/lib/price/HardcodedPriceStrategy.ts index 1a37c8b83..8db654538 100644 --- a/lib/price/HardcodedPriceStrategy.ts +++ b/lib/price/HardcodedPriceStrategy.ts @@ -1,4 +1,4 @@ -import { TokenContract, TokenStandard } from 'lib/interfaces'; +import { TokenContract, TokenStandard } from 'lib/utils/tokens'; import { Address, PublicClient } from 'viem'; import { PriceStrategy } from './PriceStrategy'; diff --git a/lib/price/PriceStrategy.ts b/lib/price/PriceStrategy.ts index d9a0ae65e..c4a337a8d 100644 --- a/lib/price/PriceStrategy.ts +++ b/lib/price/PriceStrategy.ts @@ -1,8 +1,8 @@ -import { TokenContract, TokenStandard } from 'lib/interfaces'; -import { PublicClient } from 'viem'; +import type { TokenContract, TokenStandard } from 'lib/utils/tokens'; +import type { PublicClient } from 'viem'; export interface PriceStrategy { supportedAssets: TokenStandard[]; - calculateNativeTokenPrice: (publicClient: PublicClient) => Promise; - calculateTokenPrice: (tokenContract: TokenContract) => Promise; + calculateNativeTokenPrice: (publicClient: PublicClient) => Promise; + calculateTokenPrice: (tokenContract: TokenContract) => Promise; } diff --git a/lib/price/ReservoirNftPriceStrategy.ts b/lib/price/ReservoirNftPriceStrategy.ts index d20b1b0bb..f91b61c1f 100644 --- a/lib/price/ReservoirNftPriceStrategy.ts +++ b/lib/price/ReservoirNftPriceStrategy.ts @@ -1,11 +1,11 @@ import { SearchParamsOption, TimeoutError } from 'ky'; -import { Erc721TokenContract } from 'lib/interfaces'; import ky from 'lib/ky'; import { isRateLimitError } from 'lib/utils/errors'; import { SECOND } from 'lib/utils/time'; +import type { Erc721TokenContract } from 'lib/utils/tokens'; import { RequestQueue } from '../api/logs/RequestQueue'; import { AbstractPriceStrategy } from './AbstractPriceStrategy'; -import { PriceStrategy } from './PriceStrategy'; +import type { PriceStrategy } from './PriceStrategy'; // Don't return a price if the collection is on the ignore list const IGNORE_LIST = [ @@ -97,10 +97,12 @@ export class ReservoirNftPriceStrategy extends AbstractPriceStrategy implements return result; } catch (e) { // See (https://github.com/sindresorhus/ky#readme) and search for TimoutError - if (e instanceof TimeoutError || e.message === 'Manual timeout') { + if (e instanceof TimeoutError || (e as any).message === 'Manual timeout') { console.error('Reservoir: Request timed out, will not retry'); - throw new Error(`Request timed out for ${e.request.url} with search params ${JSON.stringify(searchParams)}`); + throw new Error( + `Request timed out for ${(e as any).request.url} with search params ${JSON.stringify(searchParams)}`, + ); } if (isRateLimitError(e)) { @@ -109,7 +111,7 @@ export class ReservoirNftPriceStrategy extends AbstractPriceStrategy implements return this.makeGetRequest(url, searchParams); } - throw new Error(e.data?.error_message ?? e.message); + throw new Error((e as any).data?.error_message ?? (e as any).message); } } } @@ -117,9 +119,9 @@ export class ReservoirNftPriceStrategy extends AbstractPriceStrategy implements // TODO: Should we perform this volume check here? Or take the volume across subcollections? const pickCheapestSubcollectionWithVolume = (collections: ReservoirNFTCollection[]): ReservoirNFTCollection => { const viableCollections = collections - .filter((collection) => !!collection.volume['7day']) + .filter((collection) => !!collection.volume?.['7day']) .filter((collection) => !!collection.floorAsk?.price?.amount?.usd) - .sort((a, b) => a.floorAsk?.price?.amount?.usd - b.floorAsk?.price?.amount?.usd); + .sort((a, b) => (a.floorAsk?.price?.amount?.usd ?? 0) - (b.floorAsk?.price?.amount?.usd ?? 0)); return viableCollections[0]; }; diff --git a/lib/price/UniswapV2PriceStrategy.ts b/lib/price/UniswapV2PriceStrategy.ts index a0e7fc631..5aa6b9eca 100644 --- a/lib/price/UniswapV2PriceStrategy.ts +++ b/lib/price/UniswapV2PriceStrategy.ts @@ -1,6 +1,6 @@ import { UNISWAP_V2_ROUTER_ABI } from 'lib/abis'; -import { Erc20TokenContract } from 'lib/interfaces'; import { fixedPointMultiply } from 'lib/utils/math'; +import { Erc20TokenContract } from 'lib/utils/tokens'; import { Address, parseUnits } from 'viem'; import { AbstractPriceStrategy, AbstractPriceStrategyOptions } from './AbstractPriceStrategy'; import { PriceStrategy } from './PriceStrategy'; @@ -50,7 +50,7 @@ export class UniswapV2PriceStrategy extends AbstractPriceStrategy implements Pri this.fee = options.feeParameters?.fee ?? []; } - protected async calculateTokenPriceInternal(tokenContract: Erc20TokenContract): Promise { + protected async calculateTokenPriceInternal(tokenContract: Erc20TokenContract): Promise { if (tokenContract.address === this.path.at(-1)) { return 1; } diff --git a/lib/price/UniswapV3PriceStrategy.ts b/lib/price/UniswapV3PriceStrategy.ts index 385846820..e21dab56a 100644 --- a/lib/price/UniswapV3PriceStrategy.ts +++ b/lib/price/UniswapV3PriceStrategy.ts @@ -1,5 +1,5 @@ import { UNISWAP_V3_QUOTER_ABI } from 'lib/abis'; -import { Erc20TokenContract } from 'lib/interfaces'; +import { Erc20TokenContract } from 'lib/utils/tokens'; import { Address, concat, parseUnits } from 'viem'; import { AbstractPriceStrategy, AbstractPriceStrategyOptions } from './AbstractPriceStrategy'; import { PriceStrategy } from './PriceStrategy'; @@ -30,7 +30,7 @@ export class UniswapV3PriceStrategy extends AbstractPriceStrategy implements Pri this.decimals = options.decimals ?? 18; } - protected async calculateTokenPriceInternal(tokenContract: Erc20TokenContract): Promise { + protected async calculateTokenPriceInternal(tokenContract: Erc20TokenContract): Promise { if (tokenContract.address === this.path.at(-1)) { // return parseUnits(String(1), this.decimals); diff --git a/lib/price/UniswapV3ReadonlyPriceStrategy.ts b/lib/price/UniswapV3ReadonlyPriceStrategy.ts index b0176be55..89ec15c6e 100644 --- a/lib/price/UniswapV3ReadonlyPriceStrategy.ts +++ b/lib/price/UniswapV3ReadonlyPriceStrategy.ts @@ -1,5 +1,5 @@ import { UNISWAP_V3_POOL_ABI } from 'lib/abis'; -import { Erc20TokenContract } from 'lib/interfaces'; +import { Erc20TokenContract } from 'lib/utils/tokens'; import { Address, Hex, encodeAbiParameters, getCreate2Address, hexToNumber, keccak256, parseAbiParameters } from 'viem'; import { UniswapV3PriceStrategy, UniswapV3PriceStrategyOptions } from './UniswapV3PriceStrategy'; import { calculateTokenPrice } from './utils'; @@ -35,7 +35,7 @@ export class UniswapV3ReadonlyPriceStrategy extends UniswapV3PriceStrategy { this.minLiquidity = options.liquidityParameters?.minLiquidity ?? 10n ** 17n; } - protected async calculateTokenPriceInternal(tokenContract: Erc20TokenContract): Promise { + protected async calculateTokenPriceInternal(tokenContract: Erc20TokenContract): Promise { if (tokenContract.address === this.path.at(-1)) { return 1; } diff --git a/lib/price/utils.ts b/lib/price/utils.ts index 9a96bac85..7c4c294cf 100644 --- a/lib/price/utils.ts +++ b/lib/price/utils.ts @@ -1,34 +1,33 @@ -import { TokenContract } from 'lib/interfaces'; import { isNullish } from 'lib/utils'; import { getChainPriceStrategy } from 'lib/utils/chains'; -import { isErc721Contract } from 'lib/utils/tokens'; -import { PublicClient, formatUnits } from 'viem'; -import { PriceStrategy } from './PriceStrategy'; +import { isErc721Contract, type TokenContract } from 'lib/utils/tokens'; +import { formatUnits, type PublicClient } from 'viem'; +import type { PriceStrategy } from './PriceStrategy'; -export const calculateTokenPrice = (inversePrice: bigint | null, tokenDecimals: number): number => { - return !isNullish(inversePrice) ? 1 / Number.parseFloat(formatUnits(inversePrice, tokenDecimals)) : null; +export const calculateTokenPrice = (inversePrice: bigint | null, tokenDecimals: number): number | undefined => { + return isNullish(inversePrice) ? undefined : 1 / Number.parseFloat(formatUnits(inversePrice, tokenDecimals)); }; -export const getNativeTokenPrice = async (chainId: number, publicClient: PublicClient): Promise => { +export const getNativeTokenPrice = async (chainId: number, publicClient: PublicClient): Promise => { const strategy = getChainPriceStrategy(chainId); - if (!strategy) return null; + if (!strategy) return undefined; try { return await strategy.calculateNativeTokenPrice(publicClient); } catch { - return null; + return undefined; } }; -export const getTokenPrice = async (chainId: number, tokenContract: TokenContract): Promise => { +export const getTokenPrice = async (chainId: number, tokenContract: TokenContract): Promise => { const strategy = getChainPriceStrategy(chainId); - if (!strategy || !strategySupportsToken(strategy, tokenContract)) return null; + if (!strategy || !strategySupportsToken(strategy, tokenContract)) return undefined; try { return await strategy.calculateTokenPrice(tokenContract); } catch { - return null; + return undefined; } }; diff --git a/lib/providers.ts b/lib/providers.ts index dcc89c383..1a88ac3da 100644 --- a/lib/providers.ts +++ b/lib/providers.ts @@ -1,7 +1,6 @@ import ky from 'lib/ky'; import { PublicClient, getAddress } from 'viem'; import { RequestQueue } from './api/logs/RequestQueue'; -import type { Filter, Log, LogsProvider } from './interfaces'; import { createViemPublicClientForChain, getChainLogsRpcUrl, @@ -9,6 +8,12 @@ import { isCovalentSupportedChain, } from './utils/chains'; import { isLogResponseSizeError } from './utils/errors'; +import { Filter, Log } from './utils/events'; + +export interface LogsProvider { + chainId: number; + getLogs(filter: Filter): Promise>; +} export class DivideAndConquerLogsProvider implements LogsProvider { constructor(private underlyingProvider: LogsProvider) {} @@ -60,7 +65,7 @@ export class BackendLogsProvider implements LogsProvider { ky.post(`/api/${this.chainId}/logs`, { json: filter, timeout: false }).json(), ); } catch (error) { - throw new Error(error?.data?.message ?? error?.message); + throw new Error((error as any).data?.message ?? (error as any).message); } } } diff --git a/lib/stores/transaction-store.ts b/lib/stores/transaction-store.ts index f14c34714..4db3066aa 100644 --- a/lib/stores/transaction-store.ts +++ b/lib/stores/transaction-store.ts @@ -1,5 +1,5 @@ -import { AllowanceData, TransactionStatus } from 'lib/interfaces'; -import { getAllowanceKey } from 'lib/utils/allowances'; +import { TransactionStatus } from 'lib/interfaces'; +import { getAllowanceKey, TokenAllowanceData } from 'lib/utils/allowances'; import { Hash } from 'viem'; import { create } from 'zustand'; @@ -15,16 +15,16 @@ export interface TransactionResult { export interface TransactionStore { results: TransactionResults; - getTransaction: (allowance: AllowanceData) => TransactionResult; - updateTransaction: (allowance: AllowanceData, result: TransactionResult, override?: boolean) => void; + getTransaction: (allowance: TokenAllowanceData) => TransactionResult; + updateTransaction: (allowance: TokenAllowanceData, result: TransactionResult, override?: boolean) => void; } export const useTransactionStore = create((set, get) => ({ results: {}, - getTransaction: (allowance: AllowanceData) => { + getTransaction: (allowance: TokenAllowanceData) => { return get().results[getAllowanceKey(allowance)] ?? { status: 'not_started' as const }; }, - updateTransaction: (allowance: AllowanceData, result: TransactionResult, override: boolean = true) => { + updateTransaction: (allowance: TokenAllowanceData, result: TransactionResult, override: boolean = true) => { const key = getAllowanceKey(allowance); set((state) => ({ results: { diff --git a/lib/utils/allowances.ts b/lib/utils/allowances.ts index 20e5434e9..46ab5b80a 100644 --- a/lib/utils/allowances.ts +++ b/lib/utils/allowances.ts @@ -1,21 +1,11 @@ -import { ADDRESS_ZERO, MOONBIRDS_ADDRESS } from 'lib/constants'; +import { ADDRESS_ZERO } from 'lib/constants'; import blocksDB from 'lib/databases/blocks'; +import { useAllowances } from 'lib/hooks/ethereum/useAllowances'; import { useHandleTransaction } from 'lib/hooks/ethereum/useHandleTransaction'; -import { - TransactionType, - type AllowanceData, - type BaseAllowanceData, - type BaseTokenData, - type Erc20TokenContract, - type Erc721TokenContract, - type Log, - type OnUpdate, - type TokenContract, - type TransactionSubmitted, -} from 'lib/interfaces'; +import { type TransactionSubmitted, TransactionType } from 'lib/interfaces'; import { TransactionStore } from 'lib/stores/transaction-store'; -import { Address, PublicClient, toEventSelector, WalletClient, WriteContractParameters } from 'viem'; -import { addressToTopic, deduplicateArray, waitForTransactionConfirmation, writeContractUnlessExcessiveGas } from '.'; +import { type Address, formatUnits, type PublicClient, type WalletClient, type WriteContractParameters } from 'viem'; +import { deduplicateArray, isNullish, waitForTransactionConfirmation, writeContractUnlessExcessiveGas } from '.'; import { track } from './analytics'; import { isNetworkError, isRevertedError, isUserRejectionError, parseErrorMessage, stringifyError } from './errors'; import { @@ -23,19 +13,80 @@ import { Erc721ApprovalEvent, Erc721ApprovalForAllEvent, Erc721TransferEvent, + TimeLog, TokenEvent, TokenEventType, } from './events'; import { formatFixedPointBigInt, parseFixedPointBigInt } from './formatting'; +import { bigintMin, fixedPointMultiply } from './math'; import { getPermit2AllowancesFromApprovals, preparePermit2Approve } from './permit2'; -import { createTokenContracts, getTokenData, hasZeroBalance, isErc721Contract } from './tokens'; +import { + createTokenContracts, + type Erc20TokenContract, + type Erc721TokenContract, + getTokenData, + hasZeroBalance, + isErc721Contract, + type TokenContract, + type TokenData, +} from './tokens'; + +export interface TokenAllowanceData extends TokenData { + payload?: AllowancePayload; +} + +export type AllowancePayload = Erc721SingleAllowance | Erc721AllAllowance | Erc20Allowance | Permit2Erc20Allowance; + +export enum AllowanceType { + ERC721_SINGLE = 'ERC721_SINGLE', + ERC721_ALL = 'ERC721_ALL', + ERC20 = 'ERC20', + PERMIT2 = 'PERMIT2', +} + +export interface BaseAllowance { + type: AllowanceType; + spender: Address; + lastUpdated: TimeLog; +} + +export interface Erc721SingleAllowance extends BaseAllowance { + type: AllowanceType.ERC721_SINGLE; + tokenId: bigint; +} + +export interface Erc721AllAllowance extends BaseAllowance { + type: AllowanceType.ERC721_ALL; +} + +export interface Erc20Allowance extends BaseAllowance { + type: AllowanceType.ERC20; + amount: bigint; +} + +export interface Permit2Erc20Allowance extends BaseAllowance { + type: AllowanceType.PERMIT2; + amount: bigint; + permit2Address: Address; + expiration: number; +} + +export const isErc20Allowance = (allowance?: AllowancePayload): allowance is Erc20Allowance | Permit2Erc20Allowance => + allowance?.type === AllowanceType.ERC20 || allowance?.type === AllowanceType.PERMIT2; + +export const isErc721Allowance = ( + allowance?: AllowancePayload, +): allowance is Erc721SingleAllowance | Erc721AllAllowance => + allowance?.type === AllowanceType.ERC721_SINGLE || allowance?.type === AllowanceType.ERC721_ALL; + +export type OnUpdate = ReturnType['onUpdate']; export const getAllowancesFromEvents = async ( owner: Address, events: TokenEvent[], publicClient: PublicClient, chainId: number, -): Promise => { +): Promise => { const contracts = createTokenContracts(events, publicClient); // Look up token data for all tokens, add their lists of approvals @@ -53,10 +104,10 @@ export const getAllowancesFromEvents = async ( const allowances = unfilteredAllowances.filter((allowance) => !hasZeroAllowance(allowance, tokenData)); if (allowances.length === 0) { - return [tokenData as AllowanceData]; + return [tokenData as TokenAllowanceData]; } - const fullAllowances = allowances.map((allowance) => ({ ...tokenData, ...allowance })); + const fullAllowances = allowances.map((allowance) => ({ ...tokenData, payload: allowance })); return fullAllowances; } catch (e) { if (isNetworkError(e)) throw e; @@ -72,8 +123,8 @@ export const getAllowancesFromEvents = async ( // Filter out any zero-balance + zero-allowance tokens return allowances .flat() - .filter((allowance) => allowance.spender || allowance.balance !== 'ERC1155') - .filter((allowance) => allowance.spender || !hasZeroBalance(allowance.balance, allowance.metadata.decimals)) + .filter((allowance) => allowance.payload || allowance.balance !== 'ERC1155') + .filter((allowance) => allowance.payload || !hasZeroBalance(allowance.balance, allowance.metadata.decimals)) .sort((a, b) => a.metadata.symbol.localeCompare(b.metadata.symbol)); }; @@ -81,20 +132,15 @@ export const getAllowancesForToken = async ( contract: TokenContract, events: TokenEvent[], userAddress: Address, -): Promise => { +): Promise => { if (isErc721Contract(contract)) { const unlimitedAllowances = await getUnlimitedErc721AllowancesFromApprovals(contract, userAddress, events); const limitedAllowances = await getLimitedErc721AllowancesFromApprovals(contract, events); - - const allowances = [...limitedAllowances, ...unlimitedAllowances].filter((allowance) => !!allowance); - - return allowances; + return [...limitedAllowances, ...unlimitedAllowances]; } else { const regularAllowances = await getErc20AllowancesFromApprovals(contract, userAddress, events); const permit2Allowances = await getPermit2AllowancesFromApprovals(contract, userAddress, events); - const allAllowances = [...regularAllowances, ...permit2Allowances]; - - return allAllowances; + return [...regularAllowances, ...permit2Allowances]; } }; @@ -102,7 +148,7 @@ export const getErc20AllowancesFromApprovals = async ( contract: Erc20TokenContract, owner: Address, events: TokenEvent[], -) => { +): Promise => { const approvalEvents = events.filter((event) => event.type === TokenEventType.APPROVAL_ERC20); const deduplicatedApprovalEvents = deduplicateArray( approvalEvents, @@ -113,14 +159,14 @@ export const getErc20AllowancesFromApprovals = async ( deduplicatedApprovalEvents.map((approval) => getErc20AllowanceFromApproval(contract, owner, approval)), ); - return allowances; + return allowances.filter((allowance) => !isNullish(allowance)); }; const getErc20AllowanceFromApproval = async ( contract: Erc20TokenContract, owner: Address, approval: Erc20ApprovalEvent, -): Promise => { +): Promise => { const { spender, amount: lastApprovedAmount } = approval.payload; // If the most recent approval event was for 0, then we know for sure that the allowance is 0 @@ -137,10 +183,13 @@ const getErc20AllowanceFromApproval = async ( blocksDB.getTimeLog(contract.publicClient, approval.time), ]); - return { spender, amount, lastUpdated }; + return { type: AllowanceType.ERC20, spender, amount, lastUpdated }; }; -export const getLimitedErc721AllowancesFromApprovals = async (contract: Erc721TokenContract, events: TokenEvent[]) => { +export const getLimitedErc721AllowancesFromApprovals = async ( + contract: Erc721TokenContract, + events: TokenEvent[], +): Promise => { const singeTokenIdEvents = events.filter( (event) => event.type === TokenEventType.APPROVAL_ERC721 || event.type === TokenEventType.TRANSFER_ERC721, ); @@ -155,13 +204,13 @@ export const getLimitedErc721AllowancesFromApprovals = async (contract: Erc721To deduplicatedEvents.map((event) => getLimitedErc721AllowanceFromApproval(contract, event)), ); - return allowances; + return allowances.filter((allowance) => !isNullish(allowance)); }; const getLimitedErc721AllowanceFromApproval = async ( contract: Erc721TokenContract, event: Erc721ApprovalEvent | Erc721TransferEvent, -) => { +): Promise => { // "limited" NFT approvals are reset on transfer, so if the NFT was transferred more recently than it was approved, // we know for sure that the allowance is revoked if (event.type === TokenEventType.TRANSFER_ERC721) return undefined; @@ -173,14 +222,14 @@ const getLimitedErc721AllowanceFromApproval = async ( const [lastUpdated] = await Promise.all([blocksDB.getTimeLog(contract.publicClient, event.time)]); - return { spender, tokenId, lastUpdated }; + return { type: AllowanceType.ERC721_SINGLE, spender, tokenId, lastUpdated }; }; export const getUnlimitedErc721AllowancesFromApprovals = async ( contract: Erc721TokenContract, owner: string, events: TokenEvent[], -) => { +): Promise => { const approvalForAllEvents = events.filter((event) => event.type === TokenEventType.APPROVAL_FOR_ALL); const deduplicatedApprovalForAllEvents = deduplicateArray( approvalForAllEvents, @@ -193,14 +242,14 @@ export const getUnlimitedErc721AllowancesFromApprovals = async ( ), ); - return allowances; + return allowances.filter((allowance) => !isNullish(allowance)); }; const getUnlimitedErc721AllowanceFromApproval = async ( contract: Erc721TokenContract, _owner: string, approval: Erc721ApprovalForAllEvent, -) => { +): Promise => { const { spender, approved: isApprovedForAll } = approval.payload; // If the most recent approval event was false, we know that the approval is revoked, and we don't need to check the chain @@ -208,77 +257,56 @@ const getUnlimitedErc721AllowanceFromApproval = async ( const [lastUpdated] = await Promise.all([blocksDB.getTimeLog(contract.publicClient, approval.time)]); - return { spender, lastUpdated }; + return { type: AllowanceType.ERC721_ALL, spender, lastUpdated }; }; -export const formatErc20Allowance = (allowance: bigint, decimals: number, totalSupply: bigint): string => { - if (allowance > totalSupply) { +export const formatErc20Allowance = (allowance: bigint, decimals?: number, totalSupply?: bigint): string => { + if (totalSupply && allowance > totalSupply) { return 'Unlimited'; } return formatFixedPointBigInt(allowance, decimals); }; -export const getAllowanceI18nValues = (allowance: AllowanceData) => { - if (!allowance.spender) { +export const getAllowanceI18nValues = (allowance: TokenAllowanceData) => { + if (!allowance.payload) { const i18nKey = 'address.allowances.none'; return { i18nKey }; } - if (allowance.amount) { - const amount = formatErc20Allowance(allowance.amount, allowance.metadata.decimals, allowance.metadata.totalSupply); + if (isErc20Allowance(allowance.payload)) { + const amount = formatErc20Allowance( + allowance.payload.amount, + allowance.metadata.decimals, + allowance.metadata.totalSupply, + ); const i18nKey = amount === 'Unlimited' ? 'address.allowances.unlimited' : 'address.allowances.amount'; const { symbol } = allowance.metadata; return { amount, i18nKey, symbol }; } - const i18nKey = allowance.tokenId === undefined ? 'address.allowances.unlimited' : 'address.allowances.token_id'; - const { tokenId } = allowance; - return { tokenId: tokenId?.toString(), i18nKey }; -}; + if (allowance.payload.type === AllowanceType.ERC721_SINGLE) { + const i18nKey = 'address.allowances.token_id'; + const tokenId = allowance.payload.tokenId?.toString(); + return { tokenId, i18nKey }; + } -// This function is a hardcoded patch to show Moonbirds' OpenSea allowances, -// which do not show up normally because of a bug in their contract -export const generatePatchedAllowanceEvents = ( - userAddress: Address, - openseaProxyAddress?: Address, - allEvents: Log[] = [], -): Log[] => { - if (!userAddress || !openseaProxyAddress) return []; - - // Only add the Moonbirds approval event if the account has interacted with Moonbirds at all - if (!allEvents.some((ev) => ev.address === MOONBIRDS_ADDRESS)) return []; - - return [ - { - // We use the deployment transaction hash as a placeholder for the approval transaction hash - transactionHash: '0xd4547dc336dd4a0655f11267537964d7641f115ef3d5440d71514e3efba9d210', - blockNumber: 14591056, - transactionIndex: 145, - logIndex: 0, - address: MOONBIRDS_ADDRESS, - topics: [ - toEventSelector('ApprovalForAll(address,address,bool)'), - addressToTopic(userAddress), - addressToTopic(openseaProxyAddress), - ], - data: '0x1', - timestamp: 1649997510, - }, - ]; + const i18nKey = 'address.allowances.unlimited'; + return { i18nKey }; }; -export const stripAllowanceData = (allowance: AllowanceData): BaseTokenData => { - const { contract, metadata, chainId, owner, balance } = allowance; +export const stripAllowanceData = (allowance: TokenAllowanceData): TokenAllowanceData => { + const { contract, metadata, chainId, owner, balance, payload: _payload } = allowance; return { contract, metadata, chainId, owner, balance }; }; -export const getAllowanceKey = (allowance: AllowanceData) => { - return `${allowance.contract.address}-${allowance.spender}-${allowance.tokenId}-${allowance.chainId}-${allowance.owner}`; +export const getAllowanceKey = (allowance: TokenAllowanceData) => { + return `${allowance.contract.address}-${allowance.payload?.spender}-${(allowance.payload as any)?.tokenId}-${allowance.chainId}-${allowance.owner}`; }; -export const hasZeroAllowance = (allowance: BaseAllowanceData, tokenData: BaseTokenData) => { +export const hasZeroAllowance = (allowance: AllowancePayload, tokenData: TokenAllowanceData) => { if (!allowance) return true; + if (!isErc20Allowance(allowance)) return false; return ( formatErc20Allowance(allowance.amount, tokenData?.metadata?.decimals, tokenData?.metadata?.totalSupply) === '0' @@ -287,12 +315,10 @@ export const hasZeroAllowance = (allowance: BaseAllowanceData, tokenData: BaseTo export const revokeAllowance = async ( walletClient: WalletClient, - allowance: AllowanceData, + allowance: TokenAllowanceData, onUpdate: OnUpdate, ): Promise => { - if (!allowance.spender) { - return undefined; - } + if (!allowance.payload) return undefined; if (isErc721Contract(allowance.contract)) { return revokeErc721Allowance(walletClient, allowance, onUpdate); @@ -303,7 +329,7 @@ export const revokeAllowance = async ( export const revokeErc721Allowance = async ( walletClient: WalletClient, - allowance: AllowanceData, + allowance: TokenAllowanceData, onUpdate: OnUpdate, ): Promise => { const transactionRequest = await prepareRevokeErc721Allowance(walletClient, allowance); @@ -321,7 +347,7 @@ export const revokeErc721Allowance = async ( export const revokeErc20Allowance = async ( walletClient: WalletClient, - allowance: AllowanceData, + allowance: TokenAllowanceData, onUpdate: OnUpdate, ): Promise => { return updateErc20Allowance(walletClient, allowance, '0', onUpdate); @@ -329,7 +355,7 @@ export const revokeErc20Allowance = async ( export const updateErc20Allowance = async ( walletClient: WalletClient, - allowance: AllowanceData, + allowance: TokenAllowanceData, newAmount: string, onUpdate: OnUpdate, ): Promise => { @@ -338,10 +364,12 @@ export const updateErc20Allowance = async ( if (!transactionRequest) return; const hash = await writeContractUnlessExcessiveGas(allowance.contract.publicClient, walletClient, transactionRequest); - trackTransaction(allowance, hash, newAmount, allowance.expiration); + trackTransaction(allowance, hash, newAmount); const waitForConfirmation = async () => { const transactionReceipt = await waitForTransactionConfirmation(hash, allowance.contract.publicClient); + if (!transactionReceipt) return; + const lastUpdated = await blocksDB.getTimeLog(allowance.contract.publicClient, { ...transactionReceipt, blockNumber: Number(transactionReceipt.blockNumber), @@ -355,10 +383,8 @@ export const updateErc20Allowance = async ( return { hash, confirmation: waitForConfirmation() }; }; -export const prepareRevokeAllowance = async (walletClient: WalletClient, allowance: AllowanceData) => { - if (!allowance.spender) { - return undefined; - } +export const prepareRevokeAllowance = async (walletClient: WalletClient, allowance: TokenAllowanceData) => { + if (!allowance.payload) return undefined; if (isErc721Contract(allowance.contract)) { return prepareRevokeErc721Allowance(walletClient, allowance); @@ -369,13 +395,15 @@ export const prepareRevokeAllowance = async (walletClient: WalletClient, allowan export const prepareRevokeErc721Allowance = async ( walletClient: WalletClient, - allowance: AllowanceData, + allowance: TokenAllowanceData, ): Promise => { - if (allowance.tokenId !== undefined) { + if (!allowance.payload) throw new Error('Cannot revoke undefined allowance'); + + if (allowance.payload.type === AllowanceType.ERC721_SINGLE) { const transactionRequest = { ...(allowance.contract as Erc721TokenContract), functionName: 'approve' as const, - args: [ADDRESS_ZERO, allowance.tokenId] as const, + args: [ADDRESS_ZERO, allowance.payload.tokenId] as const, account: allowance.owner, chain: walletClient.chain, value: 0n as any as never, // Workaround for Gnosis Safe, TODO: remove when fixed @@ -388,7 +416,7 @@ export const prepareRevokeErc721Allowance = async ( const transactionRequest = { ...(allowance.contract as Erc721TokenContract), functionName: 'setApprovalForAll' as const, - args: [allowance.spender, false] as const, + args: [allowance.payload.spender, false] as const, account: allowance.owner, chain: walletClient.chain, value: 0n as any as never, // Workaround for Gnosis Safe, TODO: remove when fixed @@ -400,31 +428,33 @@ export const prepareRevokeErc721Allowance = async ( export const prepareRevokeErc20Allowance = async ( walletClient: WalletClient, - allowance: AllowanceData, + allowance: TokenAllowanceData, ): Promise => { return prepareUpdateErc20Allowance(walletClient, allowance, 0n); }; export const prepareUpdateErc20Allowance = async ( walletClient: WalletClient, - allowance: AllowanceData, + allowance: TokenAllowanceData, newAmount: bigint, ): Promise => { - if (isErc721Contract(allowance.contract)) { + if (!allowance.payload) throw new Error('Cannot update undefined allowance'); + + if (isErc721Contract(allowance.contract) || isErc721Allowance(allowance.payload)) { throw new Error('Cannot update ERC721 allowances'); } - const differenceAmount = newAmount - allowance.amount; + const differenceAmount = newAmount - allowance.payload.amount; if (differenceAmount === 0n) return; - if (allowance.expiration !== undefined) { + if (allowance.payload.type === AllowanceType.PERMIT2) { return preparePermit2Approve( - allowance.permit2Address, + allowance.payload.permit2Address, walletClient, allowance.contract, - allowance.spender, + allowance.payload.spender, newAmount, - allowance.expiration, + allowance.payload.expiration, ); } @@ -436,12 +466,12 @@ export const prepareUpdateErc20Allowance = async ( }; try { - console.debug(`Calling contract.approve(${allowance.spender}, ${newAmount})`); + console.debug(`Calling contract.approve(${allowance.payload.spender}, ${newAmount})`); const transactionRequest = { ...baseRequest, functionName: 'approve' as const, - args: [allowance.spender, newAmount] as const, + args: [allowance.payload.spender, newAmount] as const, }; const gas = await allowance.contract.publicClient.estimateContractGas(transactionRequest); @@ -451,23 +481,23 @@ export const prepareUpdateErc20Allowance = async ( // Some tokens can only change approval with {increase|decrease}Approval if (differenceAmount > 0n) { - console.debug(`Calling contract.increaseAllowance(${allowance.spender}, ${differenceAmount})`); + console.debug(`Calling contract.increaseAllowance(${allowance.payload.spender}, ${differenceAmount})`); const transactionRequest = { ...baseRequest, functionName: 'increaseAllowance' as const, - args: [allowance.spender, differenceAmount] as const, + args: [allowance.payload.spender, differenceAmount] as const, }; const gas = await allowance.contract.publicClient.estimateContractGas(transactionRequest); return { ...transactionRequest, gas }; } else { - console.debug(`Calling contract.decreaseAllowance(${allowance.spender}, ${-differenceAmount})`); + console.debug(`Calling contract.decreaseAllowance(${allowance.payload.spender}, ${-differenceAmount})`); const transactionRequest = { ...baseRequest, functionName: 'decreaseAllowance' as const, - args: [allowance.spender, -differenceAmount] as const, + args: [allowance.payload.spender, -differenceAmount] as const, }; const gas = await allowance.contract.publicClient.estimateContractGas(transactionRequest); @@ -476,33 +506,33 @@ export const prepareUpdateErc20Allowance = async ( } }; -const trackTransaction = (allowance: AllowanceData, hash: string, newAmount?: string, expiration?: number) => { +const trackTransaction = (allowance: TokenAllowanceData, hash: string, newAmount?: string) => { if (!hash) return; if (isErc721Contract(allowance.contract)) { track('Revoked ERC721 allowance', { chainId: allowance.chainId, account: allowance.owner, - spender: allowance.spender, + spender: allowance.payload?.spender, token: allowance.contract.address, - tokenId: allowance.tokenId, + tokenId: (allowance.payload as any).tokenId, }); } track(newAmount === '0' ? 'Revoked ERC20 allowance' : 'Updated ERC20 allowance', { chainId: allowance.chainId, account: allowance.owner, - spender: allowance.spender, + spender: allowance.payload?.spender, token: allowance.contract.address, amount: newAmount === '0' ? undefined : newAmount, - permit2: expiration !== undefined, + permit2: allowance.payload?.type === AllowanceType.PERMIT2, }); }; // Wraps the revoke function to update the transaction store and do any error handling // TODO: Add other kinds of transactions besides "revoke" transactions to the store export const wrapRevoke = ( - allowance: AllowanceData, + allowance: TokenAllowanceData, revoke: () => Promise, updateTransaction: TransactionStore['updateTransaction'], handleTransaction?: ReturnType, @@ -518,7 +548,7 @@ export const wrapRevoke = ( updateTransaction(allowance, { status: 'pending', transactionHash: transactionSubmitted?.hash }); // We don't await this, since we want to return after submitting all transactions, even if they're still pending - transactionSubmitted.confirmation.then(() => { + transactionSubmitted?.confirmation.then(() => { updateTransaction(allowance, { status: 'confirmed', transactionHash: transactionSubmitted.hash }); }); @@ -533,3 +563,30 @@ export const wrapRevoke = ( } }; }; + +const calculateMaxAllowanceAmount = (allowance: TokenAllowanceData) => { + if (allowance.balance === 'ERC1155') { + throw new Error('ERC1155 tokens are not supported'); + } + + if (isErc20Allowance(allowance.payload)) return allowance.payload.amount; + if (allowance.payload?.type === AllowanceType.ERC721_SINGLE) return 1n; + + return allowance.balance; +}; + +export const calculateValueAtRisk = (allowance: TokenAllowanceData): number | null => { + if (!allowance.payload?.spender) return null; + if (allowance.balance === 'ERC1155') return null; + + if (allowance.balance === 0n) return 0; + if (isNullish(allowance.metadata.price)) return null; + + const allowanceAmount = calculateMaxAllowanceAmount(allowance); + + const amount = bigintMin(allowance.balance, allowanceAmount)!; + const valueAtRisk = fixedPointMultiply(amount, allowance.metadata.price, allowance.metadata.decimals ?? 0); + const float = Number(formatUnits(valueAtRisk, allowance.metadata.decimals ?? 0)); + + return float; +}; diff --git a/lib/utils/chains.ts b/lib/utils/chains.ts index 2aa6bb35a..f6c3abded 100644 --- a/lib/utils/chains.ts +++ b/lib/utils/chains.ts @@ -7,7 +7,7 @@ import { PriceStrategy } from 'lib/price/PriceStrategy'; import { UniswapV2PriceStrategy } from 'lib/price/UniswapV2PriceStrategy'; import { UniswapV3ReadonlyPriceStrategy } from 'lib/price/UniswapV3ReadonlyPriceStrategy'; import { AddEthereumChainParameter, PublicClient, Chain as ViemChain, toHex } from 'viem'; -import { Chain, SupportType } from '../chains/Chain'; +import { Chain, DeployedContracts, SupportType } from '../chains/Chain'; // Make sure to update these lists when updating the above lists // Order is loosely based on TVL (as per DeFiLlama) @@ -165,7 +165,7 @@ const MULTICALL = { }, }; -export const CHAINS: Record = { +export const CHAINS = { [ChainId.Amoy]: new Chain({ type: SupportType.PROVIDER, chainId: ChainId.Amoy, @@ -2352,7 +2352,7 @@ export const CHAINS: Record = { chainId: 12345678905, name: 'Tabi', }), -}; +} as const; export const SUPPORTED_CHAINS = Object.values(CHAINS) .filter((chain) => chain.isSupported()) @@ -2362,135 +2362,144 @@ export const ETHERSCAN_SUPPORTED_CHAINS = Object.values(CHAINS) .filter((chain) => chain.type === SupportType.ETHERSCAN_COMPATIBLE) .map((chain) => chain.chainId); -export const getChainConfig = (chainId: number): Chain | undefined => { +export type DocumentedChainId = keyof typeof CHAINS; + +export const getChainConfig = (chainId: DocumentedChainId): Chain => { return CHAINS[chainId]; }; // TODO: All these functions below are kept for backwards compatibility and should be removed in the future in favor of getChainConfig -export const isSupportedChain = (chainId: number): boolean => { +export const isSupportedChain = (chainId: DocumentedChainId): boolean => { return Boolean(getChainConfig(chainId)?.isSupported()); }; -export const isBackendSupportedChain = (chainId: number): boolean => { +export const isBackendSupportedChain = (chainId: DocumentedChainId): boolean => { const chain = getChainConfig(chainId); - return Boolean(chain) && chain.isSupported() && chain.type !== SupportType.PROVIDER; + return chain.isSupported() && chain.type !== SupportType.PROVIDER; +}; + +export const isProviderSupportedChain = (chainId: DocumentedChainId): boolean => { + return getChainConfig(chainId).type === SupportType.PROVIDER; }; -export const isProviderSupportedChain = (chainId: number): boolean => { - return getChainConfig(chainId)?.type === SupportType.PROVIDER; +export const isCovalentSupportedChain = (chainId: DocumentedChainId): boolean => { + return getChainConfig(chainId).type === SupportType.COVALENT; }; -export const isCovalentSupportedChain = (chainId: number): boolean => { - return getChainConfig(chainId)?.type === SupportType.COVALENT; +export const isEtherscanSupportedChain = (chainId: DocumentedChainId): boolean => { + return getChainConfig(chainId).type === SupportType.ETHERSCAN_COMPATIBLE; }; -export const isEtherscanSupportedChain = (chainId: number): boolean => { - return getChainConfig(chainId)?.type === SupportType.ETHERSCAN_COMPATIBLE; +export const isNodeSupportedChain = (chainId: DocumentedChainId): boolean => { + return getChainConfig(chainId).type === SupportType.BACKEND_NODE; }; -export const isNodeSupportedChain = (chainId: number): boolean => { - return getChainConfig(chainId)?.type === SupportType.BACKEND_NODE; +export const isMainnetChain = (chainId: DocumentedChainId): boolean => { + return !isTestnetChain(chainId); }; -export const isMainnetChain = (chainId: number): boolean => CHAIN_SELECT_MAINNETS.includes(chainId); -export const isTestnetChain = (chainId: number): boolean => CHAIN_SELECT_TESTNETS.includes(chainId); +export const isTestnetChain = (chainId: DocumentedChainId): boolean => { + return getChainConfig(chainId).isTestnet(); +}; -export const getChainName = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getName(); +export const getChainName = (chainId: DocumentedChainId): string => { + return getChainConfig(chainId).getName(); }; -export const getChainSlug = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getSlug(); +export const getChainSlug = (chainId: DocumentedChainId): string => { + return getChainConfig(chainId).getSlug(); }; const REVERSE_CHAIN_SLUGS: Record = Object.fromEntries( SUPPORTED_CHAINS.map((chainId) => [getChainSlug(chainId), chainId]), ); -export const getChainIdFromSlug = (slug: string): number | undefined => { +export type ChainSlug = keyof typeof REVERSE_CHAIN_SLUGS; + +export const getChainIdFromSlug = (slug: ChainSlug): DocumentedChainId => { return REVERSE_CHAIN_SLUGS[slug]; }; -export const getChainExplorerUrl = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getExplorerUrl(); +export const getChainExplorerUrl = (chainId: DocumentedChainId): string => { + return getChainConfig(chainId).getExplorerUrl(); }; // This is used on the "Add a network" page -export const getChainFreeRpcUrl = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getFreeRpcUrl(); +export const getChainFreeRpcUrl = (chainId: DocumentedChainId): string => { + return getChainConfig(chainId).getFreeRpcUrl(); }; -export const getChainRpcUrl = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getRpcUrl(); +export const getChainRpcUrl = (chainId: DocumentedChainId): string => { + return getChainConfig(chainId).getRpcUrl(); }; -export const getChainRpcUrls = (chainId: number): string[] | undefined => { - return getChainConfig(chainId)?.getRpcUrls(); +export const getChainRpcUrls = (chainId: DocumentedChainId): string[] => { + return getChainConfig(chainId).getRpcUrls(); }; -export const getChainLogsRpcUrl = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getLogsRpcUrl(); +export const getChainLogsRpcUrl = (chainId: DocumentedChainId): string => { + return getChainConfig(chainId).getLogsRpcUrl(); }; -export const getChainLogo = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getLogoUrl(); +export const getChainLogo = (chainId: DocumentedChainId): string | undefined => { + return getChainConfig(chainId).getLogoUrl(); }; -export const getChainInfoUrl = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getInfoUrl(); +export const getChainInfoUrl = (chainId: DocumentedChainId): string | undefined => { + return getChainConfig(chainId).getInfoUrl(); }; -export const getChainNativeToken = (chainId: number): string => { - return getChainConfig(chainId)?.getNativeToken(); +export const getChainNativeToken = (chainId: DocumentedChainId): string | undefined => { + return getChainConfig(chainId).getNativeToken(); }; -export const getChainApiUrl = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getEtherscanCompatibleApiUrl(); +export const getChainApiUrl = (chainId: DocumentedChainId): string | undefined => { + return getChainConfig(chainId).getEtherscanCompatibleApiUrl(); }; -export const getChainApiKey = (chainId: number): string | undefined => { - return getChainConfig(chainId)?.getEtherscanCompatibleApiKey(); +export const getChainApiKey = (chainId: DocumentedChainId): string | undefined => { + return getChainConfig(chainId).getEtherscanCompatibleApiKey(); }; -export const getChainApiRateLimit = (chainId: number): RateLimit => { - return getChainConfig(chainId)?.getEtherscanCompatibleApiRateLimit(); +export const getChainApiRateLimit = (chainId: DocumentedChainId): RateLimit => { + return getChainConfig(chainId).getEtherscanCompatibleApiRateLimit(); }; -export const getChainApiIdentifer = (chainId: number): string => { - return getChainConfig(chainId)?.getEtherscanCompatibleApiIdentifier(); +export const getChainApiIdentifer = (chainId: DocumentedChainId): string => { + return getChainConfig(chainId).getEtherscanCompatibleApiIdentifier(); }; -export const getCorrespondingMainnetChainId = (chainId: number): number | undefined => { - return getChainConfig(chainId)?.getCorrespondingMainnetChainId(); +export const getCorrespondingMainnetChainId = (chainId: DocumentedChainId): number | undefined => { + return getChainConfig(chainId).getCorrespondingMainnetChainId(); }; -export const getChainDeployedContracts = (chainId: number): any | undefined => { - return getChainConfig(chainId)?.getDeployedContracts(); +export const getChainDeployedContracts = (chainId: DocumentedChainId): DeployedContracts | undefined => { + return getChainConfig(chainId).getDeployedContracts(); }; -export const getViemChainConfig = (chainId: number): ViemChain | undefined => { - return getChainConfig(chainId)?.getViemChainConfig(); +export const getViemChainConfig = (chainId: DocumentedChainId): ViemChain => { + return getChainConfig(chainId).getViemChainConfig(); }; -export const createViemPublicClientForChain = (chainId: number, url?: string): PublicClient | undefined => { - return getChainConfig(chainId)?.createViemPublicClient(url); +export const createViemPublicClientForChain = (chainId: DocumentedChainId, url?: string): PublicClient => { + return getChainConfig(chainId).createViemPublicClient(url); }; -export const getChainAddEthereumChainParameter = (chainId: number): AddEthereumChainParameter | undefined => { - return getChainConfig(chainId)?.getAddEthereumChainParameter(); +export const getChainAddEthereumChainParameter = (chainId: DocumentedChainId): AddEthereumChainParameter => { + return getChainConfig(chainId).getAddEthereumChainParameter(); }; -export const getChainPriceStrategy = (chainId: number): PriceStrategy | undefined => { - return getChainConfig(chainId)?.getPriceStrategy(); +export const getChainPriceStrategy = (chainId: DocumentedChainId): PriceStrategy | undefined => { + return getChainConfig(chainId).getPriceStrategy(); }; -export const getChainBackendPriceStrategy = (chainId: number): PriceStrategy | undefined => { - return getChainConfig(chainId)?.getBackendPriceStrategy(); +export const getChainBackendPriceStrategy = (chainId: DocumentedChainId): PriceStrategy | undefined => { + return getChainConfig(chainId).getBackendPriceStrategy(); }; // Target a default of a round-ish number of tokens, worth around $10-20 -export const DEFAULT_DONATION_AMOUNTS = { +export const DEFAULT_DONATION_AMOUNTS: Record = { APE: '10', ASTR: '250', AVAX: '0.5', diff --git a/lib/utils/errors.ts b/lib/utils/errors.ts index bf23323f2..cbb3740aa 100644 --- a/lib/utils/errors.ts +++ b/lib/utils/errors.ts @@ -28,7 +28,7 @@ export const isOutOfGasError = (error?: string | any): boolean => { return false; }; -export const isLogResponseSizeError = (error?: string | any) => { +export const isLogResponseSizeError = (error?: string | any): boolean => { if (typeof error !== 'string') { return isLogResponseSizeError(parseErrorMessage(error)) || isLogResponseSizeError(stringifyError(error)); } @@ -42,7 +42,7 @@ export const isLogResponseSizeError = (error?: string | any) => { return false; }; -export const isRateLimitError = (error?: string | any) => { +export const isRateLimitError = (error?: string | any): boolean => { if (typeof error !== 'string') { return isRateLimitError(parseErrorMessage(error)) || isRateLimitError(stringifyError(error)); } @@ -54,7 +54,7 @@ export const isRateLimitError = (error?: string | any) => { return false; }; -export const isNetworkError = (error?: string | any) => { +export const isNetworkError = (error?: string | any): boolean => { // These error types might sometimes also meet the criteria for a network error, but they are handled separately if (isRateLimitError(error)) return false; if (isLogResponseSizeError(error)) return false; diff --git a/lib/utils/events.ts b/lib/utils/events.ts index 4382c4695..54651519c 100644 --- a/lib/utils/events.ts +++ b/lib/utils/events.ts @@ -1,7 +1,27 @@ import { ERC20_ABI, ERC721_ABI, PERMIT2_ABI } from 'lib/abis'; -import { Log, TimeLog } from 'lib/interfaces'; -import { Address, decodeEventLog } from 'viem'; -import { isNullish } from '.'; +import { MOONBIRDS_ADDRESS } from 'lib/constants'; +import { Address, decodeEventLog, Hash, Hex, toEventSelector } from 'viem'; +import { addressToTopic, isNullish } from '.'; + +export interface Log { + address: Address; + topics: [topic0: Hex, ...rest: Hex[]]; + data: Hex; + transactionHash: Hash; + blockNumber: number; + transactionIndex: number; + logIndex: number; + timestamp?: number; +} + +export type TimeLog = Pick; + +export interface Filter { + address?: Address; + topics: Array; + fromBlock: number; + toBlock: number; +} export enum TokenEventType { APPROVAL_ERC20 = 'APPROVAL_ERC20', @@ -182,3 +202,34 @@ export const parseTransferLog = ( export const getEventKey = (event: TokenEvent) => { return JSON.stringify(event.rawLog); }; + +// This function is a hardcoded patch to show Moonbirds' OpenSea allowances, +// which do not show up normally because of a bug in their contract +export const generatePatchedAllowanceEvents = ( + userAddress: Address, + openseaProxyAddress?: Address, + allEvents: Log[] = [], +): Log[] => { + if (!userAddress || !openseaProxyAddress) return []; + + // Only add the Moonbirds approval event if the account has interacted with Moonbirds at all + if (!allEvents.some((ev) => ev.address === MOONBIRDS_ADDRESS)) return []; + + return [ + { + // We use the deployment transaction hash as a placeholder for the approval transaction hash + transactionHash: '0xd4547dc336dd4a0655f11267537964d7641f115ef3d5440d71514e3efba9d210', + blockNumber: 14591056, + transactionIndex: 145, + logIndex: 0, + address: MOONBIRDS_ADDRESS, + topics: [ + toEventSelector('ApprovalForAll(address,address,bool)'), + addressToTopic(userAddress), + addressToTopic(openseaProxyAddress), + ], + data: '0x1', + timestamp: 1649997510, + }, + ]; +}; diff --git a/lib/utils/exploits.ts b/lib/utils/exploits.ts index 95021e6a1..e844f51f4 100644 --- a/lib/utils/exploits.ts +++ b/lib/utils/exploits.ts @@ -1,5 +1,6 @@ -import { AllowanceData } from 'lib/interfaces'; import ky from 'lib/ky'; +import { TokenAllowanceData } from 'lib/utils/allowances'; +import { Address } from 'viem'; import { deduplicateArray } from '.'; import { CHAIN_SELECT_MAINNETS } from './chains'; import { isApprovalTokenEvent, TokenEvent } from './events'; @@ -46,14 +47,14 @@ export const getExploitBySlug = async (slug: string, locale: string = 'en'): Pro exploit.description = short?.content && short?.meta.language === locale ? short?.content : exploit.description; const long = readAndParseContentFile(['long', slug], locale, 'exploits'); - exploit.longDescription = long?.content && long?.meta.language === locale ? long?.content : null; + exploit.longDescription = long?.content && long?.meta.language === locale ? long?.content : undefined; return exploit; }; export const getGlobalExploitStats = (exploits: Exploit[]) => { const totalAmount = formatExploitAmount(Math.floor(exploits.reduce((prev, curr) => prev + curr.amount, 0))); - const earliestYear = exploits.at(-1).date.slice(0, 4); + const earliestYear = exploits.at(-1)!.date.slice(0, 4); return { totalAmount, earliestYear }; }; @@ -86,25 +87,29 @@ export const getUniqueChainIds = (exploit: Exploit): number[] => { return sortedChainIds; }; -export const getExploitStatus = (events: TokenEvent[], allowances: AllowanceData[], exploit: Exploit): ExploiStatus => { - const mappedEvents = events.filter(isApprovalTokenEvent).map((event) => ({ - spender: event.payload.spender, - chainId: event.chainId, - })); - +export const getExploitStatus = ( + events: TokenEvent[], + allowances: TokenAllowanceData[], + exploit: Exploit, +): ExploiStatus => { if (isAffectedByExploit(allowances, exploit)) return 'affected'; - if (isAffectedByExploit(mappedEvents, exploit)) return 'previously_affected'; + if (isAffectedByExploit(events.filter(isApprovalTokenEvent), exploit)) return 'previously_affected'; return 'safe'; }; -const isAffectedByExploit = ( - eventsOrAllowances: Array>, - exploit: Exploit, -) => { +interface EventOrAllowance { + payload?: { + spender: Address; + }; + chainId: number; +} + +const isAffectedByExploit = (eventsOrAllowances: Array, exploit: Exploit) => { return eventsOrAllowances.some((eventOrAllowance) => exploit.addresses.some( (exploitAddress) => - eventOrAllowance.spender === exploitAddress.address && eventOrAllowance.chainId === exploitAddress.chainId, + eventOrAllowance.payload?.spender === exploitAddress.address && + eventOrAllowance.chainId === exploitAddress.chainId, ), ); }; diff --git a/lib/utils/formatting.ts b/lib/utils/formatting.ts index cf8aa0801..ab1521733 100644 --- a/lib/utils/formatting.ts +++ b/lib/utils/formatting.ts @@ -1,14 +1,15 @@ -import { Balance } from 'lib/interfaces'; +import { Nullable } from 'lib/interfaces'; +import { TokenBalance } from 'lib/utils/tokens'; import { formatUnits } from 'viem'; import { isNullish } from '.'; import { fixedPointMultiply } from './math'; -export const shortenAddress = (address?: string, characters: number = 6): string => { +export const shortenAddress = (address: Nullable, characters: number = 6): Nullable => { return address && `${address.substr(0, 2 + characters)}...${address.substr(address.length - characters, characters)}`; }; -export const shortenString = (name?: string, maxLength: number = 16): string | undefined => { - if (!name) return undefined; +export const shortenString = (name: Nullable, maxLength: number = 16): Nullable => { + if (!name) return name; if (name.length <= maxLength) return name; return `${name.substr(0, maxLength - 3).trim()}...`; }; @@ -18,9 +19,7 @@ export const formatFixedPointBigInt = ( decimals: number = 0, minDisplayDecimals: number = 0, maxDisplayDecimals: number = 3, -): string | undefined => { - if (isNullish(fixedPointBigInt)) return undefined; - +): string => { const float = Number(formatUnits(fixedPointBigInt, decimals)).toFixed(decimals); const tooSmallPrefix = `0.${'0'.repeat(maxDisplayDecimals)}`; // 3 decimals -> '0.000' @@ -38,24 +37,28 @@ const constrainDisplayedDecimals = (float: string, minDecimals: number, maxDecim return Number(floatWithMaxDecimals).toFixed(Math.max(minDecimals, fractionalPart?.length ?? 0)); }; -export const parseFixedPointBigInt = (floatString: string, decimals: number): bigint => { +export const parseFixedPointBigInt = (floatString: string, decimals: number = 0): bigint => { const [integerPart, fractionalPart] = floatString.split('.'); if (fractionalPart === undefined) return BigInt(floatString.padEnd(decimals + floatString.length, '0')); return BigInt(integerPart + fractionalPart.slice(0, decimals).padEnd(decimals, '0')); }; -export const formatBalance = (symbol: string, balance: Balance, decimals?: number) => { +export const formatBalance = (symbol: string, balance: TokenBalance, decimals?: number) => { if (balance === 'ERC1155') return `(ERC1155)`; return `${formatFixedPointBigInt(balance, decimals)} ${symbol}`; }; -export const formatFiatBalance = (balance: Balance, price?: number, decimals?: number, fiatSign: string = '$') => { +export const formatFiatBalance = (balance: TokenBalance, price?: number, decimals?: number, fiatSign: string = '$') => { if (balance === 'ERC1155') return null; if (isNullish(price)) return null; - return formatFiatAmount(Number(formatUnits(fixedPointMultiply(balance, price, decimals ?? 18), decimals))); + return formatFiatAmount(Number(formatUnits(fixedPointMultiply(balance, price, decimals ?? 18), decimals ?? 18))); }; -export const formatFiatAmount = (amount?: number, decimals: number = 2, fiatSign: string = '$'): string | null => { +export const formatFiatAmount = ( + amount?: Nullable, + decimals: number = 2, + fiatSign: string = '$', +): string | null => { if (isNullish(amount)) return null; if (amount < 0.01 && amount > 0) return `< ${fiatSign}0.01`; return `${fiatSign}${addThousandsSeparators(amount.toFixed(decimals))}`; diff --git a/lib/utils/index.ts b/lib/utils/index.ts index 8c079f85b..f7cb64d3f 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -1,24 +1,22 @@ import { ChainId } from '@revoke.cash/chains'; -import type { AllowanceData, Log, TransactionSubmitted } from 'lib/interfaces'; +import type { TransactionSubmitted } from 'lib/interfaces'; import { getTranslations } from 'next-intl/server'; import { toast } from 'react-toastify'; import { Address, + getAddress, Hash, Hex, + pad, PublicClient, + slice, TransactionNotFoundError, TransactionReceiptNotFoundError, WalletClient, WriteContractParameters, - formatUnits, - getAddress, - pad, - slice, } from 'viem'; import { track } from './analytics'; -import { TokenEvent } from './events'; -import { bigintMin, fixedPointMultiply } from './math'; +import type { Log, TokenEvent } from './events'; export const assertFulfilled = (item: PromiseSettledResult): item is PromiseFulfilledResult => { return item.status === 'fulfilled'; @@ -30,33 +28,6 @@ export const isNullish = (value: unknown): value is null | undefined => { return value === null || value === undefined; }; -const calculateMaxAllowanceAmount = (allowance: AllowanceData) => { - if (allowance.balance === 'ERC1155') { - throw new Error('ERC1155 tokens are not supported'); - } - - if (allowance.amount) return allowance.amount; - if (allowance.tokenId) return 1n; - - return allowance.balance; -}; - -export const calculateValueAtRisk = (allowance: AllowanceData): number => { - if (!allowance.spender) return null; - if (allowance.balance === 'ERC1155') return null; - - if (allowance.balance === 0n) return 0; - if (isNullish(allowance.metadata.price)) return null; - - const allowanceAmount = calculateMaxAllowanceAmount(allowance); - - const amount = bigintMin(allowance.balance, allowanceAmount); - const valueAtRisk = fixedPointMultiply(amount, allowance.metadata.price, allowance.metadata.decimals); - const float = Number(formatUnits(valueAtRisk, allowance.metadata.decimals)); - - return float; -}; - export const topicToAddress = (topic: Hex) => getAddress(slice(topic, 12)); export const addressToTopic = (address: Address) => pad(address, { size: 32 }).toLowerCase() as Hex; @@ -133,7 +104,7 @@ export const getWalletAddress = async (walletClient: WalletClient) => { export const throwIfExcessiveGas = (chainId: number, address: Address, estimatedGas: bigint) => { // Some networks do weird stuff with gas estimation, so "normal" transactions have much higher gas limits. - const gasFactors = { + const gasFactors: Record = { [ChainId.ZkSyncMainnet]: 20n, [ChainId.ZkSyncSepoliaTestnet]: 20n, [ChainId.ArbitrumOne]: 20n, @@ -165,8 +136,7 @@ export const writeContractUnlessExcessiveGas = async ( walletClient: WalletClient, transactionRequest: WriteContractParameters, ) => { - const estimatedGas = - 'gas' in transactionRequest ? transactionRequest.gas : await publicClient.estimateContractGas(transactionRequest); + const estimatedGas = transactionRequest.gas ?? (await publicClient.estimateContractGas(transactionRequest)); throwIfExcessiveGas(transactionRequest.chain!.id, transactionRequest.address, estimatedGas); return walletClient.writeContract({ ...transactionRequest, gas: estimatedGas }); }; @@ -182,7 +152,7 @@ export const waitForTransactionConfirmation = async (hash: Hash, publicClient: P }; export const waitForSubmittedTransactionConfirmation = async ( - transactionSubmitted: TransactionSubmitted | Promise, + transactionSubmitted?: TransactionSubmitted | Promise, ) => { const transaction = await transactionSubmitted; return transaction?.confirmation ?? null; diff --git a/lib/utils/markdown-content.ts b/lib/utils/markdown-content.ts index 3f2ee8d38..51a73b612 100644 --- a/lib/utils/markdown-content.ts +++ b/lib/utils/markdown-content.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'fs'; import matter from 'gray-matter'; -import { ContentFile, ISidebarEntry, Person, RawContentFile } from 'lib/interfaces'; +import { ContentFile, ISidebarEntry, Nullable, Person, RawContentFile } from 'lib/interfaces'; import ky from 'lib/ky'; import { getTranslations } from 'next-intl/server'; import { join } from 'path'; @@ -10,10 +10,12 @@ import { getOpenGraphImageUrl } from './og'; const walk = require('walkdir'); +export type ContentDirectory = 'blog' | 'docs' | 'learn' | 'exploits'; + export const readContentFile = ( slug: string | string[], locale: string, - directory: string = 'learn', + directory: ContentDirectory = 'learn', ): RawContentFile | null => { try { const contentDirectory = join(process.cwd(), 'content'); @@ -35,10 +37,10 @@ export const readContentFile = ( export const readAndParseContentFile = ( slug: string | string[], locale: string, - directory: string = 'learn', -): ContentFile | null => { + directory: ContentDirectory = 'learn', +): ContentFile | undefined => { const { content: rawContent, language } = readContentFile(slug, locale, directory) ?? {}; - if (!rawContent) return null; + if (!rawContent || !language) return undefined; const { content, data } = matter(rawContent); const meta = { @@ -57,17 +59,17 @@ export const readAndParseContentFile = ( return { content, meta }; }; -const parsePerson = (person?: string): Person | null => { +const parsePerson = (person: Nullable): Person | undefined => { // Placeholders are denoted with < ... > - if (person?.match(/^<.*>$/)) return null; + if (person?.match(/^<.*>$/)) return undefined; - const split = person?.split('|'); + const [left, right] = person?.split('|') ?? []; - if (!split) return null; + if (!left) return undefined; return { - name: split?.at(0)?.trim() ?? null, - url: split?.at(1)?.trim() ?? null, + name: left.trim(), + url: right?.trim(), }; }; @@ -75,9 +77,9 @@ const calculateReadingTime = (content: string): number => Math.round(Math.max(re export const getSidebar = async ( locale: string, - directory: string = 'learn', + directory: ContentDirectory = 'learn', extended: boolean = false, -): Promise => { +): Promise => { const t = await getTranslations({ locale }); if (directory === 'learn') { @@ -115,7 +117,7 @@ export const getSidebar = async ( children: [ { title: t('learn.add_network.sidebar_title'), - description: extended ? t('learn.add_network.description', { chainName: 'Ethereum' }) : null, + description: extended ? t('learn.add_network.description', { chainName: 'Ethereum' }) : undefined, path: '/learn/wallets/add-network', coverImage: getOpenGraphImageUrl('/learn/wallets/add-network', locale), }, @@ -134,26 +136,26 @@ export const getSidebar = async ( if (directory === 'blog') { const allSlugs = getAllContentSlugs(directory); const sidebar: ISidebarEntry[] = allSlugs.map((slug) => getSidebarEntry(slug, locale, directory, extended)); - sidebar.sort((a, b) => (a.date > b.date ? -1 : 1)); + sidebar.sort((a, b) => (a.date && b.date ? (a.date > b.date ? -1 : 1) : 0)); return sidebar; } - return null; + throw new Error(`Unknown directory: ${directory}`); }; const getSidebarEntry = ( slug: string | string[], locale: string, - directory: string = 'learn', + directory: ContentDirectory = 'learn', extended: boolean = false, ): ISidebarEntry => { const { meta } = readAndParseContentFile(slug, locale, directory) ?? {}; - if (!meta) return null; + if (!meta) throw new Error(`Could not find meta for /${locale}/${directory}/${slug}`); const normalisedSlug = Array.isArray(slug) ? slug.join('/') : slug; const path = ['', directory, normalisedSlug].join('/'); - const entry: ISidebarEntry = { title: meta.sidebarTitle, path, date: meta.date }; + const entry: ISidebarEntry = { title: meta.sidebarTitle ?? meta.title, path, date: meta.date }; if (extended) { entry.description = meta.description; entry.coverImage = meta.coverImage; @@ -163,7 +165,7 @@ const getSidebarEntry = ( return entry; }; -export const getAllContentSlugs = (directory: string = 'learn'): string[][] => { +export const getAllContentSlugs = (directory: ContentDirectory = 'learn'): string[][] => { const contentDirectory = join(process.cwd(), 'content'); const subdirectory = join(contentDirectory, 'en', directory); @@ -185,9 +187,9 @@ export const getAllLearnCategories = (): string[] => { export const getTranslationUrl = async ( slug: string | string[], locale: string, - directory: string = 'learn', -): Promise => { - if (!process.env.LOCALAZY_API_KEY || locale === 'en') return null; + directory: ContentDirectory = 'learn', +): Promise => { + if (!process.env.LOCALAZY_API_KEY || locale === 'en') return undefined; const normalisedSlug = Array.isArray(slug) ? slug : [slug]; @@ -211,7 +213,7 @@ export const getTranslationUrl = async ( keys: [key], } = await ky.get(`${baseUrl}/files/${file.id}/keys/en`, { headers }).json(); - const languageCodes = { + const languageCodes: Record = { zh: 1, ru: 1105, ja: 717, @@ -223,8 +225,8 @@ export const getTranslationUrl = async ( export const getCoverImage = ( slug: string | string[], - directory: string = 'learn', + directory: ContentDirectory = 'learn', locale: string = 'en', -): string | null => { +): string => { return getOpenGraphImageUrl(`/${directory}/${[slug].flat().join('/')}`, locale); }; diff --git a/lib/utils/permit.ts b/lib/utils/permit.ts index 9504f773f..43df645d9 100644 --- a/lib/utils/permit.ts +++ b/lib/utils/permit.ts @@ -1,11 +1,10 @@ import { DAI_PERMIT_ABI } from 'lib/abis'; import { DUMMY_ADDRESS } from 'lib/constants'; import blocksDB from 'lib/databases/blocks'; -import { BaseTokenData, Erc20TokenContract, TimeLog } from 'lib/interfaces'; -import { Address, Hex, Signature, TypedDataDomain, WalletClient, parseSignature } from 'viem'; +import { Address, Hex, parseSignature, Signature, TypedDataDomain, WalletClient } from 'viem'; import { getWalletAddress, writeContractUnlessExcessiveGas } from '.'; -import { TokenEvent, TokenEventType } from './events'; -import { getPermitDomain } from './tokens'; +import { TimeLog, TokenEvent, TokenEventType } from './events'; +import { Erc20TokenContract, getPermitDomain, TokenData } from './tokens'; export const permit = async ( walletClient: WalletClient, @@ -117,12 +116,12 @@ export const signDaiPermit = async ( return parseSignature(signatureHex); }; -export const getLastCancelled = async (events: TokenEvent[], token: BaseTokenData): Promise => { +export const getLastCancelled = async (events: TokenEvent[], token: TokenData): Promise => { const [lastCancelledEvent] = events.filter( (event) => event.token === token.contract.address && isCancelPermitEvent(event), ); - if (!lastCancelledEvent) return null; + if (!lastCancelledEvent) return undefined; const timestamp = await blocksDB.getLogTimestamp(token.contract.publicClient, lastCancelledEvent.time); diff --git a/lib/utils/permit2.ts b/lib/utils/permit2.ts index f19981fb1..2c971d8ec 100644 --- a/lib/utils/permit2.ts +++ b/lib/utils/permit2.ts @@ -1,10 +1,11 @@ import { PERMIT2_ABI } from 'lib/abis'; import blocksDB from 'lib/databases/blocks'; -import { BaseAllowanceData, Erc20TokenContract } from 'lib/interfaces'; import { Address, WalletClient } from 'viem'; import { deduplicateArray, getWalletAddress, writeContractUnlessExcessiveGas } from '.'; +import { AllowanceType, Permit2Erc20Allowance } from './allowances'; import { Permit2Event, TokenEvent, TokenEventType } from './events'; import { SECOND } from './time'; +import { Erc20TokenContract } from './tokens'; export const PERMIT2_ADDRESS: Address = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; @@ -12,7 +13,7 @@ export const getPermit2AllowancesFromApprovals = async ( contract: Erc20TokenContract, owner: Address, events: TokenEvent[], -): Promise => { +): Promise => { const permit2ApprovalEvents = events.filter((event) => event.type === TokenEventType.PERMIT2); const deduplicatedApprovalEvents = deduplicateArray( @@ -28,14 +29,14 @@ export const getPermit2AllowancesFromApprovals = async ( deduplicatedApprovalEvents.map((approval) => getPermit2AllowanceFromApproval(contract, owner, approval)), ); - return allowances; + return allowances.filter((allowance) => allowance !== undefined) as Permit2Erc20Allowance[]; }; const getPermit2AllowanceFromApproval = async ( tokenContract: Erc20TokenContract, owner: Address, approval: Permit2Event, -): Promise => { +): Promise => { const { spender, amount: lastApprovedAmount, expiration, permit2Address } = approval.payload; if (lastApprovedAmount === 0n) return undefined; if (expiration * SECOND <= Date.now()) return undefined; @@ -52,7 +53,14 @@ const getPermit2AllowanceFromApproval = async ( const [amount] = permit2Allowance; - return { spender, amount, lastUpdated, expiration, permit2Address }; + return { + type: AllowanceType.PERMIT2, + spender, + amount, + lastUpdated, + expiration, + permit2Address, + }; }; export const permit2Approve = async ( diff --git a/lib/utils/risk.tsx b/lib/utils/risk.tsx index c59cb07f8..33c57259a 100644 --- a/lib/utils/risk.tsx +++ b/lib/utils/risk.tsx @@ -2,7 +2,7 @@ import { ExclamationCircleIcon, ExclamationTriangleIcon, InformationCircleIcon } import { RiskFactor, RiskLevel } from 'lib/interfaces'; import { track } from './analytics'; -export const RiskFactorScore = { +export const RiskFactorScore: Record = { blocklist: 100, closed_source: 50, deprecated: 100, diff --git a/lib/utils/table.ts b/lib/utils/table.ts index 87282c80f..e44b66565 100644 --- a/lib/utils/table.ts +++ b/lib/utils/table.ts @@ -1,9 +1,9 @@ import { ColumnFiltersState, Table } from '@tanstack/react-table'; -import { AllowanceData } from 'lib/interfaces'; +import { TokenAllowanceData } from 'lib/utils/allowances'; import { deduplicateArray } from '.'; export const updateTableFilters = ( - table: Table, + table: Table, newFilters: ColumnFiltersState, ignoreIds: string[] = [], ) => { diff --git a/lib/utils/tokens.ts b/lib/utils/tokens.ts index 9f454216a..08804096e 100644 --- a/lib/utils/tokens.ts +++ b/lib/utils/tokens.ts @@ -1,24 +1,57 @@ import { ERC20_ABI, ERC721_ABI } from 'lib/abis'; import { DUMMY_ADDRESS, DUMMY_ADDRESS_2, WHOIS_BASE_URL } from 'lib/constants'; -import type { - Balance, - BaseTokenData, - Contract, - Erc20TokenContract, - Erc721TokenContract, - TokenContract, - TokenFromList, - TokenMetadata, -} from 'lib/interfaces'; +import type { Contract } from 'lib/interfaces'; import ky from 'lib/ky'; import { getTokenPrice } from 'lib/price/utils'; import { Address, domainSeparator, getAbiItem, getAddress, pad, PublicClient, toHex, TypedDataDomain } from 'viem'; import { deduplicateArray } from '.'; import { track } from './analytics'; -import { isTransferTokenEvent, TokenEvent, TokenEventType } from './events'; +import { isTransferTokenEvent, type TimeLog, type TokenEvent, TokenEventType } from './events'; import { formatFixedPointBigInt } from './formatting'; import { withFallback } from './promises'; +export interface TokenData { + contract: Erc20TokenContract | Erc721TokenContract; + metadata: TokenMetadata; + chainId: number; + owner: Address; + balance: TokenBalance; +} + +export interface PermitTokenData extends TokenData { + lastCancelled?: TimeLog; +} + +export type TokenContract = Erc20TokenContract | Erc721TokenContract; + +export interface Erc20TokenContract extends Contract { + abi: typeof ERC20_ABI; +} + +export interface Erc721TokenContract extends Contract { + abi: typeof ERC721_ABI; +} + +export interface TokenMetadata { + // name: string; + symbol: string; + icon?: string; + decimals?: number; + totalSupply?: bigint; + price?: number; +} + +export type TokenBalance = bigint | 'ERC1155'; + +export type TokenStandard = 'ERC20' | 'ERC721'; + +interface TokenFromList { + symbol: string; + decimals?: number; + logoURI?: string; + isSpam?: boolean; +} + export const isSpamToken = (symbol: string) => { const spamRegexes = [ // Includes http(s):// @@ -39,7 +72,7 @@ export const getTokenData = async ( events: TokenEvent[], owner: Address, chainId: number, -): Promise => { +): Promise => { if (isErc721Contract(contract)) { return getErc721TokenData(contract, owner, events, chainId); } @@ -51,7 +84,7 @@ export const getErc20TokenData = async ( contract: Erc20TokenContract, owner: Address, chainId: number, -): Promise => { +): Promise => { const [metadata, balance] = await Promise.all([ getTokenMetadata(contract, chainId), contract.publicClient.readContract({ ...contract, functionName: 'balanceOf', args: [owner] }), @@ -65,7 +98,7 @@ export const getErc721TokenData = async ( owner: Address, events: TokenEvent[], chainId: number, -): Promise => { +): Promise => { const transfers = events.filter((event) => event.type === TokenEventType.TRANSFER_ERC721); const transfersFrom = transfers.filter((event) => event.payload.from === owner); const transfersTo = transfers.filter((event) => event.payload.to === owner); @@ -76,7 +109,7 @@ export const getErc721TokenData = async ( const [metadata, balance] = await Promise.all([ getTokenMetadata(contract, chainId), shouldFetchBalance - ? withFallback( + ? withFallback( contract.publicClient.readContract({ ...contract, functionName: 'balanceOf', args: [owner] }), 'ERC1155', ) @@ -175,7 +208,7 @@ export const throwIfNotErc721 = async (contract: Erc721TokenContract) => { // TODO: Improve spam checks // TODO: Investigate other proxy patterns to see if they result in false positives export const throwIfSpamNft = async (contract: Contract) => { - const bytecode = await contract.publicClient.getCode({ address: contract.address }); + const bytecode = (await contract.publicClient.getCode({ address: contract.address })) ?? ''; // This is technically possible, but I've seen many "spam" NFTs with a very tiny bytecode, which we want to filter out if (bytecode.length < 250) { @@ -191,7 +224,7 @@ export const throwIfSpamNft = async (contract: Contract) => { } }; -export const hasZeroBalance = (balance: Balance, decimals?: number) => { +export const hasZeroBalance = (balance: TokenBalance, decimals?: number) => { return balance !== 'ERC1155' && formatFixedPointBigInt(balance, decimals) === '0'; }; @@ -260,7 +293,7 @@ export const hasSupportForPermit = async (contract: TokenContract) => { export const getPermitDomain = async (contract: Erc20TokenContract): Promise => { const verifyingContract = contract.address; - const chainId = contract.publicClient.chain.id; + const chainId = contract.publicClient.chain!.id; const [version, name, symbol, contractDomainSeparator] = await Promise.all([ getPermitDomainVersion(contract), diff --git a/lib/utils/wallet.ts b/lib/utils/wallet.ts index 173eeee35..18838bfad 100644 --- a/lib/utils/wallet.ts +++ b/lib/utils/wallet.ts @@ -9,7 +9,7 @@ export const getWalletIcon = (connector: Connector): string | undefined => { 'https://raw.githubusercontent.com/rainbow-me/rainbowkit/47e578f82efafda1e7127755105141c4a6b61c66/packages/rainbowkit/src/wallets/walletConnectors'; const walletNameLowerCase = walletName.toLowerCase(); - const mapping = { + const mapping: Record = { // Injected wallets '1inchwallet': '/assets/images/vendor/wallets/1inch.svg', backpack: '/assets/images/vendor/wallets/backpack.svg', diff --git a/lib/utils/whois.ts b/lib/utils/whois.ts index 26dcf12d4..93f45bcc4 100644 --- a/lib/utils/whois.ts +++ b/lib/utils/whois.ts @@ -8,7 +8,7 @@ import { UNSTOPPABLE_DOMAINS_ETH_ADDRESS, UNSTOPPABLE_DOMAINS_POLYGON_ADDRESS, } from 'lib/constants'; -import { SpenderData, SpenderRiskData } from 'lib/interfaces'; +import { Nullable, SpenderData, SpenderRiskData } from 'lib/interfaces'; import { AggregateSpenderDataSource, AggregationType } from 'lib/whois/spender/AggregateSpenderDataSource'; import { BackendSpenderDataSource } from 'lib/whois/spender/BackendSpenderDataSource'; import { Address, PublicClient, getAddress, isAddress, namehash } from 'viem'; @@ -20,18 +20,18 @@ const GlobalClients = { ETHEREUM: createViemPublicClientForChain( ChainId.EthereumMainnet, `https://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, - ), + )!, POLYGON: createViemPublicClientForChain( ChainId.PolygonMainnet, `https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`, - ), - AVALANCHE: createViemPublicClientForChain(ChainId['AvalancheC-Chain'], 'https://api.avax.network/ext/bc/C/rpc'), -}; + )!, + AVALANCHE: createViemPublicClientForChain(ChainId['AvalancheC-Chain'], 'https://api.avax.network/ext/bc/C/rpc')!, +} as const; export const getSpenderData = async ( address: Address, chainId: number, -): Promise => { +): Promise> => { const source = new AggregateSpenderDataSource({ aggregationType: AggregationType.PARALLEL_COMBINED, sources: [new BackendSpenderDataSource()], @@ -40,7 +40,9 @@ export const getSpenderData = async ( return source.getSpenderData(address, chainId); }; -export const lookupEnsName = async (address: Address): Promise => { +export const lookupEnsName = async (address?: Address): Promise => { + if (!address) return null; + try { const name = await GlobalClients.ETHEREUM?.getEnsName({ address }); return name ?? null; @@ -49,7 +51,9 @@ export const lookupEnsName = async (address: Address): Promise => } }; -export const resolveEnsName = async (name: string): Promise
=> { +export const resolveEnsName = async (name?: string): Promise
=> { + if (!name) return null; + try { const address = await GlobalClients.ETHEREUM?.getEnsAddress({ name: name.toLowerCase() }); return address ?? null; @@ -58,7 +62,9 @@ export const resolveEnsName = async (name: string): Promise
=> { } }; -export const lookupUnsName = async (address: Address) => { +export const lookupUnsName = async (address?: Address): Promise => { + if (!address) return null; + const lookupUnsNameOnClient = (client: PublicClient, contractAddress: Address) => client.readContract({ abi: UNSTOPPABLE_DOMAINS_ABI, @@ -83,7 +89,9 @@ export const lookupUnsName = async (address: Address) => { } }; -export const resolveUnsName = async (unsName: string): Promise
=> { +export const resolveUnsName = async (unsName?: string): Promise
=> { + if (!unsName) return null; + const resolveUnsNameOnClient = (client: PublicClient, contractAddress: Address) => client.readContract({ abi: UNSTOPPABLE_DOMAINS_ABI, @@ -108,7 +116,9 @@ export const resolveUnsName = async (unsName: string): Promise
= } }; -export const lookupAvvyName = async (address: Address) => { +export const lookupAvvyName = async (address?: Address): Promise> => { + if (!address) return null; + try { const name = await GlobalClients.AVALANCHE.readContract({ abi: AVVY_DOMAINS_ABI, @@ -123,7 +133,9 @@ export const lookupAvvyName = async (address: Address) => { } }; -export const resolveAvvyName = async (avvyName: string): Promise
=> { +export const resolveAvvyName = async (avvyName?: string): Promise
=> { + if (!avvyName) return null; + try { const address = await GlobalClients.AVALANCHE.readContract({ abi: AVVY_DOMAINS_ABI, @@ -166,7 +178,7 @@ export const getOpenSeaProxyAddress = async (userAddress: Address): Promise => { +export const parseInputAddress = async (inputAddressOrName: string): Promise
=> { const sanitisedInput = inputAddressOrName.trim().toLowerCase(); // We support ENS .eth and Avvy .avax domains, other domain-like inputs are interpreted as Unstoppable Domains @@ -175,7 +187,7 @@ export const parseInputAddress = async (inputAddressOrName: string): Promise { diff --git a/lib/whois/spender/AggregateSpenderDataSource.ts b/lib/whois/spender/AggregateSpenderDataSource.ts index 0a611889c..b9e46e8eb 100644 --- a/lib/whois/spender/AggregateSpenderDataSource.ts +++ b/lib/whois/spender/AggregateSpenderDataSource.ts @@ -1,4 +1,4 @@ -import { SpenderData, SpenderRiskData } from 'lib/interfaces'; +import { Nullable, SpenderData, SpenderRiskData } from 'lib/interfaces'; import { assertFulfilled, deduplicateArray } from 'lib/utils'; import { Address } from 'viem'; import { SpenderDataSource } from './SpenderDataSource'; @@ -22,7 +22,7 @@ export class AggregateSpenderDataSource implements SpenderDataSource { this.sources = options.sources; } - async getSpenderData(address: Address, chainId: number): Promise { + async getSpenderData(address: Address, chainId: number): Promise> { if (this.aggregationType === AggregationType.SEQUENTIAL_FIRST) { for (const source of this.sources) { const result = await source.getSpenderData(address, chainId); @@ -37,7 +37,7 @@ export class AggregateSpenderDataSource implements SpenderDataSource { ); const results = settlements.filter(assertFulfilled).map((result) => result.value); - const aggregatedResults = results.reduce( + const aggregatedResults = results.reduce( (acc, result) => result ? { ...acc, ...(result ?? {}), riskFactors: [...(acc?.riskFactors ?? []), ...(result?.riskFactors ?? [])] } @@ -46,11 +46,13 @@ export class AggregateSpenderDataSource implements SpenderDataSource { ); aggregatedResults.riskFactors = deduplicateArray( - aggregatedResults.riskFactors, + aggregatedResults.riskFactors ?? [], (a, b) => a.type === b.type && a.data === b.data && a.source === b.source, ); return aggregatedResults; } + + throw new Error('Invalid aggregation type'); } } diff --git a/lib/whois/spender/BackendSpenderDataSource.ts b/lib/whois/spender/BackendSpenderDataSource.ts index 9eda593d6..5756eb327 100644 --- a/lib/whois/spender/BackendSpenderDataSource.ts +++ b/lib/whois/spender/BackendSpenderDataSource.ts @@ -1,10 +1,10 @@ import ky from 'ky'; -import { SpenderData, SpenderRiskData } from 'lib/interfaces'; +import { Nullable, SpenderData, SpenderRiskData } from 'lib/interfaces'; import { Address, getAddress } from 'viem'; import { SpenderDataSource } from './SpenderDataSource'; export class BackendSpenderDataSource implements SpenderDataSource { - async getSpenderData(address: Address, chainId: number): Promise { - return ky.get(`/api/${chainId}/spender/${getAddress(address)}`).json(); + async getSpenderData(address: Address, chainId: number): Promise> { + return ky.get(`/api/${chainId}/spender/${getAddress(address)}`).json>(); } } diff --git a/lib/whois/spender/SpenderDataSource.ts b/lib/whois/spender/SpenderDataSource.ts index 1f28c0621..1dbc9b576 100644 --- a/lib/whois/spender/SpenderDataSource.ts +++ b/lib/whois/spender/SpenderDataSource.ts @@ -1,6 +1,6 @@ -import { SpenderData, SpenderRiskData } from 'lib/interfaces'; +import { Nullable, SpenderData, SpenderRiskData } from 'lib/interfaces'; import { Address } from 'viem'; export interface SpenderDataSource { - getSpenderData(spender: Address, chainId: number): Promise; + getSpenderData(spender: Address, chainId: number): Promise>; } diff --git a/lib/whois/spender/risk/OnchainSpenderRiskDataSource.ts b/lib/whois/spender/risk/OnchainSpenderRiskDataSource.ts index 21a72872c..3e81c1f3a 100644 --- a/lib/whois/spender/risk/OnchainSpenderRiskDataSource.ts +++ b/lib/whois/spender/risk/OnchainSpenderRiskDataSource.ts @@ -1,4 +1,5 @@ import { SpenderRiskData } from 'lib/interfaces'; +import { isNullish } from 'lib/utils'; import { createViemPublicClientForChain } from 'lib/utils/chains'; import { Address, Hex } from 'viem'; import { SpenderDataSource } from '../SpenderDataSource'; @@ -40,15 +41,15 @@ export class OnchainSpenderRiskDataSource implements SpenderDataSource { } } - isEOA(bytecode: string): boolean { - return !bytecode || bytecode === '0x'; + isEOA(bytecode?: Hex): boolean { + return isNullish(bytecode) || bytecode === '0x'; } isSmallBytecode(bytecode: Hex): boolean { - return !!bytecode && bytecode.length > 0 && bytecode.length < 1000; + return !isNullish(bytecode) && bytecode.length > 0 && bytecode.length < 1000; } - hasPhishingRisk(address: Address, bytecode: Hex): boolean { + hasPhishingRisk(address: Address, bytecode?: Hex): boolean { // TODO: Add more addresses also for other chains const PHISHING_RISK_ADDRESSES = [ Addresses.OPENSEA_SEAPORT, @@ -61,7 +62,7 @@ export class OnchainSpenderRiskDataSource implements SpenderDataSource { return PHISHING_RISK_ADDRESSES.includes(address) || this.isOpenSeaProxy(bytecode); } - isOpenSeaProxy(bytecode: Hex): boolean { + isOpenSeaProxy(bytecode?: Hex): boolean { const OPENSEA_PROXY_BYTECODE = '0x6080604052600436106100825763ffffffff7c0100000000000000000000000000000000000000000000000000000000600035041663025313a281146100c85780633659cfe6146100f95780634555d5c91461011c5780634f1ef286146101435780635c60da1b1461019d5780636fde8202146101b2578063f1739cae146101c7575b600061008c6101e8565b9050600160a060020a03811615156100a357600080fd5b60405136600082376000803683855af43d806000843e8180156100c4578184f35b8184fd5b3480156100d457600080fd5b506100dd6101f7565b60408051600160a060020a039092168252519081900360200190f35b34801561010557600080fd5b5061011a600160a060020a0360043516610206565b005b34801561012857600080fd5b50610131610239565b60408051918252519081900360200190f35b60408051602060046024803582810135601f810185900485028601850190965285855261011a958335600160a060020a031695369560449491939091019190819084018382808284375094975061023e9650505050505050565b3480156101a957600080fd5b506100dd6101e8565b3480156101be57600080fd5b506100dd6102f2565b3480156101d357600080fd5b5061011a600160a060020a0360043516610301565b600054600160a060020a031690565b60006102016102f2565b905090565b61020e6101f7565b600160a060020a031633600160a060020a031614151561022d57600080fd5b61023681610391565b50565b600290565b6102466101f7565b600160a060020a031633600160a060020a031614151561026557600080fd5b61026e82610206565b30600160a060020a03168160405180828051906020019080838360005b838110156102a357818101518382015260200161028b565b50505050905090810190601f1680156102d05780820380516001836020036101000a031916815260200191505b50915050600060405180830381855af491505015156102ee57600080fd5b5050565b600154600160a060020a031690565b6103096101f7565b600160a060020a031633600160a060020a031614151561032857600080fd5b600160a060020a038116151561033d57600080fd5b7f5a3e66efaa1e445ebd894728a69d6959842ea1e97bd79b892797106e270efcd96103666101f7565b60408051600160a060020a03928316815291841660208301528051918290030190a161023681610401565b600054600160a060020a03828116911614156103ac57600080fd5b6000805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a038316908117825560405190917fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b91a250565b6001805473ffffffffffffffffffffffffffffffffffffffff1916600160a060020a03929092169190911790555600a165627a7a723058205f26049bbc794226b505f589b2ee1130db54310d79dd8a635c6f6c61e305a7770029'; return bytecode === OPENSEA_PROXY_BYTECODE; diff --git a/lib/whois/spender/risk/WebacySpenderRiskDataSource.ts b/lib/whois/spender/risk/WebacySpenderRiskDataSource.ts index 4029c7297..87b546d0d 100644 --- a/lib/whois/spender/risk/WebacySpenderRiskDataSource.ts +++ b/lib/whois/spender/risk/WebacySpenderRiskDataSource.ts @@ -8,13 +8,15 @@ import { SpenderDataSource } from '../SpenderDataSource'; export class WebacySpenderRiskDataSource implements SpenderDataSource { private queue: RequestQueue; - constructor(private apiKey: string) { + constructor(private apiKey?: string) { // Webacy has requested that we limit the number of requests to 30 per second this.queue = new RequestQueue(`webacy:${apiKey}`, { interval: 1000, intervalCap: 30 }); } async getSpenderData(address: Address, chainId: number): Promise { - const chainIdentifiers = { + if (!this.apiKey) throw new Error('Webacy API key is not set'); + + const chainIdentifiers: Record = { [ChainId.EthereumMainnet]: 'eth', [ChainId.Base]: 'base', [ChainId.BNBSmartChainMainnet]: 'bsc', @@ -58,10 +60,10 @@ export class WebacySpenderRiskDataSource implements SpenderDataSource { 'fraudulent_malicious', ]; - const riskFactors: RiskFactor[] = (data?.issues ?? []).flatMap((issue) => { + const riskFactors: RiskFactor[] = (data?.issues ?? []).flatMap((issue: any) => { const tags = issue?.tags?.map((tag: any) => tag.key) as string[]; - const tagFactors = tags.flatMap((tag) => { + const tagFactors = tags.flatMap((tag: string) => { if (tag === 'is_closed_source') return [{ type: 'closed_source', source: 'webacy' }]; if (UNSAFE_TAGS.includes(tag)) return [{ type: 'unsafe', source: 'webacy' }]; if (BLOCKLIST_TAGS.includes(tag)) return [{ type: 'blocklist', source: 'webacy' }]; diff --git a/pages/api/[chainId]/floorPrice.ts b/pages/api/[chainId]/floorPrice.ts index 20f8612f7..7e2ce732b 100644 --- a/pages/api/[chainId]/floorPrice.ts +++ b/pages/api/[chainId]/floorPrice.ts @@ -1,7 +1,7 @@ import { ERC721_ABI } from 'lib/abis'; import { RateLimiters, checkActiveSessionEdge, checkRateLimitAllowedEdge } from 'lib/api/auth'; -import { Erc721TokenContract } from 'lib/interfaces'; import { createViemPublicClientForChain, getChainBackendPriceStrategy } from 'lib/utils/chains'; +import type { Erc721TokenContract } from 'lib/utils/tokens'; import { NextRequest } from 'next/server'; import { Address } from 'viem'; @@ -48,7 +48,7 @@ const handler = async (req: NextRequest) => { }, }); } catch (e) { - return new Response(JSON.stringify({ message: e.message }), { status: 500 }); + return new Response(JSON.stringify({ message: (e as any).message }), { status: 500 }); } }; diff --git a/pages/api/[chainId]/spender/[address].ts b/pages/api/[chainId]/spender/[address].ts index 42455633e..7fa5b44d7 100644 --- a/pages/api/[chainId]/spender/[address].ts +++ b/pages/api/[chainId]/spender/[address].ts @@ -55,7 +55,7 @@ const handler = async (req: NextRequest) => { }, }); } catch (e) { - return new Response(JSON.stringify({ message: e.message }), { status: 500 }); + return new Response(JSON.stringify({ message: (e as any).message }), { status: 500 }); } }; diff --git a/scripts/find-likely-exploit-addresses.ts b/scripts/find-likely-exploit-addresses.ts index bbb553a4e..1546592f7 100644 --- a/scripts/find-likely-exploit-addresses.ts +++ b/scripts/find-likely-exploit-addresses.ts @@ -32,10 +32,10 @@ const promises = CHAIN_SELECT_MAINNETS.map(async (chainId) => { }); Promise.all(promises).then((results) => { - const res = []; + const res: { chainId: number; address: Address }[] = []; results.forEach(([chainId, transactions]) => { const froms = deduplicateArray(transactions.map((transaction) => getAddress(transaction.from))); - const tos = deduplicateArray(transactions.map((transaction) => getAddress(transaction.to))); + const tos = deduplicateArray(transactions.map((transaction) => getAddress(transaction.to!))); const whitelisted = [ '0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC', // Seaport 1.5 diff --git a/scripts/generate-ledger-live-manifest.ts b/scripts/generate-ledger-live-manifest.ts deleted file mode 100644 index a434017e7..000000000 --- a/scripts/generate-ledger-live-manifest.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ChainId } from '@revoke.cash/chains'; -import { writeFileSync } from 'fs'; -import { getChainRpcUrl } from 'lib/utils/chains'; -import { join } from 'path'; - -// Nobody knows why Ledger has these currency codes -const currencyMap = { - [ChainId.EthereumMainnet]: 'ethereum', - [ChainId.BNBSmartChainMainnet]: 'bsc', - [ChainId.ArbitrumOne]: 'arbitrum', - [ChainId.OPMainnet]: 'optimism', - [ChainId.Base]: 'base', - [ChainId.FantomOpera]: 'fantom', - [ChainId.PolygonMainnet]: 'polygon', -}; - -const manifest = { - id: 'revoke', - name: 'Revoke.cash', - url: 'https://revoke.cash', - dapp: { - nanoApp: 'Ethereum', - // Ledger Live only supports Ethereum, BNB Chain, Arbitrum, Optimism, Base, Fantom and Polygon - networks: [ - ChainId.EthereumMainnet, - ChainId.BNBSmartChainMainnet, - ChainId.ArbitrumOne, - ChainId.OPMainnet, - ChainId.Base, - ChainId.FantomOpera, - ChainId.PolygonMainnet, - ].map((chainId) => ({ - chainID: chainId, - currency: currencyMap[chainId], - nodeURL: getChainRpcUrl(chainId), - })), - }, - homepageUrl: 'https://revoke.cash', - icon: 'https://revoke.cash/assets/images/android-chrome-512x512.png', - platform: 'all', - apiVersion: '^2.0.0', - manifestVersion: '2', - branch: 'stable', - categories: ['defi', 'security'], - currencies: [], - content: { - shortDescription: { - en: 'Revoke token approvals you granted on Ethereum and over 100 other networks.', - }, - description: { - en: 'Take back control of your wallet and stay safe by revoking token approvals you granted on Ethereum and over 100 other networks.', - }, - }, - permissions: [], - domains: ['https://'], -}; - -writeFileSync(join(__dirname, 'ledger-live-manifest.json'), JSON.stringify(manifest, null, 2)); diff --git a/scripts/get-chain-order.ts b/scripts/get-chain-order.ts index 196703b65..971069944 100644 --- a/scripts/get-chain-order.ts +++ b/scripts/get-chain-order.ts @@ -53,7 +53,7 @@ const logChain = async ( }; const mapChain = (chainId: number, llamaData: any[], isTestnet?: boolean) => { - const mainnetChainId = isTestnet ? getCorrespondingMainnetChainId(chainId) : chainId; + const mainnetChainId = isTestnet ? getCorrespondingMainnetChainId(chainId)! : chainId; const llamaChainData = llamaData.find( (chain) => diff --git a/scripts/test-covalent-rate-limits.ts b/scripts/test-covalent-rate-limits.ts index ac7b6db0c..6c4a7aeb7 100644 --- a/scripts/test-covalent-rate-limits.ts +++ b/scripts/test-covalent-rate-limits.ts @@ -23,7 +23,7 @@ const testCovalentRateLimits = async (chainId: number, rps: number) => { ) .json(); } catch (e) { - console.error(e?.data); + console.error((e as any).data); return null; } }; diff --git a/test/chains.test.ts b/test/chains.test.ts index 06e305e18..2cf17cfdc 100644 --- a/test/chains.test.ts +++ b/test/chains.test.ts @@ -20,7 +20,7 @@ import { getCorrespondingMainnetChainId, getDefaultDonationAmount, } from 'lib/utils/chains'; -import networkDescriptions from 'locales/en/networks.json'; +import networkDescriptions from 'locales/en/networks.json' with { type: 'json' }; describe('Chain Support', () => { it('should not have superfluous default donation amounts', () => { @@ -33,7 +33,7 @@ describe('Chain Support', () => { ORDERED_CHAINS.forEach((chainId) => { const chainName = getChainName(chainId); - const nativeToken = getChainNativeToken(chainId); + const nativeToken = getChainNativeToken(chainId)!; describe(`${chainName} (${nativeToken})`, () => { it('should have base chain data', () => { @@ -58,24 +58,24 @@ describe('Chain Support', () => { it('should have a description', () => { const mainnetChainId = getCorrespondingMainnetChainId(chainId) ?? chainId; - expect(networkDescriptions.networks[getChainSlug(mainnetChainId)]).to.exist; + expect((networkDescriptions.networks as Record)[getChainSlug(mainnetChainId)]).to.exist; }); it('should have the correct chain ID for the main RPC', async () => { - const client = createViemPublicClientForChain(chainId, getChainRpcUrl(chainId)); + const client = createViemPublicClientForChain(chainId, getChainRpcUrl(chainId))!; expect(await client.getChainId()).to.equal(chainId); }); if (getChainRpcUrl(chainId) !== getChainLogsRpcUrl(chainId)) { it('should have the correct chain ID for the logs RPC', async () => { - const client = createViemPublicClientForChain(chainId, getChainLogsRpcUrl(chainId)); + const client = createViemPublicClientForChain(chainId, getChainLogsRpcUrl(chainId))!; expect(await client.getChainId()).to.equal(chainId); }); } if (getChainRpcUrl(chainId) !== getChainFreeRpcUrl(chainId)) { it('should have the correct chain ID for the free RPC', async () => { - const client = createViemPublicClientForChain(chainId, getChainFreeRpcUrl(chainId)); + const client = createViemPublicClientForChain(chainId, getChainFreeRpcUrl(chainId))!; expect(await client.getChainId()).to.equal(chainId); }); } diff --git a/tsconfig.json b/tsconfig.json index cdd90e89c..8b227d435 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,8 +4,7 @@ "lib": ["dom", "dom.iterable", "esnext", "es2021"], "allowJs": true, "skipLibCheck": true, - "strict": false, - "strictNullChecks": false, + "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "allowSyntheticDefaultImports": true,