diff --git a/packages/nextjs/components/Footer.tsx b/packages/nextjs/components/Footer.tsx index 444100b..15a4577 100644 --- a/packages/nextjs/components/Footer.tsx +++ b/packages/nextjs/components/Footer.tsx @@ -11,7 +11,7 @@ import { getTargetNetwork } from "~~/utils/scaffold-eth"; */ export const Footer = () => { return ( -
+
diff --git a/packages/nextjs/components/flashbotRecovery/AssetSelectionStep/AssetSelectionStep.tsx b/packages/nextjs/components/flashbotRecovery/AssetSelectionStep/AssetSelectionStep.tsx new file mode 100644 index 0000000..7c88b4b --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/AssetSelectionStep/AssetSelectionStep.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from "react"; +import Image from "next/image"; +import LogoSvg from "../../../public/assets/flashbotRecovery/logo.svg"; +import { CustomButton } from "../CustomButton/CustomButton"; +import { ManualAssetSelection } from "../ManualAssetSelection/ManualAssetSelection"; +import styles from "./assetSelectionStep.module.css"; +import { motion } from "framer-motion"; +import { useAutodetectAssets } from "~~/hooks/flashbotRecoveryBundle/useAutodetectAssets"; +import { RecoveryTx } from "~~/types/business"; + +interface IProps { + isVisible: boolean; + hackedAddress: string; + safeAddress: string; + onSubmit: (txs: RecoveryTx[]) => void; +} +export const AssetSelectionStep = ({ isVisible, onSubmit, hackedAddress, safeAddress }: IProps) => { + const [isAddingManually, setIsAddingManually] = useState(false); + const { getAutodetectedAssets } = useAutodetectAssets(); + const [selectedAssets, setSelectedAssets] = useState([]); + const [accountAssets, setAccountAssets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const onAssetSelected = (index: number) => { + const currentIndex = selectedAssets.indexOf(index); + let newAssets: number[] = []; + if (currentIndex === -1) { + newAssets.push(index); + newAssets.push(...selectedAssets); + } else { + newAssets = selectedAssets.filter(item => item !== index); + } + setSelectedAssets(newAssets); + }; + + useEffect(() => { + if (accountAssets.length > 0 || !isVisible) { + return; + } + init(); + }, [isVisible]); + + const init = async () => { + const result = await getAutodetectedAssets({ hackedAddress, safeAddress }); + if (!result) { + return; + } + setAccountAssets(result); + setIsLoading(false); + }; + + const onAddAssetsClick = () => { + const txsToAdd = accountAssets.filter((item, i) => selectedAssets.indexOf(i) != -1); + onSubmit(txsToAdd); + }; + if (!isVisible) { + return <>; + } + return ( + + setIsAddingManually(false)} + addAsset={item => { + setAccountAssets(current => [...current, item]) + setIsAddingManually(false) + }} + /> +

Your assets

+ +
+ {!!isLoading + ? [1, 2, 3].map((item, i) => ( + onAssetSelected(i)} + /> + )) + : accountAssets.map((item, i) => { + return ( + onAssetSelected(i)} + /> + ); + })} +
+ setIsAddingManually(true)} /> +
+ onAddAssetsClick()} /> +
+ ); +}; + +interface IAssetProps { + onClick: () => void; + isSelected: boolean; + tx?: RecoveryTx; + isLoading: boolean; +} + +const AssetItem = ({ onClick, isSelected, tx, isLoading }: IAssetProps) => { + const getSubtitleTitle = () => { + if (!tx) { + return ""; + } + if (["erc1155", "erc721"].indexOf(tx.type) != -1) { + //@ts-ignore + return `Token ID: ${tx.tokenId!}`; + } + if (tx.type === "erc20") { + //@ts-ignore + return tx.value; + } + if (tx.type === "custom") { + //@ts-ignore + return tx.info.split(" to ")[1] + } + return ""; + }; + const getTitle = () => { + if (!tx) { + return ""; + } + if (tx.type === "erc20") { + //@ts-ignore + return tx.symbol; + } + if (tx.type === "custom") { + //@ts-ignore + return tx.info.split(" to ")[0] + } + return tx.info; + }; + return ( + onClick()} + className={`${isSelected ? "bg-base-200" : ""} ${styles.assetItem} ${isLoading ? styles.loading : ""}`} + > +
+ +
+
+

{getTitle()}

+ {getSubtitleTitle()} +
+
+ ); +}; diff --git a/packages/nextjs/components/flashbotRecovery/AssetSelectionStep/assetSelectionStep.module.css b/packages/nextjs/components/flashbotRecovery/AssetSelectionStep/assetSelectionStep.module.css new file mode 100644 index 0000000..fea315f --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/AssetSelectionStep/assetSelectionStep.module.css @@ -0,0 +1,78 @@ +.container{ + width: 100%; + + margin-bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.title{ + font-size: 23px; + font-style: normal; + font-weight: 600; + line-height: 100%; + margin: 0; +} +.assetItem{ + display: flex; + height: 92px; + max-width: 458px; + margin: 0 auto; + border-radius: 12px; + cursor: pointer; + margin-bottom: 12px; +} +.data{ + display: flex; + flex-direction: column; + justify-content: center; +} +.assetList{ + width: 100%; + max-height: calc(100vh - 400px); + height: calc(100vh - 400px); + overflow: auto; + margin: 36px 0; +} + +.logo{ + width: 60px; + height: 60px; + margin-right: 16px; +} + +.logoContainer{ + display: flex; + flex-direction: column; + justify-content: center; + margin-left:16px; +} + +.loader{ + height: 94px; + display: flex; + justify-content: center; + align-items: center; +} + +.loading span{ + background-color:#3a4862; + width: 100px; + height: 24px; +} +.loading h3{ + background-color:#3a4862; + width: 130px; + height: 24px; +} + +.loading{ + animation: loading 1s infinite; +} + +@keyframes loading { + from {opacity: 0.3;} + to {opacity: 0.8;} + } \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/ConnectStep/ConnectStep.tsx b/packages/nextjs/components/flashbotRecovery/ConnectStep/ConnectStep.tsx new file mode 100644 index 0000000..58b3893 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ConnectStep/ConnectStep.tsx @@ -0,0 +1,44 @@ +import React, { useEffect } from "react"; +import Image from "next/image"; +import IllustrationSvg from "../../../public/assets/flashbotRecovery/logo.svg" +import styles from "./connectStep.module.css" +import { RainbowKitCustomConnectButton } from "~~/components/scaffold-eth"; +import { AnimatePresence, motion } from "framer-motion"; +interface IProps{ + isVisible:boolean + safeAddress:string; + address:string; + setSafeAddress: (newAdd:string) => void +} +export const ConnectStep = ({isVisible, safeAddress, address, setSafeAddress}:IProps) => { + + + useEffect(() => { + if (!!safeAddress || !address) { + return; + } + setSafeAddress(address); + return () => {}; + }, [address]); + + + return + {isVisible && ( + +

Welcome to Flashbot Recovery

+ +

+ Connect your "Safe Wallet" where the assets will be send after the recovery process. +

+
+ +
+
+ )} +
+}; \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/ConnectStep/connectStep.module.css b/packages/nextjs/components/flashbotRecovery/ConnectStep/connectStep.module.css new file mode 100644 index 0000000..61b0125 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ConnectStep/connectStep.module.css @@ -0,0 +1,38 @@ +.container{ + max-width: 580px; + margin: 20px; + margin-bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} +.title{ + text-align: center; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: 32px; +} +.illustration{ + width: 250px; + height: 250px; + margin: 40px 0; +} +.text{ + font-size: 18px; + font-style: normal; + font-weight: 400; + font-size: 18px; + line-height: 32px; + text-align: center; +} +.buttonContainer button{ + display: flex; + +height: 40px; +padding: 0px 48px; +} +.buttonContainer > div > div >div{ +display: none; +} \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/CustomButton/CustomButton.tsx b/packages/nextjs/components/flashbotRecovery/CustomButton/CustomButton.tsx new file mode 100644 index 0000000..2128b52 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/CustomButton/CustomButton.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import styles from "./customButton.module.css"; + +interface IProps { + text: string; + onClick: () => void; + disabled?: boolean; + type: "btn-accent" | "btn-primary"; +} +export const CustomButton = ({ text, onClick, disabled = false, type }: IProps) => { + return ( + + ); +}; diff --git a/packages/nextjs/components/flashbotRecovery/CustomButton/customButton.module.css b/packages/nextjs/components/flashbotRecovery/CustomButton/customButton.module.css new file mode 100644 index 0000000..645cfc3 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/CustomButton/customButton.module.css @@ -0,0 +1,14 @@ +.button{ + width: 250px; + margin: 0 auto; + padding: 0px 48px; + height: 40px; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: normal; + border: 1px solid; +} +.button:hover{ + border: 1px solid; +} diff --git a/packages/nextjs/components/flashbotRecovery/CustomHeader/CustomHeader.tsx b/packages/nextjs/components/flashbotRecovery/CustomHeader/CustomHeader.tsx new file mode 100644 index 0000000..cf41b8b --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/CustomHeader/CustomHeader.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import styles from "./customheader.module.css" +import Image from 'next/image' +import LogoSvg from "../../../public/assets/flashbotRecovery/logo.svg" + +export const CustomHeader = () => { + return ( +
+
+ + Recovery Flashbot +
+
+ ) +} diff --git a/packages/nextjs/components/flashbotRecovery/CustomHeader/customheader.module.css b/packages/nextjs/components/flashbotRecovery/CustomHeader/customheader.module.css new file mode 100644 index 0000000..0aeb387 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/CustomHeader/customheader.module.css @@ -0,0 +1,26 @@ +.header{ + width: 100%; +} + +.logoContainer{ + display: flex; + align-items: center; + margin-top: 20px; + margin-left: 10px; + font-size: 23px; +font-style: normal; +font-weight: 400; +} + +.logo{ + width: 60px; + height: 60px; + margin-right: 16px; +} + +@media (min-width: 768px) { + .logoContainer{ + margin-top: 56px; + margin-left: 96px; + } +} \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/CustomPortal/CustomPortal.tsx b/packages/nextjs/components/flashbotRecovery/CustomPortal/CustomPortal.tsx new file mode 100644 index 0000000..e07394e --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/CustomPortal/CustomPortal.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from "react"; +import Image from "next/image"; +import CloseSvg from "../../../public/assets/flashbotRecovery/close.svg"; +import { CustomButton } from "../CustomButton/CustomButton"; +import styles from "./customPortal.module.css"; +import { motion } from "framer-motion"; +import { createPortal } from "react-dom"; + +interface IProps { + title: string; + image?: string; + video?: string; + close?:() => void; + description: string; + button?: { + text: string; + disabled:boolean; + action: () => void; + }; + indicator?:number +} +export const CustomPortal = ({ indicator, title, image, video, description, button, close}: IProps) => { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + + return () => setMounted(false); + }, []); + const portalSelector = document.querySelector("#myportal"); + if (!portalSelector) { + return <>; + } + + return mounted + ? createPortal( + +
+ setMounted(false)}> + {" "} + {!!close ? {""} close()}/>: <>} + +
+

{title}

+
+ {!!image ? {""} : <>} + {!!indicator ?
{indicator} BLOCKS
: <>} +
+ + {!!video ?{""} : <>} +

{description}

+ {!!button ? button.action()} /> : <>} +
+ +
+
, + portalSelector, + ) + : null; +}; diff --git a/packages/nextjs/components/flashbotRecovery/CustomPortal/customPortal.module.css b/packages/nextjs/components/flashbotRecovery/CustomPortal/customPortal.module.css new file mode 100644 index 0000000..56d1527 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/CustomPortal/customPortal.module.css @@ -0,0 +1,72 @@ +.modalContainer { + position: absolute; + top: 0; + bottom: 0; + width: 100vw; + height: 100vh; + backdrop-filter: blur(5px); + display: flex; + justify-content: center; + align-items: center; + background: rgba(20, 28, 41, 0.40); +} + +.modal { + display: inline-flex; + width: 579px; + flex-direction: column; + align-items: center; + gap: 40px; + flex-shrink: 0; + border-radius: 12px; + box-shadow: 0px 0px 10px 5px rgba(0, 0, 0, 0.03); + +} + +.modalContent{ + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 40px; + flex-shrink: 0; + margin-bottom: 56px; +} + +.close{ + align-self: flex-end; + margin-top: 14px; + margin-right: 14px; + cursor: pointer; + padding: 10px; + padding-bottom: 4px; +} + +.title{ + font-size: 24px; +font-style: normal; +font-weight: 700; +line-height: 32px; +margin:0; +} + +.image{ + height: 190px; + width: 190px; +} +.indicator{ +text-align: center; +font-size: 20px; +font-style: normal; +font-weight: 600; +line-height: 100%; /* 20.033px */ +} + +.text{ + font-size: 18px; +font-style: normal; +font-weight: 400; +line-height: 32px; +width: 80%; +text-align: center; +margin: 0; +} \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/HackedAddressStep/HackedAddressStep.tsx b/packages/nextjs/components/flashbotRecovery/HackedAddressStep/HackedAddressStep.tsx new file mode 100644 index 0000000..8ff64f7 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/HackedAddressStep/HackedAddressStep.tsx @@ -0,0 +1,44 @@ +import React, { useState } from "react"; +import { CustomButton } from "../CustomButton/CustomButton"; +import styles from "./hackedAddressStep.module.css"; +import { isAddress } from "ethers/lib/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { AddressInput } from "~~/components/scaffold-eth"; + +interface IProps { + isVisible: boolean; + onSubmit: (address: string) => void; +} +export const HackedAddressStep = ({ isVisible, onSubmit }: IProps) => { + if (!isVisible) { + return <>; + } + + const [hackedAddress, setHackedAddress] = useState(""); + + return ( + + + +
+ { + if (!isAddress(hackedAddress)) { + alert("Given hacked address is not a valid address"); + return; + } + onSubmit(hackedAddress); + }} + /> +
+ ); +}; diff --git a/packages/nextjs/components/flashbotRecovery/HackedAddressStep/hackedAddressStep.module.css b/packages/nextjs/components/flashbotRecovery/HackedAddressStep/hackedAddressStep.module.css new file mode 100644 index 0000000..540b8c6 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/HackedAddressStep/hackedAddressStep.module.css @@ -0,0 +1,20 @@ +.container{ + width: 90%; + margin-bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + max-width: 458px; +} + +.label{ + font-size: 16px; +font-style: normal; +font-weight: 600; +line-height: normal; +margin-bottom: 12px; +} +.container > div{ + border-radius: 4px; + padding: 4px; +} \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/Layout/Layout.tsx b/packages/nextjs/components/flashbotRecovery/Layout/Layout.tsx new file mode 100644 index 0000000..72e2b29 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/Layout/Layout.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import Image from "next/image"; +import LogoSvg from "../../../public/assets/flashbotRecovery/logo.svg"; +import styles from "./layout.module.css"; +import { motion } from "framer-motion"; +import { Address } from "~~/components/scaffold-eth"; + +interface IProps { + children: JSX.Element; + stepActive: number; + safeAddress: string; + hackedAddress: string; +} +export const Layout = ({ children, stepActive, hackedAddress, safeAddress }: IProps) => { + return ( + +
+
+
+ + Recovery Flashbot +
+
+ 1} + index={1} + title={"Enter the hacked address"} + description={"Provide the address that was hacked so we can search for your assets."} + /> + 2} + index={2} + title={"Select your assets"} + description={ + "Your assets will be listed, select the ones you want to transfer or add manually if you miss someone" + } + /> + + 3} + index={3} + title={"Confirm the bundle"} + description={"Review the transactions that are going to be generated to recover your assets"} + /> + + 4} + index={4} + title={"Recover your assets"} + description={ + "Follow the steps to retrieve your assets, this is a critical process, so please be patient." + } + /> +
+
+
+
+ Safe Address +
+
+
+
+ Hacked Address +
+
+
+
+
+
{children}
+
+ ); +}; + +interface IStepProps { + isActive: boolean; + index: number; + title: string; + isCompleted:boolean; + description: string; +} +const Step = ({ isActive, isCompleted, index, title, description }: IStepProps) => { + return ( +
+
+ {index} +
+
+

{title}

+

{description}

+
+
+ ); +}; diff --git a/packages/nextjs/components/flashbotRecovery/Layout/layout.module.css b/packages/nextjs/components/flashbotRecovery/Layout/layout.module.css new file mode 100644 index 0000000..3efe352 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/Layout/layout.module.css @@ -0,0 +1,121 @@ +.layout { + display: flex; + justify-content: flex-start; + align-items: flex-start; + width: 100%; + height: 100vh; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; +} +.sidebar { + max-width: 530px; + display: flex; + width: auto; + + background-color: #243148; + height: 100%; +} + +.sidebarContent { + margin-top: 56px; + margin-left: 10px; +} +.content { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.logoContainer { + display: flex; + align-items: center; + font-size: 23px; + font-style: normal; + font-weight: 400; +} + +.logo { + width: 60px; + height: 60px; + margin-right: 16px; +} +.steps { + margin-top: 48px; + margin-right: 63px; + position: relative; +} +.step { + display: flex; + margin-top: 16px; +} +.badge { + border-radius: 100px; + border: 2px solid; + display: flex; + width: 34px; + font-size: 20px; + font-style: normal; + font-weight: 600; + height: 34px; + flex-direction: column; + justify-content: center; + align-items: center; +} +.stepContainer { + margin-left: 16px; +} +.stepTitle { + font-size: 18px; + font-style: normal; + font-weight: 600; + margin: 0; + display: flex; + height: 34px; + align-items: center; +} +.stepDescription { + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 26px; + margin: 0; +} + +.addresess { + display: flex; + position: absolute; + bottom: 0; + width: 100%; + border-radius: 12px; + justify-content: space-around; + max-width: 380px; + width: 34%; + left: 96px; + bottom: 26px; +} + +.addressContainer { + margin: 24px 0; + margin-right: 0; +} +.addressContainer svg { + display: none; +} +.completed{ + color: #7E92BD; +} +.completed .badge{ + background-color: #3A4A6E ; + +} +@media (min-width: 768px) { + .sidebarContent { + margin-left: 96px; + } +} diff --git a/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/BasicFlow.tsx b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/BasicFlow.tsx new file mode 100644 index 0000000..1f4e84f --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/BasicFlow.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { CustomButton } from "../../CustomButton/CustomButton"; +import { ERC20Form } from "./ERC20From"; +import { ERC721Form } from "./ERC721Form"; +import { ERC1155Form } from "./ERC1155Form"; +import styles from "../manualAssetSelection.module.css"; +import { motion } from "framer-motion"; +import { createPortal } from "react-dom"; +import { RecoveryTx } from "~~/types/business"; + +interface IBasicFlowProps { + safeAddress: string; + hackedAddress: string; + addAsset: (asset: RecoveryTx) => void; +} + +export interface ITokenForm { + hackedAddress: string; + safeAddress: string; + close: () => void; + addAsset: (arg: RecoveryTx) => void; +} + +export const BasicFlow = ({ safeAddress, hackedAddress, addAsset }: IBasicFlowProps) => { + const [tokenActive, setTokenActive] = useState(0); + return ( + <> + { + setTokenActive(0); + }} + safeAddress={safeAddress} + /> +
    +
  • +

    ERC20

    + setTokenActive(1)} /> +
  • +
  • +

    ERC721

    + setTokenActive(2)} /> +
  • +
  • +

    ERC1551

    + setTokenActive(3)} /> +
  • +
+ + ); +}; + +interface ITokenSelectionProps { + tokenActive: number; + close: () => void; + hackedAddress: string; + safeAddress: string; + addAsset: (asset: RecoveryTx) => void; +} +const TokenSelection = ({ close, addAsset, tokenActive, hackedAddress, safeAddress }: ITokenSelectionProps) => { + const portalSelector = document.querySelector("#myportal2"); + if (!portalSelector) { + return <>; + } + + return tokenActive != 0 + ? createPortal( + +
+
+
+ {tokenActive === 1 ? ( + + ) : ( + <> + )} + {tokenActive === 2 ? ( + + ) : ( + <> + )} + {tokenActive === 3 ? ( + + ) : ( + <> + )} +
+
+
, + portalSelector, + ) + : null; +}; diff --git a/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC1155Form.tsx b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC1155Form.tsx new file mode 100644 index 0000000..9f391bd --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC1155Form.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import styles from "../manualAssetSelection.module.css"; +import { usePublicClient } from "wagmi"; +import { ERC1155_ABI } from "~~/utils/constants"; +import { getTargetNetwork } from "~~/utils/scaffold-eth"; +import { ITokenForm } from "./BasicFlow"; +import { BigNumber, ethers } from "ethers"; +import { isAddress } from "viem"; +import Image from "next/image"; +import BackSvg from "../../../../public/assets/flashbotRecovery/back.svg"; +import { ERC1155Tx } from "~~/types/business"; +import { AddressInput, InputBase } from "~~/components/scaffold-eth"; +import { CustomButton } from "../../CustomButton/CustomButton"; +const erc1155Interface = new ethers.utils.Interface(ERC1155_ABI); + +export const ERC1155Form = ({ hackedAddress, safeAddress, addAsset, close }: ITokenForm) => { + const [contractAddress, setContractAddress] = useState(""); + const [tokenIds, setTokenIds] = useState(""); + const publicClient = usePublicClient({ chainId: getTargetNetwork().id }); + + const addErc1155TxToBasket = async () => { + if (!isAddress(contractAddress) || !tokenIds) { + alert("Provide a contract and a token ID"); + return; + } + + const currentIds = tokenIds + .split(",") + .map((a: any) => a) + .map((a: any) => BigNumber.from(a)); + const balances = (await publicClient.readContract({ + address: contractAddress as `0x${string}`, + abi: ERC1155_ABI, + functionName: "balanceOfBatch", + args: [Array(tokenIds.length).fill(hackedAddress), tokenIds], + })) as BigNumber[]; + const tokenIdsWithInvalidBalances: BigNumber[] = []; + for (let i = 0; i < tokenIds.length; i++) { + if (!balances[i] || balances[i].toString() == "0") { + tokenIdsWithInvalidBalances.push(currentIds[i]); + } + } + if (tokenIdsWithInvalidBalances.length > 0) { + alert(`Remove following tokenIds as hacked account does not own them: ${tokenIdsWithInvalidBalances.toString()}`); + return; + } + const erc1155TokenIds = currentIds.map(t => t.toString()); + const erc1155TokenBalances = balances.map(t => t.toString()); + + const newErc1155Tx: ERC1155Tx = { + type: "erc1155", + info: `ERC1155 for tokenIds ${erc1155TokenIds.toString()}`, + uri: "changeme", + tokenIds: erc1155TokenIds, + amounts: erc1155TokenBalances, + toSign: { + from: hackedAddress as `0x${string}`, + to: contractAddress as `0x${string}`, + data: erc1155Interface.encodeFunctionData("safeBatchTransferFrom", [ + hackedAddress, + safeAddress, + erc1155TokenIds, + erc1155TokenBalances, + ethers.constants.HashZero, + ]) as `0x${string}`, + }, + }; + addAsset(newErc1155Tx); + }; + return ( +
+ {""} close()} /> +

{"ERC1155"}

+
+ + setContractAddress(e)} + /> +
+ + setTokenIds(e)} /> +
+ addErc1155TxToBasket()} /> +
+ ); + }; + \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC20From.tsx b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC20From.tsx new file mode 100644 index 0000000..664cfca --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC20From.tsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import styles from "../manualAssetSelection.module.css"; +import { useContractRead } from "wagmi"; +import { ERC20_ABI } from "~~/utils/constants"; +import { getTargetNetwork } from "~~/utils/scaffold-eth"; +import { ITokenForm } from "./BasicFlow"; +import { BigNumber, ethers } from "ethers"; +import { isAddress } from "viem"; +import Image from "next/image"; +import BackSvg from "../../../../public/assets/flashbotRecovery/back.svg"; +import { ERC20Tx } from "~~/types/business"; +import { AddressInput } from "~~/components/scaffold-eth"; +import { CustomButton } from "../../CustomButton/CustomButton"; +const erc20Interface = new ethers.utils.Interface(ERC20_ABI); + +export const ERC20Form = ({ hackedAddress, safeAddress, addAsset, close }: ITokenForm) => { + const [contractAddress, setContractAddress] = useState(""); + + let erc20Balance: string = "NO INFO"; + try { + let { data } = useContractRead({ + chainId: getTargetNetwork().id, + functionName: "balanceOf", + address: contractAddress as `0x${string}`, + abi: ERC20_ABI, + watch: true, + args: [hackedAddress], + }); + if (data) { + erc20Balance = BigNumber.from(data).toString(); + if (erc20Balance == "0") erc20Balance = "NO INFO"; + } + } catch (e) { + // Most probably the contract address is not valid as user is + // still typing, so ignore. + } + + const addErc20TxToBasket = (balance: string) => { + if (!isAddress(contractAddress)) { + alert("Provide a contract first"); + return; + } + if (balance == "NO INFO") { + alert("Hacked account has no balance in given erc20 contract"); + return; + } + + const newErc20tx: ERC20Tx = { + type: "erc20", + info: "changeme", + symbol: "changeme", + amount: balance, + toSign: { + from: hackedAddress as `0x${string}`, + to: contractAddress as `0x${string}`, + data: erc20Interface.encodeFunctionData("transfer", [safeAddress, BigNumber.from(balance)]) as `0x${string}`, + }, + }; + addAsset(newErc20tx); + }; + + return ( +
+ {""} close()} /> +

{"ERC20"}

+
+ + setContractAddress(e)} + /> +
+ addErc20TxToBasket(erc20Balance)} /> +
+ ); + }; + \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC721Form.tsx b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC721Form.tsx new file mode 100644 index 0000000..df4be1e --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/BasicFlow/ERC721Form.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import styles from "../manualAssetSelection.module.css"; +import { usePublicClient } from "wagmi"; +import { ERC721_ABI } from "~~/utils/constants"; +import { getTargetNetwork } from "~~/utils/scaffold-eth"; +import { ITokenForm } from "./BasicFlow"; +import { BigNumber, ethers } from "ethers"; +import { isAddress } from "viem"; +import Image from "next/image"; +import BackSvg from "../../../../public/assets/flashbotRecovery/back.svg"; +import { AddressInput, InputBase } from "~~/components/scaffold-eth"; +import { CustomButton } from "../../CustomButton/CustomButton"; +import { ERC721Tx } from "~~/types/business"; +const erc721Interface = new ethers.utils.Interface(ERC721_ABI); + +export const ERC721Form = ({ hackedAddress, safeAddress, addAsset, close }: ITokenForm) => { + const [contractAddress, setContractAddress] = useState(""); + const [tokenId, setTokenId] = useState(""); + const publicClient = usePublicClient({ chainId: getTargetNetwork().id }); + + const addErc721TxToBasket = async () => { + if (!isAddress(contractAddress) || !tokenId) { + alert("Provide a contract and a token ID"); + return; + } + + let ownerOfGivenTokenId; + try { + ownerOfGivenTokenId = await publicClient.readContract({ + address: contractAddress as `0x${string}`, + abi: ERC721_ABI, + functionName: "ownerOf", + args: [BigNumber.from(tokenId)], + }); + } catch (e) {} + + if (!ownerOfGivenTokenId || ownerOfGivenTokenId.toString() != hackedAddress) { + alert(`Couldn't verify hacked account's ownership. Cannot add to the basket...`); + return; + } + + const newErc721Tx: ERC721Tx = { + type: "erc721", + info: `NFT recovery for tokenId ${tokenId}`, + symbol: "changeme", + tokenId: tokenId, + toSign: { + from: hackedAddress as `0x${string}`, + to: contractAddress as `0x${string}`, + data: erc721Interface.encodeFunctionData("transferFrom", [ + hackedAddress, + safeAddress, + BigNumber.from(tokenId), + ]) as `0x${string}`, + }, + }; + addAsset(newErc721Tx); + }; + return ( +
+ {""} close()} /> +

{"ERC721"}

+
+ + setContractAddress(e)} + /> +
+ + setTokenId(e)} /> +
+ addErc721TxToBasket()} /> +
+ ); + }; + \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/CustomFlow/CustomFlow.tsx b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/CustomFlow/CustomFlow.tsx new file mode 100644 index 0000000..710d6f0 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/CustomFlow/CustomFlow.tsx @@ -0,0 +1,74 @@ +import { parseAbiItem } from "viem"; +import { AddressInput, CustomContractWriteForm, InputBase } from "~~/components/scaffold-eth"; +import { AbiFunction } from "abitype"; +import styles from "../manualAssetSelection.module.css"; +import { RecoveryTx } from "~~/types/business"; +import { useState } from "react"; +interface ICustomFlowProps { + hackedAddress: string; + addAsset: (asset: RecoveryTx) => void; + } + export const CustomFlow = ({ hackedAddress, addAsset }: ICustomFlowProps) => { + const [customContractAddress, setCustomContractAddress] = useState(""); + const [customFunctionSignature, setCustomFunctionSignature] = useState(""); + + const getParsedAbi = () => { + if (!customContractAddress || !customFunctionSignature) { + return null; + } + try { + const parsedFunctAbi = parseAbiItem(customFunctionSignature) as AbiFunction; + return parsedFunctAbi; + } catch (e) { + return null; + } + }; + const parsedFunctAbi = getParsedAbi(); + + return ( + <> +
+ +
+ + + setCustomContractAddress(e)} + /> +
+ + setCustomFunctionSignature(e)} + /> + {!!parsedFunctAbi ? ( +
+ { + setCustomContractAddress(""); + setCustomFunctionSignature(""); + }} + /> +
+ ) : ( + <> + )} +
+ + ); + }; + \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/ManualAssetSelection.tsx b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/ManualAssetSelection.tsx new file mode 100644 index 0000000..ccb4841 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/ManualAssetSelection.tsx @@ -0,0 +1,60 @@ +import Image from "next/image"; +import CloseSvg from "../../../public/assets/flashbotRecovery/close.svg"; +import { Tabs } from "../tabs/Tabs"; +import { BasicFlow } from "./BasicFlow/BasicFlow"; +import { CustomFlow } from "./CustomFlow/CustomFlow"; +import styles from "./manualAssetSelection.module.css"; +import { motion } from "framer-motion"; +import { createPortal } from "react-dom"; +import { RecoveryTx } from "~~/types/business"; + +interface IProps { + isVisible: boolean; + close: () => void; + hackedAddress: string; + safeAddress: string; + addAsset: (asset: RecoveryTx) => void; +} +export const ManualAssetSelection = ({ isVisible, close,safeAddress, addAsset, hackedAddress }: IProps) => { + const portalSelector = document.querySelector("#myportal"); + if (!portalSelector) { + return <>; + } + + return isVisible + ? createPortal( + +
+ + {" "} + {!!close ? {""} close()} /> : <>} + +
+

{"Add assets manually"}

+ + {active => { + const isBasic = active == 0; + if (isBasic) { + return ( + + ); + } + return addAsset(item)} />; + }} + +
+
+
, + portalSelector, + ) + : null; +}; diff --git a/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/manualAssetSelection.module.css b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/manualAssetSelection.module.css new file mode 100644 index 0000000..551c70f --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/ManualAssetSelection/manualAssetSelection.module.css @@ -0,0 +1,163 @@ +.modalContainer { + position: absolute; + top: 0; + bottom: 0; + width: 100vw; + height: 100vh; + backdrop-filter: blur(5px); + display: flex; + justify-content: center; + align-items: center; + background: rgba(20, 28, 41, 0.40); +} + +.modal { + display: inline-flex; + width: 579px; + flex-direction: column; + align-items: center; + flex-shrink: 0; + border-radius: 12px; + box-shadow: 0px 0px 10px 5px rgba(0, 0, 0, 0.03); + height: 650px; +} + +.modalContent{ + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 40px; + flex-shrink: 0; + margin-bottom: 56px; + width: 90%; +} + +.close{ + align-self: flex-end; + margin-top: 14px; + margin-right: 14px; + cursor: pointer; + padding: 10px; + padding-bottom: 4px; +} + +.title{ + font-size: 24px; +font-style: normal; +font-weight: 700; +line-height: 32px; +margin:0; +text-align: center; +} + + +.text{ + font-size: 18px; +font-style: normal; +font-weight: 400; +line-height: 32px; +width: 80%; +text-align: center; +margin: 0; +} + +.basicItem{ + display: flex; +padding: 16px; +flex-direction: column; +justify-content: center; +align-items: center; +gap: 24px; +border-radius: 12px; +margin-top: 24px; +} + + +.container, .containerCustom, .containerBasic{ + position: relative; + margin-bottom: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + margin: 0 auto; + max-width: 450px; +} +.containerCustom{ + height: 392px; + width: 100%; + padding: 0 10px; + overflow-y: auto; +} +.containerBasic{ + height: 538px; + width: 100%; + padding: 0 10px; +} + +.list{ + width: 450px; + margin: 0 auto; +} +.label{ + font-size: 16px; +font-style: normal; +font-weight: 600; +line-height: normal; +margin-bottom: 12px; +} +.container > div{ + border-radius: 4px; + padding: 4px; +} +.containerCustom > div{ + border-radius: 4px; + padding: 4px; +} +.containerBasic > div{ + border-radius: 4px; + padding: 4px; +} +.contract{ + margin-top: 20px; +} + +.contract > div >div div{ + border-radius: 4px; + padding: 4px; +} +.contract > div >div div input::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ + color: rgb(156 163 175 / var(--tw-text-opacity)); + opacity: 1; /* Firefox */ +} +.contract > div >div div:last-child{ + display: flex; + justify-content: center; + align-items: center; + } + .contract > div >div div:last-child button{ + background-color: #17A9E8; + + color: #293853; + width: 230px; + margin: 0 auto; + padding: 0px 48px; + height: 40px; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: normal; + border: 1px solid; + } + +.bottom{ + flex-grow: 1; +} + .contract > div >div div:last-child button svg{ + display: none; + } + + .back{ + position: absolute; + top: 0; + cursor: pointer; + } \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/RecoveryProcess/RecoveryProcess.tsx b/packages/nextjs/components/flashbotRecovery/RecoveryProcess/RecoveryProcess.tsx new file mode 100644 index 0000000..ed81d34 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/RecoveryProcess/RecoveryProcess.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { CustomPortal } from "../CustomPortal/CustomPortal"; +import { RecoveryProcessStatus } from "~~/hooks/flashbotRecoveryBundle/useRecoveryProcess"; +import LogoSvg from "~~/public/assets/flashbotRecovery/logo.svg"; +import ClockSvg from "~~/public/assets/flashbotRecovery/clock.svg"; +import SuccessSvg from "~~/public/assets/flashbotRecovery/success.svg"; +import VideoSvg from "~~/public/assets/flashbotRecovery/video.svg"; + +interface IProps { + recoveryStatus: RecoveryProcessStatus; + startSigning: () => void; + finishProcess: () => void; + startProcess: () => void; + connectedAddress: string; + safeAddress: string; + hackedAddress: string; + blockCountdown:number +} +export const RecoveryProcess = ({ + recoveryStatus, + startSigning, + startProcess, + finishProcess, + blockCountdown, + connectedAddress, + safeAddress, + hackedAddress, +}: IProps) => { + + if (recoveryStatus == RecoveryProcessStatus.initial) { + return <>; + } + if (recoveryStatus == RecoveryProcessStatus.gasCovered) { + alert("you already covered the gas. If you're in a confussy situation, clear cookies and refresh page."); + return; + } + + if (recoveryStatus == RecoveryProcessStatus.cachedDataToClean) { + return ( + + ); + } + + if (recoveryStatus == RecoveryProcessStatus.noSafeAccountConnected) { + return ( + startProcess(), + }} + image={LogoSvg} + /> + ); + } + if ( + recoveryStatus == RecoveryProcessStatus.switchFlashbotNetworkAndPayBundleGas + ) { + return ( + + ); + } + + if(recoveryStatus == RecoveryProcessStatus.increaseGas){ + return ( + + ); + } + if (recoveryStatus == RecoveryProcessStatus.switchToHacked) { + return ( + startSigning(), + }} + image={LogoSvg} + /> + ); + } + if ( + recoveryStatus == RecoveryProcessStatus.signEachTransaction || + recoveryStatus == RecoveryProcessStatus.allTxSigned || + recoveryStatus == RecoveryProcessStatus.sendingBundle + ) { + return ( + + ); + } + if (recoveryStatus == RecoveryProcessStatus.listeningToBundle) { + return ( + + ); + } + if (recoveryStatus == RecoveryProcessStatus.success) { + return ( + finishProcess(), + }} + image={SuccessSvg} + /> + ); + } + + return <>; +}; \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/RecoveryProcess/recoveryProcess.module.css b/packages/nextjs/components/flashbotRecovery/RecoveryProcess/recoveryProcess.module.css new file mode 100644 index 0000000..e69de29 diff --git a/packages/nextjs/components/flashbotRecovery/TransactionBundleStep/transactionBundleStep.module.css b/packages/nextjs/components/flashbotRecovery/TransactionBundleStep/transactionBundleStep.module.css new file mode 100644 index 0000000..a018b57 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/TransactionBundleStep/transactionBundleStep.module.css @@ -0,0 +1,79 @@ +.container{ + width: 99%; + + margin-bottom: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.title{ + font-size: 23px; + font-style: normal; + font-weight: 600; + line-height: 100%; + margin: 0; +} +.assetItem{ + display: flex; + height: 52px; + max-width: 600px; + width: 90%; + margin: 0 auto; + cursor: pointer; + border-radius: 4px; + margin-bottom: 12px; +} +.data{ + display: flex; + flex-direction: column; + justify-content: center; + flex-grow: 1; + margin-left: 16px; +} +.data h3{ + margin: 0; + font-size: 18px; +font-style: normal; +font-weight: 500; +} +.assetList{ + width: 100%; + max-height: calc(100vh - 500px); + height: calc(100vh - 500px); + overflow: auto; + margin: 36px 0; + margin-bottom: 0; +} + +.clear{ + text-decoration: underline; + width: 230px; + margin-bottom: 42px; + margin-top: 16px; + text-align: center; + cursor: pointer; +} +.clear:hover{ + color:#17A9E8 +} +.logo{ + width: 60px; + height: 60px; + margin-right: 16px; +} + +.close{ + display: flex; + flex-direction: column; + justify-content: center; + margin-left:16px; + margin-right: 16px; +} +.gasContainer{ + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} \ No newline at end of file diff --git a/packages/nextjs/components/flashbotRecovery/TransactionBundleStep/transactionBundleStep.tsx b/packages/nextjs/components/flashbotRecovery/TransactionBundleStep/transactionBundleStep.tsx new file mode 100644 index 0000000..aa921b7 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/TransactionBundleStep/transactionBundleStep.tsx @@ -0,0 +1,129 @@ +import React, { Dispatch, SetStateAction, useEffect } from "react"; +import Image from "next/image"; +import GasSvg from "../../../public/assets/flashbotRecovery/gas.svg"; +import { CustomButton } from "../CustomButton/CustomButton"; +import styles from "./transactionBundleStep.module.css"; +import { BigNumber, ethers } from "ethers"; +import { motion } from "framer-motion"; +import { RecoveryTx } from "~~/types/business"; +import { useInterval } from "usehooks-ts"; +import { useGasEstimation } from "~~/hooks/flashbotRecoveryBundle/useGasEstimation"; + +interface IProps { + isVisible: boolean; + clear: () => void; + transactions: RecoveryTx[]; + onAddMore: () => void; + modifyTransactions: Dispatch>; + onSubmit:(val:BigNumber) => void; + totalGasEstimate:BigNumber; + setTotalGasEstimate:Dispatch>; +} + +export const TransactionBundleStep = ({ + clear, + onAddMore, + isVisible, + onSubmit, + transactions, + modifyTransactions, + totalGasEstimate, + setTotalGasEstimate +}: IProps) => { + + const {estimateTotalGasPrice} = useGasEstimation() + + useEffect(() => { + if(transactions.length == 0){ + return + } + estimateTotalGasPrice(transactions, removeUnsignedTx).then(setTotalGasEstimate) + }, [transactions]) + + const updateTotalGasEstimate = async () => { + setTotalGasEstimate(await estimateTotalGasPrice(transactions, removeUnsignedTx)); + }; + + useInterval(() => { + if(transactions.length == 0){ + return + } + updateTotalGasEstimate(); + }, 5000); + + const removeUnsignedTx = (txId: number) => { + modifyTransactions((prev: RecoveryTx[]) => { + if (txId < 0 || txId > prev.length) { + return prev.filter(a => a); + } + delete prev[txId]; + + const newUnsignedTxArr = prev.filter(a => a); + return newUnsignedTxArr; + }); + }; + + if (!isVisible) { + return <>; + } + return ( + +

Your transactions

+
+
+ {transactions.map((item, i) => { + return removeUnsignedTx(i)} tx={item} />; + })} +
+ clear()}> + Clear all + +
+ +
+ {ethers.utils.formatEther(totalGasEstimate.toString())} +
+
+ onAddMore()} /> +
+ onSubmit(totalGasEstimate)} /> +
+ ); +}; + +interface ITransactionProps { + onDelete: () => void; + tx?: RecoveryTx; +} + +const TransactionItem = ({ onDelete, tx }: ITransactionProps) => { + const getTitle = () => { + if (!tx) { + return ""; + } + if (["erc1155", "erc721"].indexOf(tx.type) != -1) { + //@ts-ignore + return `${tx.info} (${tx.tokenId!})`; + } + if (tx.type === "erc20") { + //@ts-ignore + return `${tx.value} ${tx.symbol} `; + } + return tx.info; + }; + + return ( + +
+

{getTitle()}

+
+
onDelete()}> + X +
+
+ ); +}; diff --git a/packages/nextjs/components/flashbotRecovery/tabs/Tabs.tsx b/packages/nextjs/components/flashbotRecovery/tabs/Tabs.tsx new file mode 100644 index 0000000..7192d18 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/tabs/Tabs.tsx @@ -0,0 +1,30 @@ +import { useState } from "react"; +import styles from "./tabs.module.css"; + +interface IProps { + children: (active: number) => any; + tabTitles: string[]; +} + +export const Tabs = ({ children, tabTitles }: IProps) => { + const [active, setActive] = useState(0); + + return ( +
+
+ {tabTitles.map((title, i) => ( + setActive(i)} + className={`${styles.tabTitle} ${active !== i ? "text-secondary-content" : "text-secondary bg-neutral"}`} + > + {title} + + ))} +
+
+ <>{children(active)} +
+
+ ); +}; diff --git a/packages/nextjs/components/flashbotRecovery/tabs/tabs.module.css b/packages/nextjs/components/flashbotRecovery/tabs/tabs.module.css new file mode 100644 index 0000000..ec2f103 --- /dev/null +++ b/packages/nextjs/components/flashbotRecovery/tabs/tabs.module.css @@ -0,0 +1,41 @@ +.tabsContainer { + display: flex; + width: 100%; + flex-direction: column; + justify-content: center; + align-items: center; + max-width: 940px; + +} +.tabsHeader { + display: flex; + flex-direction: row; + display: flex; + width: 100%; + max-width: 455px; + min-width: 330px; + padding: 4px; + gap: 4px; + border-radius: 8px; +} +.tabTitle { + line-height: 16px; + padding: 7px 0; + gap: 10px; + border-radius: 8px; + width: 100%; + text-align: center; + box-shadow: 0px 1px 5px 0px rgba(0, 0, 0, 0.05); + transition: all 0.3s; + cursor: pointer; +} +.tabContent { + width: 100%; +} + +@media (max-width: 768px) { + .tabContent { + width: 96%; + min-width: 330px; + } +} diff --git a/packages/nextjs/components/scaffold-eth/Contract/WriteOnlyFunctionForm.tsx b/packages/nextjs/components/scaffold-eth/Contract/WriteOnlyFunctionForm.tsx index 981b0d2..fa8ba75 100644 --- a/packages/nextjs/components/scaffold-eth/Contract/WriteOnlyFunctionForm.tsx +++ b/packages/nextjs/components/scaffold-eth/Contract/WriteOnlyFunctionForm.tsx @@ -107,7 +107,7 @@ export const CustomContractWriteForm = ({ } }} > - 🧺 add 🧺 + add
diff --git a/packages/nextjs/hooks/flashbotRecoveryBundle/useAutodetectAssets.ts b/packages/nextjs/hooks/flashbotRecoveryBundle/useAutodetectAssets.ts new file mode 100644 index 0000000..bb97eb9 --- /dev/null +++ b/packages/nextjs/hooks/flashbotRecoveryBundle/useAutodetectAssets.ts @@ -0,0 +1,326 @@ +import React, { useState } from "react"; +import { Alchemy, AssetTransfersCategory, AssetTransfersResult, Network } from "alchemy-sdk"; +import { BigNumber, ethers } from "ethers"; +import { usePublicClient } from "wagmi"; +import { + AutoDetectedERC20Info, + AutoDetectedERC721Info, + AutoDetectedERC1155Info, + ERC20Tx, + ERC721Tx, + ERC1155Tx, + RecoveryTx, +} from "~~/types/business"; +import { ERC20_ABI, ERC721_ABI, ERC1155_ABI } from "~~/utils/constants"; +import { getTargetNetwork } from "~~/utils/scaffold-eth"; + +const erc20Interface = new ethers.utils.Interface(ERC20_ABI); +const erc721Interface = new ethers.utils.Interface(ERC721_ABI); +const erc1155Interface = new ethers.utils.Interface(ERC1155_ABI); + +interface IProps { + hackedAddress: string; + safeAddress: string; +} + +export const useAutodetectAssets = () => { + const targetNetwork = getTargetNetwork(); + const [alchemy] = useState( + new Alchemy({ + apiKey: "v_x1FpS3QsTUZJK3leVsHJ_ircahJ1nt", + network: targetNetwork.network == "goerli" ? Network.ETH_GOERLI : Network.ETH_MAINNET, + }), + ); + const publicClient = usePublicClient({ chainId: targetNetwork.id }); + + const fetchAllAssetTransfersOfHackedAccount = async (hackedAddress: string) => + ( + await Promise.all([ + alchemy.core.getAssetTransfers({ + fromAddress: hackedAddress, + excludeZeroValue: true, + category: [AssetTransfersCategory.ERC20, AssetTransfersCategory.ERC721, AssetTransfersCategory.ERC1155], + }), + alchemy.core.getAssetTransfers({ + toAddress: hackedAddress, + excludeZeroValue: true, + category: [AssetTransfersCategory.ERC20, AssetTransfersCategory.ERC721, AssetTransfersCategory.ERC1155], + }), + ]) + ) + .map(res => res.transfers) + .flat(); + + const getAutodetectedAssets = async ({ hackedAddress, safeAddress }: IProps) => { + if (!ethers.utils.isAddress(hackedAddress)) { + return; + } + if (!alchemy) { + alert("Seems Alchemy API rate limit has been reached. Contact irbozk@gmail.com"); + return; + } + + const erc20transfers: AssetTransfersResult[] = [], + erc721transfers: AssetTransfersResult[] = [], + erc1155transfers: AssetTransfersResult[] = []; + + try { + (await fetchAllAssetTransfersOfHackedAccount(hackedAddress)).forEach(tx => { + if (tx.category == AssetTransfersCategory.ERC20) { + erc20transfers.push(tx); + } else if (tx.category == AssetTransfersCategory.ERC721) { + erc721transfers.push(tx); + } else if (tx.category == AssetTransfersCategory.ERC1155) { + erc1155transfers.push(tx); + } + }); + + // Classify the fetched transfers + + const erc20contracts = Array.from( + new Set( + erc20transfers.filter(tx => tx.rawContract.address != null).map(tx => tx.rawContract.address! as string), + ), + ); + + const erc721contractsAndTokenIds = erc721transfers.reduce( + (acc, tx) => { + const assetContractAddress = tx.rawContract.address; + const assetTokenId = tx.erc721TokenId; + + if (!assetContractAddress || !assetTokenId) { + return acc; + } + + if (!(assetContractAddress in acc)) { + acc[assetContractAddress] = new Set(); + } + + acc[assetContractAddress].add(assetTokenId); + return acc; + }, + {} as { + [address: string]: Set; + }, + ); + + const erc1155contractsAndTokenIds = erc1155transfers.reduce( + (acc, tx) => { + const assetContractAddress = tx.rawContract.address; + const assetMetadata = tx.erc1155Metadata; + + if (!assetContractAddress || !assetMetadata) { + return acc; + } + + if (!(assetContractAddress in acc)) { + acc[assetContractAddress] = new Set(); + } + + assetMetadata.map(meta => meta.tokenId).forEach(tokenId => acc[assetContractAddress].add(tokenId)); + return acc; + }, + {} as { + [address: string]: Set; + }, + ); + + // Now get the balances & owned NFTs + + const erc20BalancePromises = erc20contracts.map(async erc20contract => { + const balance = (await publicClient.readContract({ + address: erc20contract as `0x${string}`, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [hackedAddress], + })) as string; + if (!balance || balance.toString() == "0") { + return []; + } + return [erc20contract, balance.toString()]; + }); + + const erc721OwnershipPromises = Object.keys(erc721contractsAndTokenIds).map(async erc721Contract => { + const ownedTokenIds = await Promise.all( + Array.from(erc721contractsAndTokenIds[erc721Contract]).map(async tokenId => { + const ownerOfGivenTokenId = await publicClient.readContract({ + address: erc721Contract as `0x${string}`, + abi: ERC721_ABI, + functionName: "ownerOf", + args: [BigNumber.from(tokenId)], + }); + if (!ownerOfGivenTokenId || ownerOfGivenTokenId != hackedAddress) { + return undefined; + } + return tokenId; + }), + ); + const ownedTokenIdsFiltered = ownedTokenIds.filter(tokenId => tokenId != undefined) as string[]; + if (ownedTokenIdsFiltered.length == 0) { + return []; + } + return [erc721Contract, ownedTokenIdsFiltered]; + }); + + const erc1155OwnershipPromises = Object.keys(erc1155contractsAndTokenIds).map(async erc1155Contract => { + const tokenIdsWithinContract = Array.from(erc1155contractsAndTokenIds[erc1155Contract]); + const tokenIdBalances = (await publicClient.readContract({ + address: erc1155Contract as `0x${string}`, + abi: ERC1155_ABI, + functionName: "balanceOfBatch", + args: [Array(tokenIdsWithinContract.length).fill(hackedAddress), tokenIdsWithinContract], + })) as bigint[]; + + const tokenIdsAndBalances: string[][] = []; + for (let i = 0; i < tokenIdBalances.length; i++) { + if (tokenIdBalances[i] == 0n) { + continue; + } + tokenIdsAndBalances.push([tokenIdsWithinContract[i], tokenIdBalances[i].toString()]); + } + if (tokenIdsAndBalances.length == 0) { + return []; + } + + return [erc1155Contract, Object.fromEntries(tokenIdsAndBalances)]; + }); + + // Await all the promises + + const { erc20ContractsAndBalances, erc721ContractsAndOwnedTokens, erc1155ContractsAndTokenIdsWithBalances } = + await Promise.all([ + (await Promise.all(erc20BalancePromises)).filter(a => a.length > 0), + (await Promise.all(erc721OwnershipPromises)).filter(a => a.length > 0), + (await Promise.all(erc1155OwnershipPromises)).filter(a => a.length > 0), + ]).then(([erc20res, erc721res, erc1155res]) => { + return { + erc20ContractsAndBalances: Object.fromEntries(erc20res) as AutoDetectedERC20Info, + erc721ContractsAndOwnedTokens: Object.fromEntries(erc721res) as AutoDetectedERC721Info, + erc1155ContractsAndTokenIdsWithBalances: Object.fromEntries(erc1155res) as AutoDetectedERC1155Info, + }; + }); + + // Fetch token symbols and save results + + const autoDetectedErc20Txs: ERC20Tx[] = await Promise.all( + Object.entries(erc20ContractsAndBalances).map(async ([erc20contract, erc20balance]) => { + let tokenSymbol = "???"; + try { + tokenSymbol = (await publicClient.readContract({ + address: erc20contract as `0x${string}`, + abi: ERC20_ABI, + functionName: "symbol", + args: [], + })) as string; + } catch (e) { + /* ignore */ + } + + const newErc20tx: ERC20Tx = { + type: "erc20", + info: `ERC20 - ${tokenSymbol != "???" ? `${tokenSymbol}` : `${erc20contract}`}`, + symbol: tokenSymbol, + amount: erc20balance, + toSign: { + from: hackedAddress as `0x${string}`, + to: erc20contract as `0x${string}`, + data: erc20Interface.encodeFunctionData("transfer", [ + safeAddress, + BigNumber.from(erc20balance), + ]) as `0x${string}`, + }, + }; + return newErc20tx; + }), + ); + + const autoDetectedErc721Txs: ERC721Tx[] = ( + await Promise.all( + Object.entries(erc721ContractsAndOwnedTokens).map(async ([erc721contract, ownedTokenIds]) => { + let tokenSymbol = "???"; + try { + tokenSymbol = (await publicClient.readContract({ + address: erc721contract as `0x${string}`, + abi: ERC721_ABI, + functionName: "symbol", + args: [], + })) as string; + } catch (e) { + /* ignore */ + } + + const newErc721txs: ERC721Tx[] = ownedTokenIds.map(tokenId => { + const newErc721tx: ERC721Tx = { + type: "erc721", + info: `ERC721 - ${tokenSymbol != "???" ? `${tokenSymbol}` : `${erc721contract}`}`, + symbol: tokenSymbol, + tokenId: parseInt(tokenId).toString(), + toSign: { + from: hackedAddress as `0x${string}`, + to: erc721contract as `0x${string}`, + data: erc721Interface.encodeFunctionData("transferFrom", [ + hackedAddress, + safeAddress, + BigNumber.from(tokenId), + ]) as `0x${string}`, + }, + }; + return newErc721tx; + }); + return newErc721txs; + }), + ) + ).flat(); + + const autoDetectedErc1155Txs: ERC1155Tx[] = await Promise.all( + Object.entries(erc1155ContractsAndTokenIdsWithBalances).map(async ([erc1155contract, tokenIdsAndBalances]) => { + let uri = "???"; + try { + uri = (await publicClient.readContract({ + address: erc1155contract as `0x${string}`, + abi: ERC1155_ABI, + functionName: "uri", + args: [0], + })) as string; + } catch (e) { + /* ignore */ + } + + const tokenIds = Object.keys(tokenIdsAndBalances); + const balances: string[] = []; + for (let i = 0; i < tokenIds.length; i++) { + balances.push(tokenIdsAndBalances[tokenIds[i]]); + } + + const newErc1155Tx: ERC1155Tx = { + type: "erc1155", + info: `ERC1155 - ${uri != "???" ? `${uri}` : `${erc1155contract}`}`, + uri: "changeme", + tokenIds: tokenIds, + amounts: balances, + toSign: { + from: hackedAddress as `0x${string}`, + to: erc1155contract as `0x${string}`, + data: erc1155Interface.encodeFunctionData("safeBatchTransferFrom", [ + hackedAddress, + safeAddress, + tokenIds, + balances, + ethers.constants.HashZero, + ]) as `0x${string}`, + }, + }; + return newErc1155Tx; + }), + ); + const result: RecoveryTx[] = [...autoDetectedErc20Txs, ...autoDetectedErc721Txs, ...autoDetectedErc1155Txs]; + return result; + } catch (e) { + console.error(`Error fetching assets of hacked account: ${e}`); + } + }; + + return { + getAutodetectedAssets, + }; +}; diff --git a/packages/nextjs/hooks/flashbotRecoveryBundle/useGasEstimation.ts b/packages/nextjs/hooks/flashbotRecoveryBundle/useGasEstimation.ts new file mode 100644 index 0000000..8b0375b --- /dev/null +++ b/packages/nextjs/hooks/flashbotRecoveryBundle/useGasEstimation.ts @@ -0,0 +1,62 @@ +import React from "react"; +import { FlashbotsBundleProvider } from "@flashbots/ethers-provider-bundle"; +import { BigNumber, ethers } from "ethers"; +import { usePublicClient } from "wagmi"; +import { RecoveryTx } from "~~/types/business"; +import { getTargetNetwork } from "~~/utils/scaffold-eth"; + +const BLOCKS_IN_THE_FUTURE: { [i: number]: number } = { + 1: 7, + 5: 10, +}; + +export const useGasEstimation = () => { + const targetNetwork = getTargetNetwork(); + const publicClient = usePublicClient({ chainId: targetNetwork.id }); + + const estimateTotalGasPrice = async (txs: RecoveryTx[], deleteTransaction: (id: number) => void) => { + const tempProvider = new ethers.providers.InfuraProvider(targetNetwork.id, "416f5398fa3d4bb389f18fd3fa5fb58c"); + try { + const estimates = await Promise.all( + txs + .filter(a => a) + .map((tx, txId) => { + return tempProvider.estimateGas(tx.toSign).catch(e => { + console.warn( + `Following tx will fail when bundle is submitted, so it's removed from the bundle right now. The contract might be a hacky one, and you can try further manipulation via crafting a custom call.`, + ); + console.warn(tx); + console.warn(e); + deleteTransaction(txId); + return BigNumber.from("0"); + }); + }), + ); + + return estimates + .reduce((acc: BigNumber, val: BigNumber) => acc.add(val), BigNumber.from("0")) + .mul(await maxBaseFeeInFuture()) + .mul(105) + .div(100); + } catch (e) { + alert( + "Error estimating gas prices. Something can be wrong with one of the transactions. Check the console and remove problematic tx.", + ); + console.error(e); + return BigNumber.from("0"); + } + }; + + const maxBaseFeeInFuture = async () => { + const blockNumberNow = await publicClient.getBlockNumber(); + const block = await publicClient.getBlock({ blockNumber: blockNumberNow }); + return FlashbotsBundleProvider.getMaxBaseFeeInFutureBlock( + BigNumber.from(block.baseFeePerGas), + BLOCKS_IN_THE_FUTURE[targetNetwork.id], + ); + }; + + return { + estimateTotalGasPrice + }; +}; diff --git a/packages/nextjs/hooks/flashbotRecoveryBundle/useRecoveryProcess.ts b/packages/nextjs/hooks/flashbotRecoveryBundle/useRecoveryProcess.ts new file mode 100644 index 0000000..aef8bd7 --- /dev/null +++ b/packages/nextjs/hooks/flashbotRecoveryBundle/useRecoveryProcess.ts @@ -0,0 +1,287 @@ +import React, { useEffect, useState } from "react"; +import { BigNumber, ethers } from "ethers"; +import { useInterval, useLocalStorage } from "usehooks-ts"; +import { v4 } from "uuid"; +import { useAccount, usePublicClient, useWalletClient } from "wagmi"; +import { RecoveryTx } from "~~/types/business"; +import { getTargetNetwork } from "~~/utils/scaffold-eth"; +import { FlashbotsBundleProvider } from "@flashbots/ethers-provider-bundle"; + +interface IStartProcessPops { + safeAddress:string, + modifyBundleId:(arg: string) => void, + totalGas:BigNumber, + hackedAddress:string, + transactions:RecoveryTx[], + currentBundleId:string; +} + +const BLOCKS_IN_THE_FUTURE: { [i: number]: number } = { + 1: 7, + 5: 10, +}; + +export enum RecoveryProcessStatus { + initial, + gasCovered, + cachedDataToClean, + noAccountConnected, + noSafeAccountConnected, + switchFlashbotNetworkAndPayBundleGas, + imposible, + increaseGas, + switchToHacked, + signEachTransaction, + allTxSigned, + sendingBundle, + listeningToBundle, + success, +} + +const flashbotSigner = ethers.Wallet.createRandom(); + + +export const useRecoveryProcess = () => { + const targetNetwork = getTargetNetwork(); + const [flashbotsProvider, setFlashbotsProvider] = useState(); + const [gasCovered, setGasCovered] = useState(false); + const [sentTxHash, setSentTxHash] = useLocalStorage("sentTxHash", ""); + const [sentBlock, setSentBlock] = useLocalStorage("sentBlock", 0); + const [blockCountdown, setBlockCountdown] = useLocalStorage("blockCountdown", 0); + + const [stepActive, setStepActive] = useState(RecoveryProcessStatus.initial); + const publicClient = usePublicClient({ chainId: targetNetwork.id }); + const { address } = useAccount(); + + const { data: walletClient } = useWalletClient(); + const FLASHBOTS_RELAY_ENDPOINT = `https://relay${targetNetwork.network == "goerli" ? "-goerli" : ""}.flashbots.net/`; + + + useEffect(() => { + (async () => { + if (!targetNetwork || !targetNetwork.blockExplorers) return; + setFlashbotsProvider( + await FlashbotsBundleProvider.create( + new ethers.providers.InfuraProvider(targetNetwork.id), + flashbotSigner, + FLASHBOTS_RELAY_ENDPOINT, + targetNetwork.network == "goerli" ? "goerli": undefined + ), + ); + })(); + }, [targetNetwork.id]); + + useInterval(async () => { + const isNotAbleToListenBundle = stepActive != RecoveryProcessStatus.listeningToBundle || !sentTxHash || sentBlock == 0 + try { + if (isNotAbleToListenBundle) return; + const finalTargetBlock = sentBlock + BLOCKS_IN_THE_FUTURE[targetNetwork.id]; + const currentBlock = parseInt((await publicClient.getBlockNumber()).toString()); + const blockDelta = finalTargetBlock - currentBlock; + setBlockCountdown(blockDelta); + + if (blockDelta < 0) { + alert("Error, try again"); + setSentBlock(0); + setSentTxHash(""); + resetStatus(); + return; + } + const txReceipt = await publicClient.getTransactionReceipt({ + hash: sentTxHash as `0x${string}`, + }); + if (txReceipt && txReceipt.blockNumber) { + setStepActive(RecoveryProcessStatus.success); + } + // return; + console.log("TXs not yet mined"); + } catch (e) {} + }, 5000); + + + + const resetStatus = () => { + setStepActive(RecoveryProcessStatus.initial); + } + const validateBundleIsReady = (safeAddress: string) => { + if (gasCovered) { + setStepActive(RecoveryProcessStatus.gasCovered); + return false; + } + + ////////// Enforce switching to the safe address + if (!address) { + setStepActive(RecoveryProcessStatus.noAccountConnected); + return false; + } else if (address != safeAddress) { + setStepActive(RecoveryProcessStatus.noSafeAccountConnected); + return false; + } + setStepActive(RecoveryProcessStatus.switchFlashbotNetworkAndPayBundleGas); + return true; + }; + + const changeFlashbotNetwork = async () => { + const newBundleUuid = v4(); + await addRelayRPC(newBundleUuid); + return newBundleUuid; + }; + + const payTheGas = async (totalGas: BigNumber, hackedAddress: string) => { + await walletClient!.sendTransaction({ + to: hackedAddress as `0x${string}`, + value: BigInt(totalGas.toString()), + }); + setGasCovered(true); + }; + + + + + const signRecoveryTransactions = async (hackedAddress:string, transactions:RecoveryTx[],currentBundleId:string, surpass: boolean = false) => { + + if (!surpass && !gasCovered) { + alert("How did you come here without covering the gas fee first??"); + resetStatus() + return; + } + + ////////// Enforce switching to the hacked address + if (address != hackedAddress) { + setStepActive(RecoveryProcessStatus.switchToHacked); + return; + } + setStepActive(RecoveryProcessStatus.signEachTransaction); + ////////// Sign the transactions in the basket one after another + try { + for (const tx of transactions) { + await walletClient!.sendTransaction(tx.toSign); + } + setGasCovered(false); + await sendBundle(currentBundleId); + } catch (e) { + alert(`FAILED TO SIGN TXS Error: ${e}`); + resetStatus() + } + }; + + + + const sendBundle = async (currentBundleId:string) => { + if (!flashbotsProvider) { + alert("Flashbot provider not available"); + resetStatus() + return; + } + setStepActive(RecoveryProcessStatus.sendingBundle); + try { + const finalBundle = await ( + await fetch( + `https://rpc${targetNetwork.network == "goerli" ? "-goerli" : ""}.flashbots.net/bundle?id=${currentBundleId}`, + ) + ).json(); + if (!finalBundle || !finalBundle.rawTxs) { + alert("Couldn't fetch latest bundle"); + resetStatus() + return; + } + + const txs = finalBundle.rawTxs.reverse(); + + try { + setSentTxHash(ethers.utils.keccak256(txs[0])); + setSentBlock(parseInt((await publicClient.getBlockNumber()).toString())); + + const currentUrl = window.location.href.replace("?", ""); + const response = await fetch(currentUrl + `api/relay${targetNetwork.network == "goerli" ? "-goerli" : ""}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + txs, + }), + }); + + await response.json() + setStepActive(RecoveryProcessStatus.listeningToBundle); + } catch (e) { + console.error(e); + setSentTxHash(""); + setSentBlock(0); + alert("Error submitting bundles. Check console for details."); + resetStatus(); + } + } catch (e) { + console.error(e); + setSentTxHash(""); + setSentBlock(0); + alert("Error submitting bundles. Check console for details."); + resetStatus(); + } + }; + + + + + + const startRecoveryProcess = async ({safeAddress,modifyBundleId,totalGas,currentBundleId, hackedAddress, transactions }:IStartProcessPops) => { + const isValid = validateBundleIsReady(safeAddress); + if (!isValid) { + return; + } + try { + ////////// Create new bundle uuid & add corresponding RPC 'subnetwork' to Metamask + const bundleId = await changeFlashbotNetwork(); + modifyBundleId(bundleId); + setStepActive(RecoveryProcessStatus.increaseGas); + // ////////// Cover the envisioned total gas fee from safe account + await payTheGas(totalGas, hackedAddress) + signRecoveryTransactions(hackedAddress, transactions, currentBundleId,true); + return; + } catch (e) { + resetStatus() + alert(`Error while adding a custom RPC and signing the funding transaction with the safe account. Error: ${e}`); + } + }; + + const addRelayRPC = async (bundleUuid: string) => { + if (!window.ethereum || !window.ethereum.request) { + console.error("MetaMask Ethereum provider is not available"); + return; + } + + try { + await window.ethereum.request({ + method: "wallet_addEthereumChain", + params: [ + { + chainId: `0x${targetNetwork.network == "goerli" ? 5 : 1}`, + chainName: "Flashbot Personal RPC", + nativeCurrency: { + name: "ETH", + symbol: "ETH", + decimals: 18, + }, + rpcUrls: [ + `https://rpc${targetNetwork.network == "goerli" ? "-goerli" : ""}.flashbots.net?bundle=${bundleUuid}`, + ], + blockExplorerUrls: [`https://${targetNetwork.network == "goerli" ? "goerli." : ""}etherscan.io`], + }, + ], + }); + } catch (error) { + console.error("Failed to add custom RPC network to MetaMask:", error); + } + }; + + return { + data:stepActive, + sentBlock, + sentTxHash, + blockCountdown, + startRecoveryProcess, + signRecoveryTransactions, + resetStatus + } +}; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 4dca602..06bd05b 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -23,6 +23,7 @@ "alchemy-sdk": "^2.9.2", "daisyui": "^2.31.0", "ethers": "^5.0.0", + "framer-motion": "^10.16.0", "next": "^13.1.6", "nextjs-progressbar": "^0.0.16", "react": "^18.2.0", @@ -43,6 +44,7 @@ "@trivago/prettier-plugin-sort-imports": "^4.1.1", "@types/node": "^17.0.35", "@types/react": "^18.0.9", + "@types/react-dom": "^18.2.7", "@types/react-blockies": "^1.4.1", "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-modal": "^3", diff --git a/packages/nextjs/pages/_document.tsx b/packages/nextjs/pages/_document.tsx new file mode 100644 index 0000000..ac84519 --- /dev/null +++ b/packages/nextjs/pages/_document.tsx @@ -0,0 +1,17 @@ +import Document, { Html, Head, Main, NextScript } from "next/document" + +export default class MyDocument extends Document { + render() { + return ( + + + +
+
+
+ + + + ) + } +} \ No newline at end of file diff --git a/packages/nextjs/pages/index.tsx b/packages/nextjs/pages/index.tsx index a6b25ce..1ed9253 100644 --- a/packages/nextjs/pages/index.tsx +++ b/packages/nextjs/pages/index.tsx @@ -1,1501 +1,175 @@ /* eslint-disable */ -import { useEffect, useState } from "react"; +import { SetStateAction, useEffect, useState } from "react"; import dynamic from "next/dynamic"; -import { BigNumber } from "@ethersproject/bignumber"; -import { FlashbotsBundleProvider } from "@flashbots/ethers-provider-bundle"; -import { AbiFunction } from "abitype"; -import { Alchemy, AssetTransfersCategory, AssetTransfersResult, Network } from "alchemy-sdk"; -import { ethers } from "ethers"; -import type { NextPage } from "next"; -import ReactModal from "react-modal"; -import { useInterval, useLocalStorage } from "usehooks-ts"; -import { uuid } from "uuidv4"; -import { parseAbiItem } from "viem"; -import { useAccount, useContractRead, useFeeData, usePublicClient, useWalletClient } from "wagmi"; -import { XMarkIcon } from "@heroicons/react/24/outline"; +import LogoSvg from "../public/assets/flashbotRecovery/logo.svg"; +import VideoSvg from "../public/assets/flashbotRecovery/video.svg"; +import { BigNumber } from "ethers"; +import { isAddress } from "ethers/lib/utils"; +import { NextPage } from "next"; +import { useLocalStorage } from "usehooks-ts"; +import { useAccount } from "wagmi"; import { MetaHeader } from "~~/components/MetaHeader"; -import { - AddressInput, - CustomContractWriteForm, - InputBase, - RainbowKitCustomConnectButton, -} from "~~/components/scaffold-eth"; -import { - AutoDetectedERC20Info, - AutoDetectedERC721Info, - AutoDetectedERC1155Info, - ERC20Tx, - ERC721Tx, - ERC1155Tx, - RecoveryTx, -} from "~~/types/business"; -import { ERC20_ABI, ERC721_ABI, ERC1155_ABI } from "~~/utils/constants"; -import { getTargetNetwork } from "~~/utils/scaffold-eth"; - -const flashbotSigner = ethers.Wallet.createRandom(); - -const erc20Interface = new ethers.utils.Interface(ERC20_ABI); -const erc721Interface = new ethers.utils.Interface(ERC721_ABI); -const erc1155Interface = new ethers.utils.Interface(ERC1155_ABI); - -const BLOCKS_IN_THE_FUTURE: { [i: number]: number } = { - 1: 7, - 5: 10, -}; +import { AssetSelectionStep } from "~~/components/flashbotRecovery/AssetSelectionStep/AssetSelectionStep"; +import { ConnectStep } from "~~/components/flashbotRecovery/ConnectStep/ConnectStep"; +import { CustomHeader } from "~~/components/flashbotRecovery/CustomHeader/CustomHeader"; +import { CustomPortal } from "~~/components/flashbotRecovery/CustomPortal/CustomPortal"; +import { HackedAddressStep } from "~~/components/flashbotRecovery/HackedAddressStep/HackedAddressStep"; +import { Layout } from "~~/components/flashbotRecovery/Layout/Layout"; +import { RecoveryProcess } from "~~/components/flashbotRecovery/RecoveryProcess/RecoveryProcess"; +import { TransactionBundleStep } from "~~/components/flashbotRecovery/TransactionBundleStep/transactionBundleStep"; +import { RecoveryProcessStatus, useRecoveryProcess } from "~~/hooks/flashbotRecoveryBundle/useRecoveryProcess"; +import { RecoveryTx } from "~~/types/business"; const Home: NextPage = () => { - const targetNetwork = getTargetNetwork(); - const { address: connectedAccount } = useAccount(); - - const { data: walletClient } = useWalletClient(); - const publicClient = usePublicClient({ chainId: targetNetwork.id }); - - const { data: feeData } = useFeeData(); - - ////////////////////////////////////////// - //*********** FlashbotProvider - ////////////////////////////////////////// - const FLASHBOTS_RELAY_ENDPOINT = `https://relay${targetNetwork.network == "goerli" ? "-goerli" : ""}.flashbots.net/`; - const [flashbotsProvider, setFlashbotsProvider] = useState(); - - useEffect(() => { - (async () => { - if (!targetNetwork || !targetNetwork.blockExplorers) return; - if (targetNetwork.network == "goerli") { - setFlashbotsProvider( - await FlashbotsBundleProvider.create( - new ethers.providers.InfuraProvider(targetNetwork.id), - flashbotSigner, - FLASHBOTS_RELAY_ENDPOINT, - "goerli", - ), - ); - } else { - setFlashbotsProvider( - await FlashbotsBundleProvider.create( - new ethers.providers.InfuraProvider(targetNetwork.id), - flashbotSigner, - FLASHBOTS_RELAY_ENDPOINT, - ), - ); - } - })(); - }, [targetNetwork.id]); - - ////////////////////////////////////////// - //*********** Hacked and safe accounts - ////////////////////////////////////////// + const { isConnected, address } = useAccount(); const [safeAddress, setSafeAddress] = useLocalStorage("toAddress", ""); const [hackedAddress, setHackedAddress] = useLocalStorage("hackedAddress", ""); - const [accountsInputGiven, setAccountsInputGiven] = useLocalStorage("accountsInputGiven", false); - const displayAddressInput = - accountsInputGiven && ethers.utils.isAddress(safeAddress) && ethers.utils.isAddress(hackedAddress); - - const addressInputDisplay = ( -
-
- -
-
- -
- -
- -
-
- ); - - ////////////////////////////////////////// - //*********** Handling unsigned transactions - ////////////////////////////////////////// const [unsignedTxs, setUnsignedTxs] = useLocalStorage("unsignedTxs", []); - - const isDuplicateTx = (newTx: RecoveryTx): boolean => { - const txsOfSameType = unsignedTxs.filter(tx => tx.type == newTx.type); - - if (newTx.type == "erc20" || newTx.type == "erc1155") { - if (txsOfSameType.some(tx => tx.toSign.to == newTx.toSign.to)) { - return true; - } - const customCalls = unsignedTxs.filter(tx => tx.type == "custom"); - return customCalls.some(tx => tx.toSign.to == newTx.toSign.to); - } else if (newTx.type == "erc721" || newTx.type == "custom") { - if ( - newTx.type == "erc721" && - (txsOfSameType as ERC721Tx[]).some( - tx => tx.toSign.to == newTx.toSign.to && tx.tokenId == (newTx as ERC721Tx).tokenId, - ) - ) { - return true; - } - - const customCalls = unsignedTxs.filter(tx => tx.type == "custom"); - for (let i = 0; i < customCalls.length; i++) { - if (newTx.toSign.to == customCalls[i].toSign.to && newTx.toSign.data == customCalls[i].toSign.data) { - return true; - } - } - } - - return false; - }; - - const addUnsignedTx = (newTx: RecoveryTx): void => { - if (isDuplicateTx(newTx)) { - if (newTx.type == "erc20" || newTx.type == "erc1155") { - alert(`You can have one call to an ${newTx.type} contract. Remove the existing one before adding this.`); - } else if (newTx.type == "erc721") { - alert("You already have a call to this contract with given tokenId. Remove that before adding this one."); - } else { - alert("You already have an identical call. Remove that before adding this one."); - } - - return; - } - - setUnsignedTxs((prev: RecoveryTx[]) => { - const newUnsignedTxArr = [...prev.filter(a => a != undefined && a != null), newTx]; - estimateTotalGasPrice(newUnsignedTxArr).then(setTotalGasEstimate); - return newUnsignedTxArr; - }); - }; - - const removeUnsignedTx = (txId: number, tryEstimation: boolean = true) => { - setUnsignedTxs((prev: RecoveryTx[]) => { - if (txId < 0 || txId > prev.length) { - return prev.filter(a => a); - } - delete prev[txId]; - - const newUnsignedTxArr = prev.filter(a => a); - - if (tryEstimation) { - estimateTotalGasPrice(newUnsignedTxArr).then(setTotalGasEstimate); - } - return newUnsignedTxArr; - }); - }; - - const unsignedTxsDisplay = ( - <> - {unsignedTxs.length == 0 && ( -
- no unsigned tx -
- )} - {unsignedTxs.length > 0 && ( - <> - {unsignedTxs.map( - (tx, idx) => - tx && ( -
-
- removeUnsignedTx(idx)} /> -
- -
- -
-
- ), - )} - - )} - - ); - - ////////////////////////////////////////// - //*********** Gas Estimation - ////////////////////////////////////////// const [totalGasEstimate, setTotalGasEstimate] = useState(BigNumber.from("0")); - - const maxBaseFeeInFuture = async () => { - const blockNumberNow = await publicClient.getBlockNumber(); - const block = await publicClient.getBlock({ blockNumber: blockNumberNow }); - return FlashbotsBundleProvider.getMaxBaseFeeInFutureBlock( - BigNumber.from(block.baseFeePerGas), - BLOCKS_IN_THE_FUTURE[targetNetwork.id], - ); - }; - - const estimateTotalGasPrice = async (txs?: RecoveryTx[]) => { - const tempProvider = new ethers.providers.InfuraProvider(targetNetwork.id, "416f5398fa3d4bb389f18fd3fa5fb58c"); - if (!txs) { - txs = unsignedTxs; - } - - try { - const estimates = await Promise.all( - txs - .filter(a => a) - .map((tx, txId) => { - return tempProvider.estimateGas(tx.toSign).catch(e => { - console.warn( - `Following tx will fail when bundle is submitted, so it's removed from the bundle right now. The contract might be a hacky one, and you can try further manipulation via crafting a custom call.`, - ); - console.warn(tx); - console.warn(e); - removeUnsignedTx(txId, false); - return BigNumber.from("0"); - }); - }), - ); - - return estimates - .reduce((acc: BigNumber, val: BigNumber) => acc.add(val), BigNumber.from("0")) - .mul(await maxBaseFeeInFuture()) - .mul(105) - .div(100); - } catch (e) { - alert( - "Error estimating gas prices. Something can be wrong with one of the transactions. Check the console and remove problematic tx.", - ); - console.error(e); - return BigNumber.from("0"); - } - }; - - const updateTotalGasEstimate = async () => { - if (!flashbotsProvider || !feeData || !feeData.gasPrice || sentTxHash.length > 0) return; - if (unsignedTxs.length == 0) setTotalGasEstimate(BigNumber.from("0")); - setTotalGasEstimate(await estimateTotalGasPrice(unsignedTxs)); - }; - - useInterval(() => { - updateTotalGasEstimate(); - }, 5000); - - const totalGasEstimationDisplay = ( -
- ⛽💸 {ethers.utils.formatEther(totalGasEstimate.toString())} 💸⛽ -
- ); - - ////////////////////////////////////////// - //*********** Handling the current bundle - ////////////////////////////////////////// - - const [gasCovered, setGasCovered] = useLocalStorage("gasCovered", false); + const [isOnBasket, setIsOnBasket] = useState(false); + const [currentBundleId, setCurrentBundleId] = useLocalStorage("bundleUuid", ""); - const [sentTxHash, setSentTxHash] = useLocalStorage("sentTxHash", ""); - const [sentBlock, setSentBlock] = useLocalStorage("sentBlock", 0); - - const sendBundle = async () => { - if (!flashbotsProvider) { - alert("Flashbot provider not available"); - return; - } - try { - const finalBundle = await ( - await fetch( - `https://rpc${targetNetwork.network == "goerli" ? "-goerli" : ""}.flashbots.net/bundle?id=${currentBundleId}`, - ) - ).json(); - if (!finalBundle || !finalBundle.rawTxs) { - alert("Couldn't fetch latest bundle"); - return; - } - - const txs = finalBundle.rawTxs.reverse(); - - try { - setSentTxHash(ethers.utils.keccak256(txs[0])); - setSentBlock(parseInt((await publicClient.getBlockNumber()).toString())); - - const currentUrl = window.location.href.replace("?", ""); - const response = await fetch(currentUrl + `api/relay${targetNetwork.network == "goerli" ? "-goerli" : ""}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - txs, - }), - }); - - alert(await response.json()); - } catch (e) { - console.error(e); - setSentTxHash(""); - setSentBlock(0); - alert("Error submitting bundles. Check console for details."); - } - } catch (e) { - console.error(e); - setSentTxHash(""); - setSentBlock(0); - alert("Error submitting bundles. Check console for details."); - } - }; - - // poll blocks for txHashes of our bundle - useInterval(async () => { - try { - if (!sentTxHash || sentBlock == 0) return; - - const finalTargetBlock = sentBlock + BLOCKS_IN_THE_FUTURE[targetNetwork.id]; - const currentBlock = parseInt((await publicClient.getBlockNumber()).toString()); - const blockDelta = finalTargetBlock - currentBlock; - setBlockCountdown(blockDelta); - - if (!countdownModalOpen) { - setCountdownModalOpen(true); - } - - if (blockDelta < 0) { - setCountdownModalOpen(false); - setTryAgainModalOpen(true); - setSentBlock(0); - setSentTxHash(""); - return; - } - const txReceipt = await publicClient.getTransactionReceipt({ - hash: sentTxHash as `0x${string}`, - }); - if (txReceipt && txReceipt.blockNumber) { - resetState(); - openCustomModal( -
- Bundle successfully mined in block {txReceipt.blockNumber.toString()} -
, - ); - return; - } - console.log("TXs not yet mined"); - } catch (e) {} - }, 5000); - - ////////////////////////////////////////// - //******** Handle signing & account switching - ////////////////////////////////////////// - - // Step1 - const [step1ModalOpen, setStep1ModalOpen] = useLocalStorage("step1ModalOpen", false); - const step1ModalDisplay = step1ModalOpen && ( - { - setStep1ModalOpen(false); - coverGas(); - }} - > -
- Connect the safe account - - Then close the modal to switch to personal Flashbot RPC network -
-
- ); - - // Step2 - const [step2ModalOpen, setStep2ModalOpen] = useLocalStorage("step2ModalOpen", false); - const step2ModalDisplay = step2ModalOpen && ( - { - setStep2ModalOpen(false); - coverGas(); - }} - > -
- - Before moving on, if the safe or hacked accounts have any pending transactions in the wallet, clear activity - data for both of them. - - - We'll add a new personal Flashbot RPC network to your wallet, then sign the funding transaction. - - Connect the safe address {safeAddress} using your wallet and close the modal. -
-
- ); - - // Step3 - const [step3ModalOpen, setStep3ModalOpen] = useLocalStorage("step3ModalOpen", false); - const step3ModalDisplay = step3ModalOpen && ( - { - setStep3ModalOpen(false); - signRecoveryTransactions(); - }} - > -
- Connect your metamask and switch to the hacked account - -
-
- ); - - // Step4 - const [step4ModalOpen, setStep4ModalOpen] = useLocalStorage("step4ModalOpen", false); - const step4ModalDisplay = step4ModalOpen && ( - { - setStep4ModalOpen(false); - signRecoveryTransactions(); - }} - > -
- Please switch to the hacked address {hackedAddress} - And close the modal to sign the transactions -
-
- ); - - const coverGas = async () => { - if (gasCovered) { - alert("you already covered the gas. If you're in a confussy situation, clear cookies and refresh page."); - return; - } - ////////// Enforce switching to the safe address - if (!connectedAccount) { - setStep1ModalOpen(true); - return; - } else if (connectedAccount != safeAddress) { - setStep2ModalOpen(true); - return; - } - - try { - ////////// Create new bundle uuid & add corresponding RPC 'subnetwork' to Metamask - const newBundleUuid = uuid(); - setCurrentBundleId(newBundleUuid); - await addRelayRPC(newBundleUuid); + const { data: processStatus, startRecoveryProcess, signRecoveryTransactions, blockCountdown } = useRecoveryProcess(); - ////////// Cover the envisioned total gas fee from safe account - const totalGas = await estimateTotalGasPrice(unsignedTxs); - await walletClient!.sendTransaction({ - to: hackedAddress as `0x${string}`, - value: BigInt(totalGas.toString()), - }); - - setGasCovered(true); - signRecoveryTransactions(true); - } catch (e) { - alert(`Error while adding a custom RPC and signing the funding transaction with the safe account. Error: ${e}`); - } + const startRecovery = () => { + startRecoveryProcess({ + safeAddress, + modifyBundleId: val => setCurrentBundleId(val), + totalGas: totalGasEstimate, + hackedAddress, + currentBundleId, + transactions: unsignedTxs, + }); }; - - const signRecoveryTransactions = async (surpass: boolean = false) => { - if (!surpass && !gasCovered) { - alert("How did you come here without covering the gas fee first??"); - return; + const getLayoutActiveStep = () => { + if (!!isOnBasket) { + return 2; } - - ////////// Enforce switching to the hacked address - if (!connectedAccount) { - setStep3ModalOpen(true); - return; - } else if (connectedAccount != hackedAddress) { - setStep4ModalOpen(true); - return; + if(processStatus !== RecoveryProcessStatus.initial){ + return 4; } - - ////////// Sign the transactions in the basket one after another - try { - for (const tx of unsignedTxs) { - await walletClient!.sendTransaction(tx.toSign); - } - setGasCovered(false); - sendBundle(); - } catch (e) { - console.error(`FAILED TO SIGN TXS`); - console.error(e); + if (unsignedTxs.length > 0) { + return 3; } - }; - - ////////////////////////////////////////// - //******** Switch to Flashbot RPC Network - ////////////////////////////////////////// - - const addRelayRPC = async (bundleUuid: string) => { - if (!window.ethereum || !window.ethereum.request) { - console.error("MetaMask Ethereum provider is not available"); - return; + if (hackedAddress !== "") { + return 2; } - - try { - await window.ethereum.request({ - method: "wallet_addEthereumChain", - params: [ - { - chainId: `0x${targetNetwork.network == "goerli" ? 5 : 1}`, - chainName: "Flashbot Personal RPC", - nativeCurrency: { - name: "ETH", - symbol: "ETH", - decimals: 18, - }, - rpcUrls: [ - `https://rpc${targetNetwork.network == "goerli" ? "-goerli" : ""}.flashbots.net?bundle=${bundleUuid}`, - ], - blockExplorerUrls: [`https://${targetNetwork.network == "goerli" ? "goerli." : ""}etherscan.io`], - }, - ], - }); - } catch (error) { - console.error("Failed to add custom RPC network to MetaMask:", error); - } - }; - - ////////////////////////////////////////// - //*********** ERC20 recovery - ////////////////////////////////////////// - - const [erc20ContractAddress, setErc20ContractAddress] = useLocalStorage("erc20ContractAddress", ""); - - const addErc20TxToBasket = (contractAddress: string, balance: string) => { - const newErc20tx: ERC20Tx = { - type: "erc20", - info: "changeme", - symbol: "changeme", - amount: balance, - toSign: { - from: hackedAddress as `0x${string}`, - to: contractAddress as `0x${string}`, - data: erc20Interface.encodeFunctionData("transfer", [safeAddress, BigNumber.from(balance)]) as `0x${string}`, - }, - }; - addUnsignedTx(newErc20tx); + + return 1; }; + const activeStep = getLayoutActiveStep(); - let erc20Balance: string = "NO INFO"; - try { - let { data } = useContractRead({ - chainId: getTargetNetwork().id, - functionName: "balanceOf", - address: erc20ContractAddress as `0x${string}`, - abi: ERC20_ABI, - watch: true, - args: [hackedAddress], - }); - if (data) { - erc20Balance = BigNumber.from(data).toString(); - if (erc20Balance == "0") erc20Balance = "NO INFO"; - } - } catch (e) { - // Most probably the contract address is not valid as user is - // still typing, so ignore. - } - - const erc20RecoveryDisplay = ( -
-
- ERC20 -
- - - {erc20Balance != "NO INFO" && ( -
- Hacked account balance: - {erc20Balance} -
- )} - - -
- ); - - ////////////////////////////////////////// - //*********** ERC721 recovery - ////////////////////////////////////////// - - const [erc721ContractAddress, setErc721ContractAddress] = useLocalStorage("erc721ContractAddress", ""); - const [erc721TokenId, setErc721TokenId] = useLocalStorage("erc721TokenId", ""); - - const addErc721TxToBasket = (contractAddress: string, erc721TokenId: string) => { - const newErc721Tx: ERC721Tx = { - type: "erc721", - info: `NFT recovery for tokenId ${erc721TokenId}`, - symbol: "changeme", - tokenId: erc721TokenId, - toSign: { - from: hackedAddress as `0x${string}`, - to: contractAddress as `0x${string}`, - data: erc721Interface.encodeFunctionData("transferFrom", [ - hackedAddress, - safeAddress, - BigNumber.from(erc721TokenId), - ]) as `0x${string}`, - }, - }; - addUnsignedTx(newErc721Tx); - }; - - const erc721RecoveryDisplay = ( -
-
- ERC721 -
- - placeholder={"ERC721 tokenId"} value={erc721TokenId} onChange={setErc721TokenId} /> - -
- ); - - ////////////////////////////////////////// - //*********** ERC1155 recovery - ////////////////////////////////////////// - - const [erc1155ContractAddress, setErc1155ContractAddress] = useLocalStorage("erc1155ContractAddress", ""); - const [erc1155TokenIds, setErc1155TokenIds] = useLocalStorage("erc1155TokenIds", ""); - - const addErc1155TxToBasket = (contractAddress: string, erc1155TokenIds: string[], erc1155TokenBalances: string[]) => { - const newErc1155Tx: ERC1155Tx = { - type: "erc1155", - info: `ERC1155 for tokenIds ${erc1155TokenIds.toString()}`, - uri: "changeme", - tokenIds: erc1155TokenIds, - amounts: erc1155TokenBalances, - toSign: { - from: hackedAddress as `0x${string}`, - to: contractAddress as `0x${string}`, - data: erc1155Interface.encodeFunctionData("safeBatchTransferFrom", [ - hackedAddress, - safeAddress, - erc1155TokenIds, - erc1155TokenBalances, - ethers.constants.HashZero, - ]) as `0x${string}`, - }, - }; - addUnsignedTx(newErc1155Tx); - }; - - const erc1155RecoveryDisplay = ( -
-
- ERC1155 -
- - - placeholder={"Comma-separated token ids"} - value={erc1155TokenIds} - onChange={str => setErc1155TokenIds(str.replace(" ", ""))} - /> - - -
- ); - - ////////////////////////////////////////// - //*********** Handling External Contract - ////////////////////////////////////////// - - const [customContractAddress, setCustomContractAddress] = useLocalStorage("customContractAddress", ""); - const [customFunctionSignature, setCustomFunctionSignature] = useLocalStorage("customFunctionSignature", ""); - const [externalContractDisplay, setExternalContractDisplay] = useState(<>); - - useEffect(() => { - try { - const parsedFunctAbi = parseAbiItem(customFunctionSignature) as AbiFunction; - setExternalContractDisplay( -
- { - setCustomContractAddress(""); - setCustomFunctionSignature(""); - setExternalContractDisplay(<>); - }} - /> -
, - ); - } catch (e) { - setExternalContractDisplay(<>); - } - }, [customFunctionSignature, customContractAddress]); - - ////////////////////////////////////////// - //*********** Custom / Basic View - ////////////////////////////////////////// - const [isBasic, setIsBasic] = useLocalStorage("isBasic", true); - - const basicViewDisplay = ( -
-
- {erc20RecoveryDisplay} -
-
- {erc721RecoveryDisplay} -
-
- {erc1155RecoveryDisplay} -
-
- ); - - const customViewDisplay = ( -
-
- setSafeAddress(newAdd)} + address={address ?? ""} /> -
- -