diff --git a/src/abi/EntryPointV06ABI.json b/src/abi/EntryPointV06ABI.json new file mode 100644 index 0000000..e89504a --- /dev/null +++ b/src/abi/EntryPointV06ABI.json @@ -0,0 +1 @@ +[{"inputs":[{"internalType":"uint256","name":"preOpGas","type":"uint256"},{"internalType":"uint256","name":"paid","type":"uint256"},{"internalType":"uint48","name":"validAfter","type":"uint48"},{"internalType":"uint48","name":"validUntil","type":"uint48"},{"internalType":"bool","name":"targetSuccess","type":"bool"},{"internalType":"bytes","name":"targetResult","type":"bytes"}],"name":"ExecutionResult","type":"error"},{"inputs":[{"internalType":"uint256","name":"opIndex","type":"uint256"},{"internalType":"string","name":"reason","type":"string"}],"name":"FailedOp","type":"error"},{"inputs":[{"internalType":"address","name":"sender","type":"address"}],"name":"SenderAddressResult","type":"error"},{"inputs":[{"internalType":"address","name":"aggregator","type":"address"}],"name":"SignatureValidationFailed","type":"error"},{"inputs":[{"components":[{"internalType":"uint256","name":"preOpGas","type":"uint256"},{"internalType":"uint256","name":"prefund","type":"uint256"},{"internalType":"bool","name":"sigFailed","type":"bool"},{"internalType":"uint48","name":"validAfter","type":"uint48"},{"internalType":"uint48","name":"validUntil","type":"uint48"},{"internalType":"bytes","name":"paymasterContext","type":"bytes"}],"internalType":"struct IEntryPoint.ReturnInfo","name":"returnInfo","type":"tuple"},{"components":[{"internalType":"uint256","name":"stake","type":"uint256"},{"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"internalType":"struct IStakeManager.StakeInfo","name":"senderInfo","type":"tuple"},{"components":[{"internalType":"uint256","name":"stake","type":"uint256"},{"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"internalType":"struct IStakeManager.StakeInfo","name":"factoryInfo","type":"tuple"},{"components":[{"internalType":"uint256","name":"stake","type":"uint256"},{"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"internalType":"struct IStakeManager.StakeInfo","name":"paymasterInfo","type":"tuple"}],"name":"ValidationResult","type":"error"},{"inputs":[{"components":[{"internalType":"uint256","name":"preOpGas","type":"uint256"},{"internalType":"uint256","name":"prefund","type":"uint256"},{"internalType":"bool","name":"sigFailed","type":"bool"},{"internalType":"uint48","name":"validAfter","type":"uint48"},{"internalType":"uint48","name":"validUntil","type":"uint48"},{"internalType":"bytes","name":"paymasterContext","type":"bytes"}],"internalType":"struct IEntryPoint.ReturnInfo","name":"returnInfo","type":"tuple"},{"components":[{"internalType":"uint256","name":"stake","type":"uint256"},{"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"internalType":"struct IStakeManager.StakeInfo","name":"senderInfo","type":"tuple"},{"components":[{"internalType":"uint256","name":"stake","type":"uint256"},{"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"internalType":"struct IStakeManager.StakeInfo","name":"factoryInfo","type":"tuple"},{"components":[{"internalType":"uint256","name":"stake","type":"uint256"},{"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"internalType":"struct IStakeManager.StakeInfo","name":"paymasterInfo","type":"tuple"},{"components":[{"internalType":"address","name":"aggregator","type":"address"},{"components":[{"internalType":"uint256","name":"stake","type":"uint256"},{"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"internalType":"struct IStakeManager.StakeInfo","name":"stakeInfo","type":"tuple"}],"internalType":"struct IEntryPoint.AggregatorStakeInfo","name":"aggregatorInfo","type":"tuple"}],"name":"ValidationResultWithAggregation","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"userOpHash","type":"bytes32"},{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"address","name":"factory","type":"address"},{"indexed":false,"internalType":"address","name":"paymaster","type":"address"}],"name":"AccountDeployed","type":"event"},{"anonymous":false,"inputs":[],"name":"BeforeExecution","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint256","name":"totalDeposit","type":"uint256"}],"name":"Deposited","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"aggregator","type":"address"}],"name":"SignatureAggregatorChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint256","name":"totalStaked","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"unstakeDelaySec","type":"uint256"}],"name":"StakeLocked","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"uint256","name":"withdrawTime","type":"uint256"}],"name":"StakeUnlocked","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"address","name":"withdrawAddress","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"StakeWithdrawn","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"userOpHash","type":"bytes32"},{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":true,"internalType":"address","name":"paymaster","type":"address"},{"indexed":false,"internalType":"uint256","name":"nonce","type":"uint256"},{"indexed":false,"internalType":"bool","name":"success","type":"bool"},{"indexed":false,"internalType":"uint256","name":"actualGasCost","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"actualGasUsed","type":"uint256"}],"name":"UserOperationEvent","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"userOpHash","type":"bytes32"},{"indexed":true,"internalType":"address","name":"sender","type":"address"},{"indexed":false,"internalType":"uint256","name":"nonce","type":"uint256"},{"indexed":false,"internalType":"bytes","name":"revertReason","type":"bytes"}],"name":"UserOperationRevertReason","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":false,"internalType":"address","name":"withdrawAddress","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Withdrawn","type":"event"},{"inputs":[],"name":"SIG_VALIDATION_FAILED","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"initCode","type":"bytes"},{"internalType":"address","name":"sender","type":"address"},{"internalType":"bytes","name":"paymasterAndData","type":"bytes"}],"name":"_validateSenderAndPaymaster","outputs":[],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint32","name":"unstakeDelaySec","type":"uint32"}],"name":"addStake","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"depositTo","outputs":[],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"deposits","outputs":[{"internalType":"uint112","name":"deposit","type":"uint112"},{"internalType":"bool","name":"staked","type":"bool"},{"internalType":"uint112","name":"stake","type":"uint112"},{"internalType":"uint32","name":"unstakeDelaySec","type":"uint32"},{"internalType":"uint48","name":"withdrawTime","type":"uint48"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"getDepositInfo","outputs":[{"components":[{"internalType":"uint112","name":"deposit","type":"uint112"},{"internalType":"bool","name":"staked","type":"bool"},{"internalType":"uint112","name":"stake","type":"uint112"},{"internalType":"uint32","name":"unstakeDelaySec","type":"uint32"},{"internalType":"uint48","name":"withdrawTime","type":"uint48"}],"internalType":"struct IStakeManager.DepositInfo","name":"info","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"uint192","name":"key","type":"uint192"}],"name":"getNonce","outputs":[{"internalType":"uint256","name":"nonce","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"initCode","type":"bytes"}],"name":"getSenderAddress","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"bytes","name":"initCode","type":"bytes"},{"internalType":"bytes","name":"callData","type":"bytes"},{"internalType":"uint256","name":"callGasLimit","type":"uint256"},{"internalType":"uint256","name":"verificationGasLimit","type":"uint256"},{"internalType":"uint256","name":"preVerificationGas","type":"uint256"},{"internalType":"uint256","name":"maxFeePerGas","type":"uint256"},{"internalType":"uint256","name":"maxPriorityFeePerGas","type":"uint256"},{"internalType":"bytes","name":"paymasterAndData","type":"bytes"},{"internalType":"bytes","name":"signature","type":"bytes"}],"internalType":"struct UserOperation","name":"userOp","type":"tuple"}],"name":"getUserOpHash","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"components":[{"components":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"bytes","name":"initCode","type":"bytes"},{"internalType":"bytes","name":"callData","type":"bytes"},{"internalType":"uint256","name":"callGasLimit","type":"uint256"},{"internalType":"uint256","name":"verificationGasLimit","type":"uint256"},{"internalType":"uint256","name":"preVerificationGas","type":"uint256"},{"internalType":"uint256","name":"maxFeePerGas","type":"uint256"},{"internalType":"uint256","name":"maxPriorityFeePerGas","type":"uint256"},{"internalType":"bytes","name":"paymasterAndData","type":"bytes"},{"internalType":"bytes","name":"signature","type":"bytes"}],"internalType":"struct UserOperation[]","name":"userOps","type":"tuple[]"},{"internalType":"contract IAggregator","name":"aggregator","type":"address"},{"internalType":"bytes","name":"signature","type":"bytes"}],"internalType":"struct IEntryPoint.UserOpsPerAggregator[]","name":"opsPerAggregator","type":"tuple[]"},{"internalType":"address payable","name":"beneficiary","type":"address"}],"name":"handleAggregatedOps","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"bytes","name":"initCode","type":"bytes"},{"internalType":"bytes","name":"callData","type":"bytes"},{"internalType":"uint256","name":"callGasLimit","type":"uint256"},{"internalType":"uint256","name":"verificationGasLimit","type":"uint256"},{"internalType":"uint256","name":"preVerificationGas","type":"uint256"},{"internalType":"uint256","name":"maxFeePerGas","type":"uint256"},{"internalType":"uint256","name":"maxPriorityFeePerGas","type":"uint256"},{"internalType":"bytes","name":"paymasterAndData","type":"bytes"},{"internalType":"bytes","name":"signature","type":"bytes"}],"internalType":"struct UserOperation[]","name":"ops","type":"tuple[]"},{"internalType":"address payable","name":"beneficiary","type":"address"}],"name":"handleOps","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"uint192","name":"key","type":"uint192"}],"name":"incrementNonce","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes","name":"callData","type":"bytes"},{"components":[{"components":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"uint256","name":"callGasLimit","type":"uint256"},{"internalType":"uint256","name":"verificationGasLimit","type":"uint256"},{"internalType":"uint256","name":"preVerificationGas","type":"uint256"},{"internalType":"address","name":"paymaster","type":"address"},{"internalType":"uint256","name":"maxFeePerGas","type":"uint256"},{"internalType":"uint256","name":"maxPriorityFeePerGas","type":"uint256"}],"internalType":"struct EntryPoint.MemoryUserOp","name":"mUserOp","type":"tuple"},{"internalType":"bytes32","name":"userOpHash","type":"bytes32"},{"internalType":"uint256","name":"prefund","type":"uint256"},{"internalType":"uint256","name":"contextOffset","type":"uint256"},{"internalType":"uint256","name":"preOpGas","type":"uint256"}],"internalType":"struct EntryPoint.UserOpInfo","name":"opInfo","type":"tuple"},{"internalType":"bytes","name":"context","type":"bytes"}],"name":"innerHandleOp","outputs":[{"internalType":"uint256","name":"actualGasCost","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"uint192","name":"","type":"uint192"}],"name":"nonceSequenceNumber","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"bytes","name":"initCode","type":"bytes"},{"internalType":"bytes","name":"callData","type":"bytes"},{"internalType":"uint256","name":"callGasLimit","type":"uint256"},{"internalType":"uint256","name":"verificationGasLimit","type":"uint256"},{"internalType":"uint256","name":"preVerificationGas","type":"uint256"},{"internalType":"uint256","name":"maxFeePerGas","type":"uint256"},{"internalType":"uint256","name":"maxPriorityFeePerGas","type":"uint256"},{"internalType":"bytes","name":"paymasterAndData","type":"bytes"},{"internalType":"bytes","name":"signature","type":"bytes"}],"internalType":"struct UserOperation","name":"op","type":"tuple"},{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"targetCallData","type":"bytes"}],"name":"simulateHandleOp","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"uint256","name":"nonce","type":"uint256"},{"internalType":"bytes","name":"initCode","type":"bytes"},{"internalType":"bytes","name":"callData","type":"bytes"},{"internalType":"uint256","name":"callGasLimit","type":"uint256"},{"internalType":"uint256","name":"verificationGasLimit","type":"uint256"},{"internalType":"uint256","name":"preVerificationGas","type":"uint256"},{"internalType":"uint256","name":"maxFeePerGas","type":"uint256"},{"internalType":"uint256","name":"maxPriorityFeePerGas","type":"uint256"},{"internalType":"bytes","name":"paymasterAndData","type":"bytes"},{"internalType":"bytes","name":"signature","type":"bytes"}],"internalType":"struct UserOperation","name":"userOp","type":"tuple"}],"name":"simulateValidation","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"unlockStake","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address payable","name":"withdrawAddress","type":"address"}],"name":"withdrawStake","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address payable","name":"withdrawAddress","type":"address"},{"internalType":"uint256","name":"withdrawAmount","type":"uint256"}],"name":"withdrawTo","outputs":[],"stateMutability":"nonpayable","type":"function"},{"stateMutability":"payable","type":"receive"}] \ No newline at end of file diff --git a/src/components/AccountTokensBanner.tsx b/src/components/AccountTokensBanner.tsx index 8193636..0e44229 100644 --- a/src/components/AccountTokensBanner.tsx +++ b/src/components/AccountTokensBanner.tsx @@ -80,14 +80,9 @@ const AccountTokensBanner = (props: IAccountTokensBanner & PropsFromRedux) => { let [searchParams, setSearchParams] = useSearchParams(); - const [ownedTokenBalances, setOwnedTokenBalances] = useState([]); - const [ownedTokenAssets, setOwnedTokenAssets] = useState({}); const [page, setPage] = useState(Number(searchParams.get("page")) || 1); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [perPage, setPerPage] = useState(20); - const [paginationTotalPages, setPaginationTotalPages] = useState(0); - // const [paginationTotalRecords, setPaginationTotalRecords] = useState(0); - const [isLoading, setIsLoading] = useState(true); + + const perPage = 20; const classes = useStyles(); @@ -157,17 +152,18 @@ const AccountTokensBanner = (props: IAccountTokensBanner & PropsFromRedux) => { staleTime: 60, // Data is always considered fresh }); - useEffect(() => { - let isMounted = true; - const fetchMixedTokens = async () => { - // let apiResponse = await fetch(`${API_ENDPOINT}/balances/${account}`).then(resp => resp.json()); + const { + data: accountTokensDataTanstack, + isLoading: isLoadingAccountTokensDataTanstack, + } = useQuery({ + queryKey: ['account-tokens-banner', account, page, perPage], + queryFn: async () => { let apiResponse = await AccountBalanceService.getAccountBalancesPaginated( account, perPage, page, ) - setIsLoading(false); - if(apiResponse?.status && apiResponse?.data?.data && isMounted) { + if(apiResponse?.status && apiResponse?.data?.data) { let renderResults: IBalanceRecord[] = []; let assetResults : IAllTokenAssets = {}; let apiResponseData : IOwnedBalancesResult = apiResponse.data; @@ -181,52 +177,41 @@ const AccountTokensBanner = (props: IAccountTokensBanner & PropsFromRedux) => { } } } - // commenting this out while we transition to current balances from chain - // if(apiResponseData?.data?.['ERC-20']) { - // for(let [assetAddress, assetRecord] of Object.entries(apiResponseData?.data?.['ERC-20'])) { - // if(assetRecord?.balances) { - // renderResults = [...renderResults, ...assetRecord.balances]; - // } - // if(assetRecord?.asset) { - // assetResults[assetAddress] = assetRecord?.asset; - // } - // } - // } - if(apiResponseData?.metadata?.pagination?.totalPages) { - setPaginationTotalPages(apiResponseData?.metadata?.pagination?.totalPages); - } else { - setPaginationTotalPages(0); + return { + ownedTokenBalances: renderResults, + ownedTokenAssets: assetResults, + pagination: { + totalPages: apiResponseData?.metadata?.pagination?.totalPages ? apiResponseData?.metadata?.pagination?.totalPages : 0, + totalRecords: apiResponseData?.metadata?.pagination?.total ? apiResponseData?.metadata?.pagination?.total : 0, + } } - // if(apiResponseData?.metadata?.pagination?.total) { - // setPaginationTotalRecords(apiResponseData?.metadata?.pagination?.total); - // } else { - // setPaginationTotalRecords(0); - // } - setOwnedTokenBalances(renderResults); - setOwnedTokenAssets(assetResults); - } else { - setOwnedTokenBalances([]); - setOwnedTokenAssets({}); - setPaginationTotalPages(0); - // setPaginationTotalRecords(0); } - if(page > 1) { - setSearchParams((params => { - params.set("page", page.toString()); - return params; - })); - } else if(searchParams.get("page")) { - setSearchParams((params => { - params.delete("page"); - return params; - })); + return { + ownedTokenBalances: [], + ownedTokenAssets: {}, + pagination: { + totalPages: 0, + totalRecords: 0, + } } + }, + gcTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + }); + + useEffect(() => { + if(page > 1) { + setSearchParams((params => { + params.set("page", page.toString()); + return params; + })); + } else if(searchParams.get("page")) { + setSearchParams((params => { + params.delete("page"); + return params; + })); } - fetchMixedTokens(); - return () => { - isMounted = false; - } - }, [account, perPage, page, setSearchParams, searchParams]) + }, [page, searchParams, setSearchParams]); return ( <> @@ -275,17 +260,17 @@ const AccountTokensBanner = (props: IAccountTokensBanner & PropsFromRedux) => { /> } - {!isLoading && ownedTokenBalances && ownedTokenBalances.sort((a, b) => { + {!isLoadingAccountTokensDataTanstack && accountTokensDataTanstack?.ownedTokenBalances && accountTokensDataTanstack?.ownedTokenBalances.sort((a, b) => { if(a?.asset?.standard && b?.asset?.standard) { return (a.asset.standard).localeCompare(b.asset.standard); } return 0; - }).slice(0,maxRecords ? maxRecords : ownedTokenBalances.length).map((item, index) => + }).slice(0,maxRecords ? maxRecords : accountTokensDataTanstack?.ownedTokenBalances.length).map((item, index) => - + )} - {isLoading && maxRecords && + {isLoadingAccountTokensDataTanstack && maxRecords && Array.from({length: maxRecords}).map((entry, index) => @@ -293,10 +278,10 @@ const AccountTokensBanner = (props: IAccountTokensBanner & PropsFromRedux) => { ) } - {paginationTotalPages > 1 && showPagination && + {accountTokensDataTanstack?.pagination?.totalPages && accountTokensDataTanstack?.pagination?.totalPages > 1 && showPagination && <>
- setPage(page)} count={paginationTotalPages} variant="outlined" color="primary" /> + setPage(page)} count={accountTokensDataTanstack?.pagination?.totalPages} variant="outlined" color="primary" />
{/* { paginationTotalRecords && diff --git a/src/components/AllTokensBanner.tsx b/src/components/AllTokensBanner.tsx deleted file mode 100644 index 48fc31a..0000000 --- a/src/components/AllTokensBanner.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React, { useEffect, useState } from 'react' - -import { useAccount } from 'wagmi'; - -import { Theme } from '@mui/material/styles'; - -import makeStyles from '@mui/styles/makeStyles'; -import createStyles from '@mui/styles/createStyles'; - -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; -import Button from '@mui/material/Button'; - -import SingleTokenCard from './SingleTokenCard'; -import LinkWrapper from './LinkWrapper'; - -import { - IBalanceRecord, - IMixedBalancesResult, - IAssetRecord, -} from '../interfaces'; - -import { - API_ENDPOINT, -} from '../utils/constants'; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - titleContainer: { - marginBottom: theme.spacing(2), - marginTop: theme.spacing(2), - display: 'flex', - justifyContent: 'space-between', - }, - title: { - fontWeight: '500', - // color: 'white', - } - }), -); - -interface IAllTokensBanner { - maxRecords?: number - showTitle?: boolean -} - -interface IAllTokenAssets { - [key: string]: IAssetRecord -} - -const AllTokensBanner = (props: IAllTokensBanner) => { - - const [allTokenBalances, setAllTokenBalances] = useState([]); - const [allTokenAssets, setAllTokenAssets] = useState({}); - - const { address } = useAccount(); - - const classes = useStyles(); - - let { - maxRecords, - showTitle = false, - } = props; - - useEffect(() => { - let isMounted = true; - const fetchMixedTokens = async () => { - let apiResponse = await fetch(`${API_ENDPOINT}/balances/mix`).then(resp => resp.json()); - if(apiResponse?.status && apiResponse?.data && isMounted) { - let renderResults: IBalanceRecord[] = []; - let assetResults : IAllTokenAssets = {}; - let apiResponseData : IMixedBalancesResult = apiResponse.data; - if(apiResponseData?.['ERC-721']) { - for(let [assetAddress, assetRecord] of Object.entries(apiResponseData?.['ERC-721'])) { - if(assetRecord?.balances) { - renderResults = [...renderResults, ...assetRecord.balances]; - } - if(assetRecord?.asset) { - assetResults[assetAddress] = assetRecord?.asset; - } - } - } - console.log({renderResults}) - setAllTokenBalances(renderResults); - setAllTokenAssets(assetResults); - } else { - setAllTokenBalances([]); - setAllTokenAssets({}); - } - } - fetchMixedTokens(); - return () => { - isMounted = false; - } - }, [address]) - - return ( - <> - {showTitle && -
- - Propy Assets - - - - -
- } - - {allTokenBalances && allTokenBalances.sort((a, b) => { - if(allTokenAssets[a?.asset_address]?.standard && allTokenAssets[b?.asset_address]?.standard) { - return (allTokenAssets[a?.asset_address]?.standard).localeCompare(allTokenAssets[b?.asset_address]?.standard); - } - return 0; - }).slice(0,maxRecords ? maxRecords : allTokenBalances.length).map((item, index) => - - - - )} - - - ) -} - -export default AllTokensBanner; \ No newline at end of file diff --git a/src/components/BridgeFinalizeWithdrawalFormUnified.tsx b/src/components/BridgeFinalizeWithdrawalFormUnified.tsx new file mode 100644 index 0000000..048cbc9 --- /dev/null +++ b/src/components/BridgeFinalizeWithdrawalFormUnified.tsx @@ -0,0 +1,395 @@ +import React, { useState, useCallback, useEffect } from 'react'; + +import { animated, useSpring } from '@react-spring/web'; + +import { utils } from "ethers"; + +import dayjs from 'dayjs'; + +import BigNumber from 'bignumber.js'; + +import { toast } from 'sonner'; + +import EastIcon from '@mui/icons-material/East'; + +import { Theme } from '@mui/material/styles'; + +import makeStyles from '@mui/styles/makeStyles'; +import createStyles from '@mui/styles/createStyles'; + +import Card from '@mui/material/Card'; +import Typography from '@mui/material/Typography'; + +import CircularProgress from '@mui/material/CircularProgress'; + +import { PropsFromRedux } from '../containers/BridgeFinalizeWithdrawalFormContainer'; + +import EthLogo from '../assets/img/ethereum-web3-modal.png'; +import BaseLogo from '../assets/img/base-logo-transparent-bg.png'; + +import FloatingActionButton from './FloatingActionButton'; + +import { useUnifiedWriteContract } from '../hooks/useUnifiedWriteContract'; + +import { + SupportedNetworks, + NetworkName, +} from '../interfaces'; + +import LinkWrapper from './LinkWrapper'; + +import { + priceFormat, + getEtherscanLinkByNetworkName, +} from '../utils'; + +import { + NETWORK_NAME_TO_DISPLAY_NAME, + NETWORK_NAME_TO_ID, + OPTIMISM_PORTAL_ADDRESS, + BASE_BRIDGE_L1_NETWORK, + BASE_BRIDGE_L2_NETWORK, + PROPY_LIGHT_BLUE, +} from '../utils/constants'; + +import { + BridgeService, +} from '../services/api'; + +import { + IBaseWithdrawalInitiatedEvent, +} from '../interfaces'; + +import { usePrepareFinalizeWithdrawal } from '../hooks/usePrepareFinalizeWithdrawal'; + +BigNumber.config({ EXPONENTIAL_AT: [-1e+9, 1e+9] }); + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + display: 'flex', + justifyContent: 'center', + }, + title: { + fontWeight: '500', + }, + cardTitle: { + + }, + cardTitleNetworks: { + + }, + cardSubtitle: { + marginBottom: theme.spacing(3), + }, + subtitle: { + // marginBottom: theme.spacing(2), + }, + innerSpacingBottom: { + marginBottom: theme.spacing(2), + }, + innerSpacingTop: { + marginTop: theme.spacing(2), + }, + sectionSpacer: { + marginBottom: theme.spacing(4), + }, + bridgeIconContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + card: { + width: '100%', + display: 'flex', + justifyContent: 'center', + maxWidth: '420px', + }, + cardInner: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + margin: theme.spacing(4), + width: '100%', + }, + bridgeIllustration: { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: theme.spacing(2), + }, + singleBridgeIcon: { + fontSize: '30px', + }, + networkLogo: { + borderRadius: '50%', + height: 75, + }, + networkLogoRight: { + marginLeft: theme.spacing(2), + }, + networkLogoLeft: { + marginRight: theme.spacing(2), + }, + submitButtonContainer: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), + width: '100%', + display: 'flex', + justifyContent: 'flex-end', + }, + submitButton: { + width: '100%', + }, + }), +); + +const getNetworkIcon = (network: SupportedNetworks) => { + if(['ethereum', 'sepolia', 'goerli'].indexOf(network) > -1) { + return EthLogo; + } + if(['base', 'base-sepolia', 'base-goerli'].indexOf(network) > -1) { + return BaseLogo; + } +} + +const getBridgeFinalizedWithdrawalButtonText = ( + isAwaitingWalletInteraction: boolean, + isAwaitingFinalizeTx: boolean, + isAwaitingValidPreparation: boolean, + isWithdrawalAlreadyFinalized: boolean, + showSuccessMessage: boolean, + isFinalizationPeriodNotElapsed: boolean, +) => { + + if(isFinalizationPeriodNotElapsed) { + return "Finalization Period Pending..." + } + + if(isAwaitingValidPreparation) { + return "Awaiting Finalization Opportunity..." + } + + if(isWithdrawalAlreadyFinalized || showSuccessMessage) { + return "Withdrawal Finalized"; + } + + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingFinalizeTx) { + return "Finalizing Withdrawal..."; + } + + return "Finalize Withdrawal"; + +} + +// const getTransitTime = (origin: string, destination: string) => { +// if(['ethereum', 'sepolia', 'goerli'].indexOf(origin) > -1 && ['base', 'base-sepolia', 'base-goerli'].indexOf(destination)) { +// return `~ 20 minutes`; +// } +// if(['base', 'base-sepolia', 'base-goerli'].indexOf(origin) > -1 && ['ethereum', 'sepolia', 'base-goerli'].indexOf(destination)) { +// return `~ 1 week`; +// } +// } + +interface IBridgeFinalizeWithdrawalForm { + origin: SupportedNetworks + destination: SupportedNetworks + transactionHash: `0x${string}` + postBridgeSuccess?: () => void +} + +const BridgeFinalizeWithdrawalFormUnified = (props: PropsFromRedux & IBridgeFinalizeWithdrawalForm) => { + + let { + origin, + destination, + transactionHash, + postBridgeSuccess, + } = props; + + const classes = useStyles(); + + const [transactionData, setTransactionData] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isAwaitingValidPreparation, setIsAwaitingValidPreparation] = useState(false); + const [isWithdrawalAlreadyFinalized, setIsWithdrawalAlreadyFinalized] = useState(false); + const [isFinalizationPeriodNotElapsed, setIsFinalizationPeriodNotElapsed] = useState(false); + const [showSuccessMessage, setShowSuccessMessage] = useState(false); + const [forceUpdateCounter, setForceUpdateCounter] = useState(0); + + useEffect(() => { + // Using ReturnType to infer the type returned by setInterval + let intervalId: ReturnType; + + if (isFinalizationPeriodNotElapsed) { + intervalId = setInterval(() => { + // This will trigger a rerender every 10 seconds + setForceUpdateCounter(prevCount => prevCount + 1); + }, 15000); + } + + return () => { + if (intervalId) { + clearInterval(intervalId); + } + }; + }, [isFinalizationPeriodNotElapsed]); + + // refactor into react-query + useEffect(() => { + let isMounted = true; + const fetchTransactionData = async () => { + if(transactionHash) { + setIsLoading(true); + let fetchedData = await BridgeService.getBaseBridgeTransactionOverviewByTransactionHash(BASE_BRIDGE_L1_NETWORK, BASE_BRIDGE_L2_NETWORK, transactionHash); + if(isMounted && fetchedData.data) { + setTransactionData(fetchedData.data); + setIsLoading(false); + } + } + } + fetchTransactionData(); + return () => { + isMounted = false; + } + }, [transactionHash]) + + // L2 -> L1 FINALIZE WITHDRAWAL METHODS BELOW + + const finalizeWithdrawalConfig = usePrepareFinalizeWithdrawal( + transactionHash, + NETWORK_NAME_TO_ID[origin].toString(), + NETWORK_NAME_TO_ID[destination].toString(), + OPTIMISM_PORTAL_ADDRESS, + (isPrepError: boolean) => setIsAwaitingValidPreparation(isPrepError), + (isAlreadyFinalized: boolean) => setIsWithdrawalAlreadyFinalized(isAlreadyFinalized), + (finalizationPeriodNotElapsed: boolean) => setIsFinalizationPeriodNotElapsed(finalizationPeriodNotElapsed), + forceUpdateCounter, + ); + + const { + executeTransaction: executeFinalizeWithdrawalTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionFinalizeWithdrawalTx, + isAwaitingTx: isAwaitingFinalizeWithdrawalTx, + isLoading: isLoadingFinalizeWithdrawalTx, + } = useUnifiedWriteContract({ + contractConfig: finalizeWithdrawalConfig, + onSuccess: () => { + setShowSuccessMessage(true); + const refreshBridge = async () => { + await BridgeService.triggerBaseBridgeOptimisticSync(BASE_BRIDGE_L1_NETWORK, BASE_BRIDGE_L2_NETWORK); + if(postBridgeSuccess) { + postBridgeSuccess(); + } + } + refreshBridge(); + }, + successToastMessage: "Withdrawal finalized! Tokens have been withdrawn to L1.", + fallbackErrorMessage: "Unable to submit finalization, please try again or contact support.", + }); + + const handleFinalizeWithdrawal = useCallback(() => { + void (async () => { + try { + if(finalizeWithdrawalConfig) { + await executeFinalizeWithdrawalTx(); + } else { + toast.error(`Unable to submit finalization, please refresh the page and try again, or contact support if the problem persists.`); + } + } catch(e) { + console.error({e}); + // onCloseFinalizeWithdrawalModal(); + } + })(); + }, [ + executeFinalizeWithdrawalTx, + finalizeWithdrawalConfig, + ]); + + // L2 -> L1 FINALIZE WITHDRAWAL METHODS ABOVE + + const originSpring = useSpring({ + from: { + transform: 'translateX(25%)', + }, + to: { + transform: 'translateX(0%)', + }, + }) + + const destinationSpring = useSpring({ + from: { + transform: 'translateX(-25%)', + }, + to: { + transform: 'translateX(0%)', + }, + }) + + const arrowSpring = useSpring({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + delay: 150, + }) + + return ( +
+ + {transactionData && !isLoading && +
+
+ + + + + +
+ {NETWORK_NAME_TO_DISPLAY_NAME[origin]} to {NETWORK_NAME_TO_DISPLAY_NAME[destination]} + Finalize withdrawal of {priceFormat(Number(utils.formatUnits(transactionData.amount, 8)), 2, 'PRO')} to L1 + + {isAwaitingValidPreparation && + <> + Waiting for your withdrawal proof to make it through the challenge period, this will take around ~ 1 week from the time of submitting your withdrawal proof. + {transactionData?.withdrawal_proven_event?.evm_transaction?.block_timestamp && + Detected withdrawal proof at {dayjs.unix(Number(transactionData?.withdrawal_proven_event?.evm_transaction?.block_timestamp)).format('hh:mm A MMM-D-YYYY')}, therefore withdrawal finalization should be possible at approximately {dayjs.unix(Number(transactionData?.withdrawal_proven_event?.evm_transaction?.block_timestamp)).add(7, 'day').format('hh:mm A MMM-D-YYYY')}. + } + + } + {(isWithdrawalAlreadyFinalized || showSuccessMessage) && Withdrawal finalized! Tokens have been withdrawn to L1.} +
+ } + {isLoading && +
+ + Loading transaction... +
+ } + {!isLoading && !transactionData && +
+ Transaction not found +
+ } +
+
+ ); +} + +export default BridgeFinalizeWithdrawalFormUnified; \ No newline at end of file diff --git a/src/components/BridgeFormUnified.tsx b/src/components/BridgeFormUnified.tsx new file mode 100644 index 0000000..bea512c --- /dev/null +++ b/src/components/BridgeFormUnified.tsx @@ -0,0 +1,620 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; + +import { animated, useSpring } from '@react-spring/web'; + +import { utils } from 'ethers'; + +import BigNumber from 'bignumber.js'; + +import * as yup from 'yup'; + +import EastIcon from '@mui/icons-material/East'; + +import { Theme } from '@mui/material/styles'; + +import makeStyles from '@mui/styles/makeStyles'; +import createStyles from '@mui/styles/createStyles'; + +import Card from '@mui/material/Card'; +import Typography from '@mui/material/Typography'; +import Grid from '@mui/material/Grid'; + +import { useAccount, useBalance, useReadContract, useBlockNumber } from 'wagmi'; + +import { Formik, Form, Field, FormikProps } from 'formik'; +import { TextField } from 'formik-mui'; + +import { PropsFromRedux } from '../containers/BridgeFormContainer'; + +import EthLogo from '../assets/img/ethereum-web3-modal.png'; +import BaseLogo from '../assets/img/base-logo-transparent-bg.png'; + +import FloatingActionButton from './FloatingActionButton'; + +import { SupportedNetworks } from '../interfaces'; + +import { + priceFormat, +} from '../utils'; + +import { + NETWORK_NAME_TO_DISPLAY_NAME, + PROPY_LIGHT_BLUE, + BASE_BRIDGE_L1_NETWORK, + BASE_BRIDGE_L2_NETWORK, +} from '../utils/constants'; + +import ERC20ABI from '../abi/ERC20ABI.json'; +import L1StandardBridgeABI from '../abi/L1StandardBridgeABI.json'; +import L2StandardBridgeABI from '../abi/L2StandardBridgeABI.json'; + +import { useUnifiedWriteContract } from '../hooks/useUnifiedWriteContract'; + +import { + BridgeService, +} from '../services/api'; + +BigNumber.config({ EXPONENTIAL_AT: [-1e+9, 1e+9] }); + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + display: 'flex', + justifyContent: 'center', + }, + title: { + fontWeight: '500', + }, + cardTitle: { + + }, + cardTitleNetworks: { + + }, + cardSubtitle: { + marginBottom: theme.spacing(3), + }, + subtitle: { + // marginBottom: theme.spacing(2), + }, + innerSpacing: { + marginBottom: theme.spacing(2), + }, + sectionSpacer: { + marginBottom: theme.spacing(4), + }, + bridgeIconContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + card: { + width: '100%', + display: 'flex', + justifyContent: 'center', + maxWidth: '420px', + }, + cardInner: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + margin: theme.spacing(4), + width: '100%', + }, + bridgeIllustration: { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: theme.spacing(2), + }, + singleBridgeIcon: { + fontSize: '30px', + }, + networkLogo: { + borderRadius: '50%', + height: 75, + }, + networkLogoRight: { + marginLeft: theme.spacing(2), + }, + networkLogoLeft: { + marginRight: theme.spacing(2), + }, + submitButtonContainer: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), + width: '100%', + display: 'flex', + justifyContent: 'flex-end', + }, + submitButton: { + width: '100%', + }, + innerSpacingTop: { + marginTop: theme.spacing(2), + }, + }), +); + +const getNetworkIcon = (network: SupportedNetworks) => { + if(['ethereum', 'sepolia', 'goerli'].indexOf(network) > -1) { + return EthLogo; + } + if(['base', 'base-sepolia', 'base-goerli'].indexOf(network) > -1) { + return BaseLogo; + } +} + +interface IHelperTextMaxSelection { + balance: string | number; + setFieldValue: any + origin?: SupportedNetworks + rawBalance?: number | bigint; + destinationAssetDecimals?: number; + originAssetDecimals?: number; +} + +const HelperTextTop = (props: IHelperTextMaxSelection) => { + const { + balance, + setFieldValue, + origin, + } = props; + return ( +
+ setFieldValue('proAmount', balance)}> + Balance: {priceFormat(balance, 2, origin && ['base','base-sepolia','base-goerli'].indexOf(origin) > -1 ? 'PRO (Base)' : 'PRO')} + +
+ ) +} + +const HelperTextMaxSelection = (props: IHelperTextMaxSelection) => { + const { + balance, + setFieldValue, + // destinationAssetDecimals, + // originAssetDecimals, + } = props; + return ( +
+ + setFieldValue('proAmount', new BigNumber(balance).multipliedBy(0.25).toString())}>25% +    + setFieldValue('proAmount', new BigNumber(balance).multipliedBy(0.5).toString())}>50% +    + setFieldValue('proAmount', new BigNumber(balance).multipliedBy(0.75).toString())}>75% +    + setFieldValue('proAmount', balance)}>100% + +
+ ) +} + +const isSufficientAllowance = (allowance: number, amount: string, amountDecimals: number) => { + console.log({isSufficientAllowance: allowance, amount, amountDecimals}) + return new BigNumber(allowance).isGreaterThanOrEqualTo(utils.parseUnits(amount, amountDecimals).toString()); +} + +const getBridgeButtonText = ( + origin: SupportedNetworks, + destination: SupportedNetworks, + allowance: number, + amount: string, + amountDecimals: number, + isAwaitingWalletInteraction: boolean, + isAwaitingApproveTx: boolean, + isAwaitingPerformTx: boolean, +) => { + + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingApproveTx) { + return "Approving Bridge..."; + } + + if(isAwaitingPerformTx) { + return "Bridging PRO..."; + } + + if(isSufficientAllowance(allowance, amount, amountDecimals)) { + return "Bridge PRO"; + } + + return "Approve Bridge"; + +} + +const getTransitTime = (origin: string, destination: string) => { + if(['ethereum', 'sepolia', 'goerli'].indexOf(origin) > -1 && ['base', 'base-sepolia', 'base-goerli'].indexOf(destination) > -1) { + return `~ 20 minutes`; + } + if(['base', 'base-sepolia', 'base-goerli'].indexOf(origin) > -1 && ['ethereum', 'sepolia', 'base-goerli'].indexOf(destination) > -1) { + return `~ 1 week`; + } +} + +interface IBridgeForm { + bridgeAddress: `0x${string}` + origin: SupportedNetworks + destination: SupportedNetworks + originAssetAddress: `0x${string}` + originAssetDecimals: number + destinationAssetAddress: `0x${string}` + destinationAssetDecimals: number + postBridgeSuccess?: () => void +} + +const BridgeFormUnified = (props: PropsFromRedux & IBridgeForm) => { + + let { + origin, + destination, + originAssetAddress, + bridgeAddress, + originAssetDecimals, + destinationAssetAddress, + destinationAssetDecimals, + postBridgeSuccess, + } = props; + + const classes = useStyles(); + + const [hasTriedSubmit, setHasTriedSubmit] = useState(false); + + const { + address, + } = useAccount(); + + const formikRef = useRef>(); + + const { data: blockNumber } = useBlockNumber({ watch: true }) + + const { + data: balanceData, + refetch: refetchBalanceData + } = useBalance({ + address: address, + token: originAssetAddress, + //watch: true, + }); + + const { + data: dataL1BridgePROAllowance, + // isError, + // isLoading + refetch: refetchDataL1BridgePROAllowance + } = useReadContract({ + address: originAssetAddress, + abi: ERC20ABI, + functionName: 'allowance', + //watch: true, + args: [address, bridgeAddress], + }) + + const { + data: dataL2BridgePROAllowance, + refetch: refetchDataL2BridgePROAllowance, + } = useReadContract({ + address: originAssetAddress, + abi: ERC20ABI, + functionName: 'allowance', + //watch: true, + args: [address, bridgeAddress], + }) + + useEffect(() => { + refetchDataL2BridgePROAllowance() + refetchBalanceData() + refetchDataL1BridgePROAllowance() + }, [ + blockNumber, + refetchDataL2BridgePROAllowance, + refetchBalanceData, + refetchDataL1BridgePROAllowance, + ]) + + // L1 BRIDGING METHODS BELOW + + const { + executeTransaction: executeApproveBridgeL1Tx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionApproveBridgeL1Tx, + isAwaitingTx: isAwaitingApproveBridgeL1Tx, + isLoading: isLoadingApproveBridgeL1Tx, + } = useUnifiedWriteContract({ + successToastMessage: `Approval success! You may now bridge PRO from ${NETWORK_NAME_TO_DISPLAY_NAME[origin]} to ${NETWORK_NAME_TO_DISPLAY_NAME[destination]}`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + const { + executeTransaction: executePerformBridgeL1Tx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformBridgeL1Tx, + isAwaitingTx: isAwaitingPerformBridgeL1Tx, + isLoading: isLoadingPerformBridgeL1Tx, + } = useUnifiedWriteContract({ + onSuccess: () => { + formikRef?.current?.resetForm(); + const refreshBridge = async () => { + await BridgeService.triggerBaseBridgeOptimisticSync(BASE_BRIDGE_L1_NETWORK, BASE_BRIDGE_L2_NETWORK); + if(postBridgeSuccess) { + postBridgeSuccess(); + } + } + refreshBridge(); + }, + successToastMessage: `Bridge success! Please note that it may take 20-60 minutes for your PRO tokens to arrive on ${NETWORK_NAME_TO_DISPLAY_NAME[destination]}`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + // L1 BRIDGING METHODS ABOVE + + // ------------------------- + + // L2 BRIDGING METHODS BELOW + + const { + executeTransaction: executeApproveBridgeL2Tx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionApproveBridgeL2Tx, + isAwaitingTx: isAwaitingApproveBridgeL2Tx, + isLoading: isLoadingApproveBridgeL2Tx, + } = useUnifiedWriteContract({ + successToastMessage: `Approval success! You may now bridge PRO from ${NETWORK_NAME_TO_DISPLAY_NAME[origin]} to ${NETWORK_NAME_TO_DISPLAY_NAME[destination]}`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + const { + executeTransaction: executePerformBridgeL2Tx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformBridgeL2Tx, + isAwaitingTx: isAwaitingPerformBridgeL2Tx, + isLoading: isLoadingPerformBridgeL2Tx, + } = useUnifiedWriteContract({ + onSuccess: () => { + formikRef?.current?.resetForm();const refreshBridge = async () => { + await BridgeService.triggerBaseBridgeOptimisticSync(BASE_BRIDGE_L1_NETWORK, BASE_BRIDGE_L2_NETWORK); + if(postBridgeSuccess) { + postBridgeSuccess(); + } + } + refreshBridge(); + }, + successToastMessage: `Withdrawal initiation success! Please note that you will need to submit a withdrawal proof before the ~ 1 week timeline to withdraw to ${NETWORK_NAME_TO_DISPLAY_NAME[destination]} begins`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + // L2 BRIDGING METHODS ABOVE + + const isAwaitingWalletInteraction = useMemo(() => { + return ( + isAwaitingWalletInteractionApproveBridgeL1Tx || + isAwaitingWalletInteractionPerformBridgeL1Tx || + isAwaitingWalletInteractionApproveBridgeL2Tx || + isAwaitingWalletInteractionPerformBridgeL2Tx + ); + }, [ + isAwaitingWalletInteractionApproveBridgeL1Tx, + isAwaitingWalletInteractionPerformBridgeL1Tx, + isAwaitingWalletInteractionApproveBridgeL2Tx, + isAwaitingWalletInteractionPerformBridgeL2Tx + ]); + + const isAwaitingApproveTx = useMemo(() => { + return ( + isAwaitingApproveBridgeL1Tx || + isAwaitingApproveBridgeL2Tx + ); + }, [ + isAwaitingApproveBridgeL1Tx, + isAwaitingApproveBridgeL2Tx + ]); + + const isAwaitingPerformTx = useMemo(() => { + return ( + isAwaitingPerformBridgeL1Tx || + isAwaitingPerformBridgeL2Tx + ); + }, [ + isAwaitingPerformBridgeL1Tx, + isAwaitingPerformBridgeL2Tx + ]); + + const ValidationSchema = yup.object().shape({ + proAmount: yup.number() + .required('Required'), + hiddenField: yup.string().test('hiddenField', 'hiddenField', function (value) { setHasTriedSubmit(true); return true }), + }); + + const originSpring = useSpring({ + from: { + transform: 'translateX(25%)', + }, + to: { + transform: 'translateX(0%)', + }, + }) + + const destinationSpring = useSpring({ + from: { + transform: 'translateX(-25%)', + }, + to: { + transform: 'translateX(0%)', + }, + }) + + const arrowSpring = useSpring({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + delay: 150, + }) + + return ( +
+ +
+
+ + + + + +
+ {NETWORK_NAME_TO_DISPLAY_NAME[origin]} to {NETWORK_NAME_TO_DISPLAY_NAME[destination]} + Takes {getTransitTime(origin, destination)} + { + try { + if(["ethereum", "sepolia", "goerli"].indexOf(origin) > -1) { + if(isSufficientAllowance(Number(dataL1BridgePROAllowance ? dataL1BridgePROAllowance : 0), values.proAmount.toString(), originAssetDecimals)) { + await executePerformBridgeL1Tx({ + address: bridgeAddress, + abi: L1StandardBridgeABI, + functionName: 'bridgeERC20', + args: [ + originAssetAddress, + destinationAssetAddress, + utils.parseUnits(values.proAmount.toString(), originAssetDecimals).toString(), + 1, + '0x0' + ] + }); + } else { + await executeApproveBridgeL1Tx({ + address: originAssetAddress, + abi: ERC20ABI, + functionName: 'approve', + args: [ + bridgeAddress, + utils.parseUnits(values.proAmount.toString(), originAssetDecimals).toString(), + ] + }) + } + } + if(["base", "base-sepolia", 'base-goerli'].indexOf(origin) > -1) { + if(isSufficientAllowance(Number(dataL2BridgePROAllowance ? dataL2BridgePROAllowance : 0), values.proAmount.toString(), originAssetDecimals)) { + await executePerformBridgeL2Tx({ + address: bridgeAddress, + abi: L2StandardBridgeABI, + functionName: 'withdraw', + args: [ + originAssetAddress, + utils.parseUnits(values.proAmount.toString(), originAssetDecimals).toString(), + 150000, + '0x0' + ] + }); + } else { + await executeApproveBridgeL2Tx({ + address: originAssetAddress, + abi: ERC20ABI, + functionName: 'approve', + args: [ + bridgeAddress, + utils.parseUnits(values.proAmount.toString(), originAssetDecimals).toString(), + ] + }) + } + } + } catch(e: any) { + setSubmitting(false); + // toast.error(`Error: ${e?.details ? e.details : "Unable to complete transaction, please try again or contact support."}`); + // TODO handle errors + } + }} + > + {({ submitForm, isSubmitting, handleChange, setFieldValue, errors, values }) => ( +
+ + + + -1 ? 'Base ' : ''}PRO Amount`} + onChange={(event: React.ChangeEvent) => { + handleChange(event) + }} + // helperText={} + /> + + +
+ +
+ {/* + + Your information is stored securely and never used to send spam. By completing this form you agree to our Terms of Service. + + */} + +
+ {(['ethereum','sepolia','goerli'].indexOf(origin) > -1) && + + } + {(['base','base-sepolia','base-goerli'].indexOf(origin) > -1) && + + } +
+ {(['base','base-sepolia','base-goerli'].indexOf(origin) > -1) && + Please note: Withdrawing PRO to Ethereum (L1) will require two L1 transactions to be made (a proof and a finalization). At an L1 gas price of 50 gwei, this would lead to around ~ 0.02 ETH in L1 fees. + } + Trying to bridge ETH?
Please use the official Base Bridge
+
+
+
+ )} +
+ {/* Bridge time: ~ 20 minutes */} +
+
+
+ ); +} + +export default BridgeFormUnified; \ No newline at end of file diff --git a/src/components/BridgeProveWithdrawalFormUnified.tsx b/src/components/BridgeProveWithdrawalFormUnified.tsx new file mode 100644 index 0000000..fab70aa --- /dev/null +++ b/src/components/BridgeProveWithdrawalFormUnified.tsx @@ -0,0 +1,374 @@ +import React, { useState, useCallback, useEffect } from 'react'; + +import { animated, useSpring } from '@react-spring/web'; + +import { utils } from "ethers"; + +import BigNumber from 'bignumber.js'; + +import { toast } from 'sonner'; + +import EastIcon from '@mui/icons-material/East'; + +import { Theme } from '@mui/material/styles'; + +import makeStyles from '@mui/styles/makeStyles'; +import createStyles from '@mui/styles/createStyles'; + +import Card from '@mui/material/Card'; +import Typography from '@mui/material/Typography'; + +import { PropsFromRedux } from '../containers/BridgeProveWithdrawalFormContainer'; + +import EthLogo from '../assets/img/ethereum-web3-modal.png'; +import BaseLogo from '../assets/img/base-logo-transparent-bg.png'; + +import FloatingActionButton from './FloatingActionButton'; + +import { SupportedNetworks } from '../interfaces'; + +import CircularProgress from '@mui/material/CircularProgress'; + +import { + priceFormat, +} from '../utils'; + +import { + NETWORK_NAME_TO_DISPLAY_NAME, + NETWORK_NAME_TO_ID, + L2_TO_L1_MESSAGE_PASSER_ADDRESS, + OPTIMISM_PORTAL_ADDRESS, + L2_OUTPUT_ORACLE, + BASE_BRIDGE_L1_NETWORK, + BASE_BRIDGE_L2_NETWORK, +} from '../utils/constants'; + +import { + BridgeService, +} from '../services/api'; + +import { + IBaseWithdrawalInitiatedEvent, +} from '../interfaces'; + +import { usePrepareProveWithdrawal } from '../hooks/usePrepareProveWithdrawal'; +import { useBlockNumberOfLatestL2OutputProposal } from '../base-bridge/hooks/useBlockNumberOfLatestL2OutputProposal'; + +import { useUnifiedWriteContract } from '../hooks/useUnifiedWriteContract'; + +BigNumber.config({ EXPONENTIAL_AT: [-1e+9, 1e+9] }); + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + display: 'flex', + justifyContent: 'center', + }, + title: { + fontWeight: '500', + }, + cardTitle: { + + }, + cardTitleNetworks: { + + }, + cardSubtitle: { + marginBottom: theme.spacing(3), + }, + subtitle: { + // marginBottom: theme.spacing(2), + }, + innerSpacingBottom: { + marginBottom: theme.spacing(2), + }, + innerSpacingTop: { + marginTop: theme.spacing(2), + }, + sectionSpacer: { + marginBottom: theme.spacing(4), + }, + bridgeIconContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + card: { + width: '100%', + display: 'flex', + justifyContent: 'center', + maxWidth: '420px', + }, + cardInner: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + margin: theme.spacing(4), + width: '100%', + }, + bridgeIllustration: { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + marginBottom: theme.spacing(2), + }, + singleBridgeIcon: { + fontSize: '30px', + }, + networkLogo: { + borderRadius: '50%', + height: 75, + }, + networkLogoRight: { + marginLeft: theme.spacing(2), + }, + networkLogoLeft: { + marginRight: theme.spacing(2), + }, + submitButtonContainer: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), + width: '100%', + display: 'flex', + justifyContent: 'flex-end', + }, + submitButton: { + width: '100%', + }, + }), +); + +const getNetworkIcon = (network: SupportedNetworks) => { + if(['ethereum', 'sepolia', 'goerli'].indexOf(network) > -1) { + return EthLogo; + } + if(['base', 'base-sepolia', 'base-goerli'].indexOf(network) > -1) { + return BaseLogo; + } +} + +const getBridgeProveWithdrawalButtonText = ( + isInitialized: boolean, + isAwaitingWalletInteraction: boolean, + isAwaitingProofTx: boolean, + isAwaitingValidPreparation: boolean, + isWithdrawalAlreadyProven: boolean, + showSuccessMessage: boolean, +) => { + + if(!isInitialized) { + return "Initializing..."; + } + + if(isWithdrawalAlreadyProven || showSuccessMessage) { + return "Proof Submitted"; + } + + if(isAwaitingValidPreparation) { + return "Awaiting Proof Opportunity..."; + } + + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingProofTx) { + return "Proving Withdrawal..."; + } + + return "Prove Withdrawal"; + +} + +const getTransitTime = (origin: string, destination: string) => { + if(['ethereum', 'sepolia', 'goerli'].indexOf(origin) > -1 && ['base', 'base-sepolia', 'base-goerli'].indexOf(destination) > -1) { + return `~ 20 minutes`; + } + if(['base', 'base-sepolia', 'base-goerli'].indexOf(origin) > -1 && ['ethereum', 'sepolia', 'base-goerli'].indexOf(destination) > -1) { + return `~ 1 week`; + } +} + +interface IBridgeProveWithdrawalForm { + origin: SupportedNetworks + destination: SupportedNetworks + transactionHash: `0x${string}` + postBridgeSuccess?: () => void +} + +const BridgeProveWithdrawalFormUnified = (props: PropsFromRedux & IBridgeProveWithdrawalForm) => { + + let { + origin, + destination, + transactionHash, + postBridgeSuccess, + } = props; + + const classes = useStyles(); + + const [transactionData, setTransactionData] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isAwaitingValidPreparation, setIsAwaitingValidPreparation] = useState(false); + const [isWithdrawalAlreadyProven, setIsWithdrawalAlreadyProven] = useState(false); + const [showSuccessMessage, setShowSuccessMessage] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + + // refactor into react-query + useEffect(() => { + let isMounted = true; + const fetchTransactionData = async () => { + if(transactionHash) { + setIsLoading(true); + let fetchedData = await BridgeService.getBaseBridgeTransactionOverviewByTransactionHash(BASE_BRIDGE_L1_NETWORK, BASE_BRIDGE_L2_NETWORK, transactionHash); + if(isMounted && fetchedData.data) { + setTransactionData(fetchedData.data); + setIsLoading(false); + } + } + } + fetchTransactionData(); + return () => { + isMounted = false; + } + }, [transactionHash]) + + // L2 -> L1 PROVE WITHDRAWAL METHODS BELOW + + const blockNumberOfLatestL2OutputProposal = useBlockNumberOfLatestL2OutputProposal( + L2_OUTPUT_ORACLE, + NETWORK_NAME_TO_ID[destination].toString() + ); + + const proveWithdrawalConfig = usePrepareProveWithdrawal( + transactionHash, + blockNumberOfLatestL2OutputProposal ? blockNumberOfLatestL2OutputProposal : BigInt(0), + NETWORK_NAME_TO_ID[origin].toString(), + NETWORK_NAME_TO_ID[destination].toString(), + L2_TO_L1_MESSAGE_PASSER_ADDRESS, + OPTIMISM_PORTAL_ADDRESS, + L2_OUTPUT_ORACLE, + (isPrepError: boolean) => setIsAwaitingValidPreparation(isPrepError), + (isAlreadyProven: boolean) => setIsWithdrawalAlreadyProven(isAlreadyProven), + (isInitComplete: boolean) => setIsInitialized(isInitComplete), + ); + + const { + executeTransaction: executeProveWithdrawalTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionProveWithdrawalTx, + isAwaitingTx: isAwaitingProveWithdrawalTx, + isLoading: isLoadingProveWithdrawalTx, + } = useUnifiedWriteContract({ + onSuccess: () => { + setShowSuccessMessage(true); + const refreshBridge = async () => { + await BridgeService.triggerBaseBridgeOptimisticSync(BASE_BRIDGE_L1_NETWORK, BASE_BRIDGE_L2_NETWORK); + if(postBridgeSuccess) { + postBridgeSuccess(); + } + } + refreshBridge(); + }, + successToastMessage: `Proof submission success! Please note that after ~ 1 week you will need to come back and finalize your withdrawal.`, + fallbackErrorMessage: "Unable to submit proof, please refresh the page and try again, or contact support if the problem persists.", + }); + + const handleProveWithdrawal = useCallback(() => { + void (async () => { + try { + if(proveWithdrawalConfig) { + await executeProveWithdrawalTx(proveWithdrawalConfig); + } else { + toast.error(`Unable to submit proof, please refresh the page and try again, or contact support if the problem persists.`); + } + } catch(e) { + console.error({e}); + // onCloseProveWithdrawalModal(); + } + })(); + }, [ + proveWithdrawalConfig, + executeProveWithdrawalTx, + ]); + + // useWaitForTransactionReceipt({ + // hash: dataSubmitProof, + // }) + + // L2 -> L1 PROVE WITHDRAWAL METHODS ABOVE + + const originSpring = useSpring({ + from: { + transform: 'translateX(25%)', + }, + to: { + transform: 'translateX(0%)', + }, + }) + + const destinationSpring = useSpring({ + from: { + transform: 'translateX(-25%)', + }, + to: { + transform: 'translateX(0%)', + }, + }) + + const arrowSpring = useSpring({ + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + delay: 150, + }) + + return ( +
+ + {transactionData && !isLoading && +
+
+ + + + + +
+ {NETWORK_NAME_TO_DISPLAY_NAME[origin]} to {NETWORK_NAME_TO_DISPLAY_NAME[destination]} + Takes {getTransitTime(origin, destination)} + Prove withdrawal of {priceFormat(Number(utils.formatUnits(transactionData.amount, 8)), 2, 'PRO')} to L1 + + {isAwaitingValidPreparation && Waiting for L1 to have a valid checkpoint so that you can prove your withdrawal transaction was included in an L2 transaction, this may take a few minutes. Once you submit your proof, it will take ~ 1 week before you can finalize your withdrawal and receive your PRO on L1.} + {(isWithdrawalAlreadyProven || showSuccessMessage) && Proof submitted! Please note that after ~ 1 week you will need to come back and finalize your withdrawal.} +
+ } + {isLoading && +
+ + Loading transaction... +
+ } + {!isLoading && !transactionData && +
+ Transaction not found +
+ } +
+
+ ); +} + +export default BridgeProveWithdrawalFormUnified; \ No newline at end of file diff --git a/src/components/CollectionExplorerGallery.tsx b/src/components/CollectionExplorerGallery.tsx index 02b4342..2226958 100644 --- a/src/components/CollectionExplorerGallery.tsx +++ b/src/components/CollectionExplorerGallery.tsx @@ -35,6 +35,8 @@ import { PROPY_LIGHT_BLUE, } from '../utils/constants'; +import PlaceholderImage from '../assets/img/placeholder.webp'; + const useStyles = makeStyles((theme: Theme) => createStyles({ rootDesktop: { @@ -157,6 +159,10 @@ const useStyles = makeStyles((theme: Theme) => descriptionSpacerDesktop: { marginBottom: theme.spacing(2), }, + placeholderImage: { + maxWidth: '100%', + maxHeight: '100%', + } }), ) @@ -342,8 +348,8 @@ const CollectionExplorerGallery = ({ /> ); })} - {isLoading && -
+ {(isLoading || explorerEntries?.length === 0) && + placeholder } { (explorerEntries.length > 1) && @@ -358,7 +364,7 @@ const CollectionExplorerGallery = ({ }
- {explorerEntries[selectedEntryIndex]?.type === "NFT" && + {(explorerEntries[selectedEntryIndex]?.type === "NFT" || isLoading) && <> @@ -369,27 +375,30 @@ const CollectionExplorerGallery = ({ - {metadata && metadata?.name ? metadata?.name : ""} + {((!isLoading && metadata) && metadata?.name) ? metadata?.name : ""} + {isLoading && `Loading...`} - { - token_id && - asset_address && - network_name && -
- -
- } - {metadata && metadata?.description && +
+ +
+ {(!isLoading && metadata && metadata?.description) && <> {metadata?.description} } + {isLoading && + <> + + Loading Description... + + } { token_id && asset_address && @@ -402,6 +411,14 @@ const CollectionExplorerGallery = ({
} + { + isLoading && +
+ +
+ } } {explorerEntries[selectedEntryIndex]?.type === "LISTING" && diff --git a/src/components/HomeListingCollectionFilterZoneInner.tsx b/src/components/HomeListingCollectionFilterZoneInner.tsx index ed3c38c..5067af1 100644 --- a/src/components/HomeListingCollectionFilterZoneInner.tsx +++ b/src/components/HomeListingCollectionFilterZoneInner.tsx @@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react' import { useSearchParams } from "react-router-dom"; +import { useQuery } from '@tanstack/react-query'; + import { Theme } from '@mui/material/styles'; import makeStyles from '@mui/styles/makeStyles'; @@ -62,8 +64,6 @@ const HomeListingCollectionFilterZoneInner = (props: ICollectionFilterZone) => { } = props; let [searchParams, setSearchParams] = useSearchParams(); - let [uniqueCities, setUniqueCities] = useState([]); - let [uniqueCountries, setUniqueCountries] = useState([]); let [selectedCity, setSelectedCity] = useState(searchParams.get("city") || ""); let [selectedCountry, setSelectedCountry] = useState(searchParams.get("country") || ""); @@ -156,8 +156,12 @@ const HomeListingCollectionFilterZoneInner = (props: ICollectionFilterZone) => { nftContractAddress, } = props; - useEffect(() => { - const fetchUniqueFieldValues = async () => { + const { + data: uniqueFieldDataTanstack, + isLoading: isLoadingUniqueFieldDataTanstack, + } = useQuery({ + queryKey: ['home-listing-unique-filter-values', network, contractNameOrCollectionNameOrAddress, nftContractAddress], + queryFn: async () => { let useAsNftAddress = nftContractAddress ? nftContractAddress : contractNameOrCollectionNameOrAddress; let [ uniqueCityRequest, @@ -167,15 +171,22 @@ const HomeListingCollectionFilterZoneInner = (props: ICollectionFilterZone) => { NFTService.getUniqueMetadataFieldValuesWithListingAttached(network, useAsNftAddress, "City"), NFTService.getUniqueMetadataFieldValuesWithListingAttached(network, useAsNftAddress, "Country"), ]); + let uniqueCities = []; if(uniqueCityRequest?.data?.length > 0) { - setUniqueCities(uniqueCityRequest?.data); + uniqueCities = uniqueCityRequest?.data; } + let uniqueCountries = []; if(uniqueCountryRequest?.data?.length > 0) { - setUniqueCountries(uniqueCountryRequest?.data); + uniqueCountries = uniqueCountryRequest?.data; } - } - fetchUniqueFieldValues(); - }, [network, contractNameOrCollectionNameOrAddress, nftContractAddress]); + return { + uniqueCities, + uniqueCountries, + } + }, + gcTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + }); useEffect(() => { let isMounted = true; @@ -209,8 +220,8 @@ const HomeListingCollectionFilterZoneInner = (props: ICollectionFilterZone) => {
} @@ -225,8 +236,8 @@ const HomeListingCollectionFilterZoneInner = (props: ICollectionFilterZone) => { /> { diff --git a/src/components/NFTLikeZone.tsx b/src/components/NFTLikeZone.tsx index c89ff69..bd66d26 100644 --- a/src/components/NFTLikeZone.tsx +++ b/src/components/NFTLikeZone.tsx @@ -1,7 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { useAccount, useSignMessage } from 'wagmi'; +import { useQueryClient, useQuery } from '@tanstack/react-query'; + import { toast } from 'sonner'; import { Theme } from '@mui/material/styles'; @@ -55,24 +57,22 @@ const useStyles = makeStyles((theme: Theme) => interface INFTLikeZone { title?: string, - tokenAddress: string, - tokenId: string, - tokenNetwork: string, + tokenAddress: string | false, + tokenId: string | false, + tokenNetwork: string | false, onSuccess?: () => void, compact?: boolean, + isPlaceholder?: boolean, } const NFTLikeZone = (props: PropsFromRedux & INFTLikeZone) => { - const [loading, setLoading] = useState(true); - const [likeCount, setLikeCount] = useState(0); - const [isLiked, setIsLiked] = useState(false); - const [reloadIndex, setReloadIndex] = useState(0); - const classes = useStyles(); const { address, chainId } = useAccount(); + const queryClient = useQueryClient(); + const { // data, // isError, @@ -88,49 +88,46 @@ const NFTLikeZone = (props: PropsFromRedux & INFTLikeZone) => { tokenNetwork, onSuccess, compact = false, + isPlaceholder = false, } = props; - useEffect(() => { - let isMounted = true; - const getLikeStatus = async () => { - if(isMounted) { - setLoading(true); - } - if(address) { - let [likeStatusResponse, likeCountResponse] = await Promise.all([ - NFTService.getLikedByStatus(tokenNetwork, tokenAddress, tokenId, address), - NFTService.getLikeCount(tokenNetwork, tokenAddress, tokenId), - ]); - if(isMounted) { + const { + data: likeDataTanstack, + isLoading: isLoadingLikeDataTanstack, + } = useQuery({ + queryKey: ['nft-like-zone', tokenNetwork, tokenAddress, tokenId, address, isPlaceholder], + queryFn: async () => { + let likeCount = 0; + let isLiked = false; + if (tokenNetwork && tokenId && tokenAddress && !isPlaceholder) { + if(address) { + let [likeStatusResponse, likeCountResponse] = await Promise.all([ + NFTService.getLikedByStatus(tokenNetwork, tokenAddress, tokenId, address), + NFTService.getLikeCount(tokenNetwork, tokenAddress, tokenId), + ]); if(likeStatusResponse?.data?.like_status) { - setIsLiked(true); - } else { - setIsLiked(false); + isLiked = true; } if(!isNaN(likeCountResponse?.data?.like_count)) { - setLikeCount(likeCountResponse?.data?.like_count); + likeCount = likeCountResponse?.data?.like_count; } - } - } else { - let [likeCountResponse] = await Promise.all([ - NFTService.getLikeCount(tokenNetwork, tokenAddress, tokenId), - ]); - if(isMounted) { + } else { + let [likeCountResponse] = await Promise.all([ + NFTService.getLikeCount(tokenNetwork, tokenAddress, tokenId), + ]); if(!isNaN(likeCountResponse?.data?.like_count)) { - setLikeCount(likeCountResponse?.data?.like_count); + likeCount = likeCountResponse?.data?.like_count; } } - setIsLiked(false); } - if(isMounted) { - setLoading(false); + return { + likeCount, + isLiked, } - } - getLikeStatus(); - return () => { - isMounted = false; - } - }, [tokenNetwork, tokenAddress, tokenId, address, reloadIndex]) + }, + gcTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + }); const signLike = async (type: 'add_like_nft' | 'remove_like_nft') => { if(signMessageAsync && address && chainId) { @@ -167,14 +164,7 @@ const NFTLikeZone = (props: PropsFromRedux & INFTLikeZone) => { if(onSuccess) { onSuccess(); } - setReloadIndex(reloadIndex + 1); - if(type === 'add_like_nft') { - setIsLiked(true); - setLikeCount(likeCount + 1); - } else { - setIsLiked(false); - setLikeCount(likeCount - 1); - } + queryClient.invalidateQueries({ queryKey: ['nft-like-zone', tokenNetwork, tokenAddress, tokenId, address, isPlaceholder] }) toast.success(`Like ${type === 'add_like_nft' ? "added" : "removed"} successfully!`); } else { toast.error(`Unable to ${type === 'add_like_nft' ? "add" : "remove"} like`); @@ -212,14 +202,15 @@ const NFTLikeZone = (props: PropsFromRedux & INFTLikeZone) => { e.preventDefault(); onClickFn(); }} + disabled={isPlaceholder} > - {isLiked ? : } + {likeDataTanstack?.isLiked ? : } )} variant="contained" color="secondary" darkMode={darkMode} overrideConnectText="Connect wallet" hideNetworkSwitch={true} /> } {address && - + { onClick={(e) => { e.stopPropagation(); e.preventDefault(); - signLike(isLiked ? 'remove_like_nft' : 'add_like_nft') + signLike(likeDataTanstack?.isLiked ? 'remove_like_nft' : 'add_like_nft') }} + disabled={isPlaceholder} onTouchStart={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > - {isLiked ? : } + {likeDataTanstack?.isLiked ? : } } - {!loading && + {!isLoadingLikeDataTanstack && - {likeCount} - {!compact && <>{(likeCount && (likeCount === 1)) ? ' Like' : ' Likes'}} + {likeDataTanstack?.likeCount} + {!compact && <>{(likeDataTanstack?.likeCount && (likeDataTanstack?.likeCount === 1)) ? ' Like' : ' Likes'}} } - {loading && + {isLoadingLikeDataTanstack && }
diff --git a/src/components/NavigationLeftSideBar.tsx b/src/components/NavigationLeftSideBar.tsx index 75340a0..7073218 100644 --- a/src/components/NavigationLeftSideBar.tsx +++ b/src/components/NavigationLeftSideBar.tsx @@ -93,7 +93,7 @@ const navigationMenu : IMenuEntry[] = [ { text: 'Bridge', path: '/bridge', - pathExtended: ['/bridge/:bridgeSelection', '/bridge/:bridgeSelection/:bridgeAction/:transactionHash'], + externalLink: 'https://superbridge.app/base', icon: , }, { diff --git a/src/components/NavigationLeftSideBarDesktop.tsx b/src/components/NavigationLeftSideBarDesktop.tsx index 53350b9..0574468 100644 --- a/src/components/NavigationLeftSideBarDesktop.tsx +++ b/src/components/NavigationLeftSideBarDesktop.tsx @@ -93,7 +93,7 @@ const navigationMenu : IMenuEntry[] = [ { text: 'Bridge', path: '/bridge', - pathExtended: ['/bridge/:bridgeSelection', '/bridge/:bridgeSelection/:bridgeAction/:transactionHash'], + externalLink: 'https://superbridge.app/base', icon: , }, { diff --git a/src/components/PageContainer.tsx b/src/components/PageContainer.tsx index 14b4e81..de050b8 100644 --- a/src/components/PageContainer.tsx +++ b/src/components/PageContainer.tsx @@ -26,6 +26,7 @@ import PropyKeysMapPage from '../pages/PropyKeysMapPage'; import AnalyticsPage from '../pages/AnalyticsPage'; import PropyKeyRepossessionPage from '../pages/PropyKeyRepossessionPage'; import PropyProfilePage from '../pages/PropyProfilePage'; +import PaymasterTestUnifiedPage from '../pages/PaymasterTestUnifiedPage'; import useWindowSize from '../hooks/useWindowSize'; @@ -91,6 +92,7 @@ const PageContainer = (props: PropsFromRedux) => { {showDesktopMenu && } {isLayoutInitialized && + } /> } /> } /> } /> diff --git a/src/components/PropyKeyRepossessionUnified.tsx b/src/components/PropyKeyRepossessionUnified.tsx new file mode 100644 index 0000000..bc416e6 --- /dev/null +++ b/src/components/PropyKeyRepossessionUnified.tsx @@ -0,0 +1,374 @@ +import React, { useEffect, useState } from 'react'; + +import { Theme } from '@mui/material/styles'; +import Grid from '@mui/material/Grid'; + +import makeStyles from '@mui/styles/makeStyles'; +import createStyles from '@mui/styles/createStyles'; + +import { useQuery } from '@tanstack/react-query'; + +import Typography from '@mui/material/Typography'; + +import SingleTokenCardBaseline from './SingleTokenCardBaseline'; + +import FloatingActionButton from './FloatingActionButton'; + +import PlaceholderImage from '../assets/img/placeholder.webp'; + +import { useUnifiedWriteContract } from '../hooks/useUnifiedWriteContract'; + +import { + useAccount, + useReadContract, + useBlockNumber, +} from 'wagmi'; + +import { + PROPY_KEY_REPOSSESSION_CONTRACT, + BASE_OG_STAKING_NFT, + BASE_PROPYKEYS_NFT, + // BASE_PROPYKEYS_STAKING_NFT, +} from '../utils/constants'; + +import { + getResolvableIpfsLink, +} from '../utils'; + +import PropyKeyRepossessionABI from '../abi/PropyKeyRepossessionABI.json'; +import PropyNFTABI from '../abi/PropyNFTABI.json'; + +import { PropsFromRedux } from '../containers/PropyKeyRepossessionContainer'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + marginTop: theme.spacing(4), + }, + sectionSpacer: { + marginTop: theme.spacing(4), + }, + sectionSpacerSmall: { + marginTop: theme.spacing(2), + }, + submitButtonContainer: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), + width: '100%', + display: 'flex', + justifyContent: 'flex-end', + }, + submitButton: { + width: '100%', + }, + }), +); + +interface IPropyKeyRepossession { + propyKeyTokenId: string | undefined +} + +const PropyKeyRepossession = (props: PropsFromRedux & IPropyKeyRepossession) => { + + const { + propyKeyTokenId, + } = props; + + const classes = useStyles(); + + const { data: blockNumber } = useBlockNumber({ watch: true }) + + const [metadataIpfsLink, setMetadataIpfsLink] = useState(); + const [isMarkedForRepossession, setIsMarkedForRepossession] = useState(false); + const [ogCountForPropyKey, setOgCountForPropyKey] = useState(false); + const [isOwnerOfPropyKey, setIsOwnerOfPropyKey] = useState(false); + const [isRepossessionContractApproved, setIsRepossessionContractApproved] = useState(false); + + const [tokenImage, setTokenImage] = useState(PlaceholderImage); + const [tokenTitle, setTokenTitle] = useState(''); + + // const { + // isConsideredMobile, + // isConsideredMedium, + // } = props; + + const { + address, + } = useAccount(); + + const { + data: dataRepossessionConfig, + refetch: refetchDataRepossessionConfig, + } = useReadContract({ + address: PROPY_KEY_REPOSSESSION_CONTRACT, + abi: PropyKeyRepossessionABI, + functionName: 'tokenIdToRepossessionConfiguration', + //watch: true, + args: [propyKeyTokenId], + }) + + useEffect(() => { + console.log({dataRepossessionConfig}) + if(dataRepossessionConfig?.[0]) { + setIsMarkedForRepossession(true); + } else { + setIsMarkedForRepossession(false); + } + if(dataRepossessionConfig?.[3]) { + setOgCountForPropyKey(dataRepossessionConfig?.[3]); + } else { + setOgCountForPropyKey(false); + } + if(dataRepossessionConfig?.[1] && (dataRepossessionConfig?.[1]?.indexOf("ipfs://") > -1)) { + let metadataResolveLink = getResolvableIpfsLink(dataRepossessionConfig?.[1]); + setMetadataIpfsLink(metadataResolveLink); + } + }, [dataRepossessionConfig]) + + const { + data: ogMetadata, + // isLoading: isLoadingOgMetadata, + } = useQuery({ + queryKey: ['fetchIpfsMetadata', metadataIpfsLink], + queryFn: async () => { + if(metadataIpfsLink) { + let metadata = await fetch(metadataIpfsLink).then((res) => res.json()); + return metadata; + } + return null; + }, + gcTime: 60, // Cache the data indefinitely + staleTime: 60, // Data is always considered fresh + }); + + useEffect(() => { + if(ogMetadata?.image) { + let imageResolveLink = getResolvableIpfsLink(ogMetadata?.image); + if(imageResolveLink) { + setTokenImage(imageResolveLink); + } else { + setTokenImage(PlaceholderImage); + } + } else { + setTokenImage(PlaceholderImage); + } + if(ogMetadata?.name) { + setTokenTitle(ogMetadata?.name); + } else { + setTokenTitle('') + } + + }, [ogMetadata]) + + const { + data: approvedTokenController, + refetch: refetchApprovedTokenController, + } = useReadContract({ + address: BASE_PROPYKEYS_NFT, + abi: PropyNFTABI, + functionName: 'getApproved', + //watch: true, + args: [propyKeyTokenId], + }); + + useEffect(() => { + if(approvedTokenController === PROPY_KEY_REPOSSESSION_CONTRACT) { + setIsRepossessionContractApproved(true); + } else { + setIsRepossessionContractApproved(false); + } + }, [approvedTokenController]) + + const { + data: tokenOwner, + refetch: refetchTokenOwner, + } = useReadContract({ + address: BASE_PROPYKEYS_NFT, + abi: PropyNFTABI, + functionName: 'ownerOf', + //watch: true, + args: [propyKeyTokenId], + }); + + useEffect(() => { + refetchTokenOwner(); + refetchDataRepossessionConfig(); + refetchApprovedTokenController(); + }, [ + blockNumber, + refetchTokenOwner, + refetchDataRepossessionConfig, + refetchApprovedTokenController, + ]) + + useEffect(() => { + console.log({tokenOwner, address}); + if(tokenOwner === address) { + setIsOwnerOfPropyKey(true); + } else { + setIsOwnerOfPropyKey(false); + } + }, [tokenOwner, address]) + + const getClaimButtonText = (waitingForWallet: boolean, waitingForTransaction: boolean) => { + if(waitingForWallet) { + return "Please Check Wallet..."; + } + if(waitingForTransaction) { + return "Awaiting Transaction"; + } + return "Claim"; + } + + const getApproveButtonText = (waitingForWallet: boolean, waitingForTransaction: boolean) => { + if(waitingForWallet) { + return "Please Check Wallet..."; + } + if(waitingForTransaction) { + return "Awaiting Transaction"; + } + return "Approve Redemption"; + } + + const getNotOwnerButtonText = () => { + if(tokenOwner === PROPY_KEY_REPOSSESSION_CONTRACT) { + return "Redemption Complete"; + } + return "Not PropyKey Owner"; + } + + const { + executeTransaction: executeApprovalTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionApprovalTx, + isAwaitingTx: isAwaitingApprovalTx, + isLoading: isLoadingApprovalTx, + } = useUnifiedWriteContract({ + contractConfig: { + args: [ + PROPY_KEY_REPOSSESSION_CONTRACT, + propyKeyTokenId, + ], + address: BASE_PROPYKEYS_NFT, + abi: PropyNFTABI, + functionName: 'approve', + }, + successToastMessage: 'Approval success! You may now run the redemption.', + }); + + const { + executeTransaction: executeRedemptionTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionRedemptionTx, + isAwaitingTx: isAwaitingRedemptionTx, + isLoading: isLoadingRedemptionTx, + } = useUnifiedWriteContract({ + contractConfig: { + args: [ + propyKeyTokenId, + ], + address: PROPY_KEY_REPOSSESSION_CONTRACT, + abi: PropyKeyRepossessionABI, + functionName: 'depositPropyKey', + }, + successToastMessage: `Redemption success!`, + }); + + return ( +
+ {!isMarkedForRepossession && +
+ + PropyOG Claim + + + PropyKey #{propyKeyTokenId} has not been marked for repossession. + +
+ } + {isMarkedForRepossession && +
+ + PropyOG Claim + + + {(tokenOwner === PROPY_KEY_REPOSSESSION_CONTRACT) && + <> + PropyKey #{propyKeyTokenId} has been exchanged for {ogCountForPropyKey} PropyOG token{(Number(ogCountForPropyKey) > 1) ? "s" : ""} + + } + {(tokenOwner !== PROPY_KEY_REPOSSESSION_CONTRACT) && + <> + PropyKey #{propyKeyTokenId} may be exchanged for {ogCountForPropyKey} PropyOG token{(Number(ogCountForPropyKey) > 1) ? "s" : ""} + + } + + + + + + + + + {isOwnerOfPropyKey && isRepossessionContractApproved && + executeRedemptionTx()} + showLoadingIcon={isAwaitingWalletInteractionRedemptionTx || isAwaitingRedemptionTx || isLoadingRedemptionTx} + text={getClaimButtonText(isAwaitingWalletInteractionRedemptionTx, isAwaitingRedemptionTx)} + /> + } + {isOwnerOfPropyKey && !isRepossessionContractApproved && + executeApprovalTx()} + showLoadingIcon={isAwaitingWalletInteractionApprovalTx || isAwaitingApprovalTx || isLoadingApprovalTx} + text={getApproveButtonText(isAwaitingWalletInteractionApprovalTx, isAwaitingApprovalTx)} + /> + } + {!isOwnerOfPropyKey && + {}} + showLoadingIcon={false} + text={getNotOwnerButtonText()} + /> + } + + +
+ } +
+ ) +} + +export default PropyKeyRepossession; \ No newline at end of file diff --git a/src/components/PropyKeysCollectionFilterZoneInner.tsx b/src/components/PropyKeysCollectionFilterZoneInner.tsx index 4966b36..55a053b 100644 --- a/src/components/PropyKeysCollectionFilterZoneInner.tsx +++ b/src/components/PropyKeysCollectionFilterZoneInner.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query'; + import { useSearchParams } from "react-router-dom"; import { Theme } from '@mui/material/styles'; @@ -61,10 +63,6 @@ const PropyKeysCollectionFilterZoneInner = (props: ICollectionFilterZone) => { } = props; let [searchParams, setSearchParams] = useSearchParams(); - let [uniqueCities, setUniqueCities] = useState([]); - let [uniqueCountries, setUniqueCountries] = useState([]); - // let [uniqueOwners, setUniqueOwners] = useState([]); - let [uniqueStatuses, setUniqueStatuses] = useState([]); let [selectedCity, setSelectedCity] = useState(searchParams.get("city") || ""); let [selectedCountry, setSelectedCountry] = useState(searchParams.get("country") || ""); @@ -153,8 +151,12 @@ const PropyKeysCollectionFilterZoneInner = (props: ICollectionFilterZone) => { contractNameOrCollectionNameOrAddress, } = props; - useEffect(() => { - const fetchUniqueFieldValues = async () => { + const { + data: uniqueFieldDataTanstack, + isLoading: isLoadingUniqueFieldDataTanstack, + } = useQuery({ + queryKey: ['propykeys-unique-filter-values', network, contractNameOrCollectionNameOrAddress], + queryFn: async () => { let [ uniqueCityRequest, uniqueCountryRequest, @@ -166,21 +168,27 @@ const PropyKeysCollectionFilterZoneInner = (props: ICollectionFilterZone) => { // NFTService.getUniqueMetadataFieldValues(network, contractNameOrCollectionNameOrAddress, "Owner"), NFTService.getUniqueMetadataFieldValues(network, contractNameOrCollectionNameOrAddress, "Status") ]); + let uniqueCities = []; if(uniqueCityRequest?.data?.length > 0) { - setUniqueCities(uniqueCityRequest?.data); + uniqueCities = uniqueCityRequest?.data; } + let uniqueCountries = []; if(uniqueCountryRequest?.data?.length > 0) { - setUniqueCountries(uniqueCountryRequest?.data); + uniqueCountries = uniqueCountryRequest?.data; } - // if(uniqueOwnerRequest?.data?.length > 0) { - // setUniqueOwners(uniqueOwnerRequest?.data); - // } + let uniqueStatuses = []; if(uniqueStatusRequest?.data?.length > 0) { - setUniqueStatuses(uniqueStatusRequest?.data); + uniqueStatuses = uniqueStatusRequest?.data; } - } - fetchUniqueFieldValues(); - }, [network, contractNameOrCollectionNameOrAddress]); + return { + uniqueCities, + uniqueCountries, + uniqueStatuses, + } + }, + gcTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + }); useEffect(() => { let isMounted = true; @@ -213,8 +221,8 @@ const PropyKeysCollectionFilterZoneInner = (props: ICollectionFilterZone) => {
} @@ -229,8 +237,8 @@ const PropyKeysCollectionFilterZoneInner = (props: ICollectionFilterZone) => { /> { @@ -245,8 +253,8 @@ const PropyKeysCollectionFilterZoneInner = (props: ICollectionFilterZone) => { /> { diff --git a/src/components/PropyKeysHomeListingLikeZone.tsx b/src/components/PropyKeysHomeListingLikeZone.tsx index 8360bf9..69751ae 100644 --- a/src/components/PropyKeysHomeListingLikeZone.tsx +++ b/src/components/PropyKeysHomeListingLikeZone.tsx @@ -1,7 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { useAccount, useSignMessage } from 'wagmi'; +import { useQuery, useQueryClient } from '@tanstack/react-query' + import { toast } from 'sonner'; import { Theme } from '@mui/material/styles'; @@ -58,15 +60,11 @@ interface IPropyKeysHomeListingLikeZone { propyKeysHomeListingId: string, onSuccess?: () => void, compact?: boolean, + isPlaceholder?: boolean, } const PropyKeysHomeListingLikeZone = (props: PropsFromRedux & IPropyKeysHomeListingLikeZone) => { - const [loading, setLoading] = useState(true); - const [likeCount, setLikeCount] = useState(0); - const [isLiked, setIsLiked] = useState(false); - const [reloadIndex, setReloadIndex] = useState(0); - const classes = useStyles(); const { address, chainId } = useAccount(); @@ -84,50 +82,48 @@ const PropyKeysHomeListingLikeZone = (props: PropsFromRedux & IPropyKeysHomeList propyKeysHomeListingId, onSuccess, compact = false, + isPlaceholder = false, } = props; - useEffect(() => { - let isMounted = true; - const getLikeStatus = async () => { - if(isMounted) { - setLoading(true); - } - if(address) { - let [likeStatusResponse, likeCountResponse] = await Promise.all([ - PropyKeysListingService.getLikedByStatus(propyKeysHomeListingId, address), - PropyKeysListingService.getLikeCount(propyKeysHomeListingId), - ]); - if(isMounted) { + const queryClient = useQueryClient(); + + const { + data: likeDataTanstack, + isLoading: isLoadingLikeDataTanstack, + } = useQuery({ + queryKey: ['home-listing-like-zone', propyKeysHomeListingId, address, isPlaceholder], + queryFn: async () => { + let likeCount = 0; + let isLiked = false; + if (propyKeysHomeListingId && address && !isPlaceholder) { + if(address) { + let [likeStatusResponse, likeCountResponse] = await Promise.all([ + PropyKeysListingService.getLikedByStatus(propyKeysHomeListingId, address), + PropyKeysListingService.getLikeCount(propyKeysHomeListingId), + ]); if(likeStatusResponse?.data?.like_status) { - setIsLiked(true); - } else { - setIsLiked(false); + isLiked = true; } - console.log({likeCountResponse}) if(!isNaN(likeCountResponse?.data?.like_count)) { - setLikeCount(likeCountResponse?.data?.like_count); + likeCount = likeCountResponse?.data?.like_count; } - } - } else { - let [likeCountResponse] = await Promise.all([ - PropyKeysListingService.getLikeCount(propyKeysHomeListingId), - ]); - if(isMounted) { + } else { + let [likeCountResponse] = await Promise.all([ + PropyKeysListingService.getLikeCount(propyKeysHomeListingId), + ]); if(!isNaN(likeCountResponse?.data?.like_count)) { - setLikeCount(likeCountResponse?.data?.like_count); + likeCount = likeCountResponse?.data?.like_count; } } - setIsLiked(false); } - if(isMounted) { - setLoading(false); + return { + likeCount, + isLiked, } - } - getLikeStatus(); - return () => { - isMounted = false; - } - }, [propyKeysHomeListingId, address, reloadIndex]) + }, + gcTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + }); const signLike = async (type: 'add_like_propykeys_listing' | 'remove_like_propykeys_listing') => { if(signMessageAsync && address && chainId) { @@ -162,14 +158,7 @@ const PropyKeysHomeListingLikeZone = (props: PropsFromRedux & IPropyKeysHomeList if(onSuccess) { onSuccess(); } - setReloadIndex(reloadIndex + 1); - if(type === 'add_like_propykeys_listing') { - setIsLiked(true); - setLikeCount(likeCount + 1); - } else { - setIsLiked(false); - setLikeCount(likeCount - 1); - } + queryClient.invalidateQueries({ queryKey: ['home-listing-like-zone', propyKeysHomeListingId, address, isPlaceholder] }) toast.success(`Like ${type === 'add_like_propykeys_listing' ? "added" : "removed"} successfully!`); } else { toast.error(`Unable to ${type === 'add_like_propykeys_listing' ? "add" : "remove"} like`); @@ -208,13 +197,13 @@ const PropyKeysHomeListingLikeZone = (props: PropsFromRedux & IPropyKeysHomeList onClickFn(); }} > - {isLiked ? : } + {likeDataTanstack?.isLiked ? : } )} variant="contained" color="secondary" darkMode={darkMode} overrideConnectText="Connect wallet" hideNetworkSwitch={true} /> } {address && - + { e.stopPropagation(); e.preventDefault(); - signLike(isLiked ? 'remove_like_propykeys_listing' : 'add_like_propykeys_listing') + signLike(likeDataTanstack?.isLiked ? 'remove_like_propykeys_listing' : 'add_like_propykeys_listing') }} onTouchStart={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > - {isLiked ? : } + {likeDataTanstack?.isLiked ? : } } - {!loading && + {!isLoadingLikeDataTanstack && - {likeCount} - {!compact && <>{(likeCount && (likeCount === 1)) ? ' Like' : ' Likes'}} + {likeDataTanstack?.likeCount} + {!compact && <>{(likeDataTanstack?.likeCount && (likeDataTanstack?.likeCount === 1)) ? ' Like' : ' Likes'}} } - {loading && + {isLoadingLikeDataTanstack && }
diff --git a/src/components/RecentHomeNftScrollingBanner.tsx b/src/components/RecentHomeNftScrollingBanner.tsx index d7fa1f9..2eaa760 100644 --- a/src/components/RecentHomeNftScrollingBanner.tsx +++ b/src/components/RecentHomeNftScrollingBanner.tsx @@ -1,4 +1,6 @@ -import React, { useState, useEffect } from 'react' +import React from 'react' + +import { useQuery } from '@tanstack/react-query'; import { IRecentlyMintedResult, @@ -23,12 +25,12 @@ let maxTimeframeMinutesSinceMinted = 60; const RecentHomeNftScrollingBanner = () => { - // const [isLoading, setIsLoading] = useState(true); - const [scrollingTextEntries, setScrollingTextEntries] = useState([]); - - useEffect(() => { - let isMounted = true; - const fetchCollection = async () => { + const { + data: scrollingTextEntriesTanstack, + } = useQuery({ + queryKey: ['latest-propykey-mint-scroller'], + queryFn: async () => { + let entries : IHorizontalScrollingTextEntry[] = []; if(collectionConfigEntry) { let collectionResponse = await NFTService.getCollectionPaginated( collectionConfigEntry.network, @@ -36,8 +38,7 @@ const RecentHomeNftScrollingBanner = () => { 10, 1, ) - // setIsLoading(false); - if(collectionResponse?.status && collectionResponse?.data && isMounted) { + if(collectionResponse?.status && collectionResponse?.data) { let renderResults : IHorizontalScrollingTextEntry[] = []; let apiResponseData : IRecentlyMintedResult = collectionResponse.data; if(collectionResponse?.status && apiResponseData?.data) { @@ -57,22 +58,19 @@ const RecentHomeNftScrollingBanner = () => { } } } - setScrollingTextEntries(renderResults); - } else { - setScrollingTextEntries([]); + entries = renderResults; } - } else { - setScrollingTextEntries([]); } - } - fetchCollection(); - return () => { - isMounted = false; - } - }, []) + return { + entries + } + }, + gcTime: 5 * 60 * 1000, + staleTime: 60 * 1000, + }); return ( - + ) } diff --git a/src/components/StakePortalUnified.tsx b/src/components/StakePortalUnified.tsx new file mode 100644 index 0000000..d631570 --- /dev/null +++ b/src/components/StakePortalUnified.tsx @@ -0,0 +1,1275 @@ +import React, { useState, useEffect, useId, useMemo } from 'react'; + +import { useQueryClient } from '@tanstack/react-query' + +import { animated, useSpring } from '@react-spring/web'; + +import { useQuery } from '@tanstack/react-query'; + +import { utils } from 'ethers'; + +import BigNumber from 'bignumber.js'; + +import { toast } from 'sonner'; + +import { Theme } from '@mui/material/styles'; + +import makeStyles from '@mui/styles/makeStyles'; +import createStyles from '@mui/styles/createStyles'; + +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Grid from '@mui/material/Grid'; +import Typography from '@mui/material/Typography'; +import Stepper from '@mui/material/Stepper'; +import Step from '@mui/material/Step'; +import StepLabel from '@mui/material/StepLabel'; +import Button from '@mui/material/Button'; + +import { useAccount, useBalance, useReadContract, useBlockNumber } from 'wagmi'; + +import { PropsFromRedux } from '../containers/StakeStatsContainer'; + +import SingleTokenCard from './SingleTokenCard'; +import SingleTokenCardLoading from './SingleTokenCardLoading'; + +import FloatingActionButton from './FloatingActionButton'; + +import LinkWrapper from './LinkWrapper'; + +import { + priceFormat, + countdownToTimestamp, + sleep, +} from '../utils'; + +import { + BASE_PROPYKEYS_STAKING_CONTRACT_V1, + BASE_PROPYKEYS_STAKING_CONTRACT_V2, + BASE_PROPYKEYS_STAKING_NFT, + BASE_OG_STAKING_NFT, + PRO_BASE_L2_ADDRESS, + STAKING_ORIGIN_COUNTRY_BLACKLIST, + PROPY_LIGHT_BLUE, +} from '../utils/constants'; + +import { + IAssetRecord, + IBalanceRecord, + IPaginationNoOptional, +} from '../interfaces'; + +import ERC20ABI from '../abi/ERC20ABI.json'; +import PropyNFTABI from '../abi/PropyNFTABI.json'; +import PRONFTStakingABI from '../abi/PRONFTStakingABI.json'; + +import { + AccountBalanceService, + StakeService, + GeoService, +} from '../services/api'; + +import { useStakerUnlockTime, useUnifiedWriteContract } from '../hooks'; + +BigNumber.config({ EXPONENTIAL_AT: [-1e+9, 1e+9] }); + +interface IStakeEnter { + mode: "enter" | "leave" + postStakeSuccess?: () => void + version: number +} + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + display: 'flex', + justifyContent: 'center', + lineHeight: 0, + }, + card: { + display: 'flex', + justifyContent: 'center', + width: '100%', + height: '100%', + }, + cardInner: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + margin: theme.spacing(4), + width: '100%', + }, + personalStatsSpacer: { + marginBottom: theme.spacing(2), + }, + floatingActionZone: { + position: 'fixed', + maxWidth: '400px', + width: 'calc(100% - 16px)', + transform: 'translateY(0%)', + textAlign: 'center', + zIndex: 1200, + }, + floatingActionZoneCard: { + padding: theme.spacing(2), + // border: `2px solid ${PROPY_LIGHT_BLUE}`, + }, + submitButtonContainer: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(1), + width: '100%', + display: 'flex', + justifyContent: 'flex-end', + }, + submitButton: { + width: '100%', + }, + stepContainer: { + width: '100%', + marginTop: theme.spacing(3), + marginBottom: theme.spacing(3), + }, + selectionOptionsContainer: { + width: '100%', + marginTop: theme.spacing(1), + marginBottom: theme.spacing(2), + }, + selectionOptionsSpacer: { + maxWidth: '350px', + display: 'flex', + justifyContent: 'space-evenly', + }, + buttonSubtitle: { + marginTop: theme.spacing(1.5), + fontWeight: 400, + }, + buttonTitle: { + marginBottom: theme.spacing(1), + }, + buttonTitleSmallSpacing: { + marginBottom: theme.spacing(0.5), + }, + loadingZone: { + opacity: 0.5, + }, + loadMoreButtonContainer: { + marginTop: theme.spacing(4), + width: '100%', + display: 'flex', + justifyContent: 'center', + }, + }), +); + +interface INftAssets { + [key: string]: IAssetRecord +} + +const getApproveNFTButtonText = ( + isAwaitingWalletInteraction: boolean, + isAwaitingPropyKeysApprovalForAllTx: boolean, +) => { + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingPropyKeysApprovalForAllTx) { + return "Granting Allowance..."; + } + + return "Grant NFT Allowance"; +} + +const getApprovePROButtonText = ( + isAwaitingWalletInteraction: boolean, + isAwaitingPROAllowanceTx: boolean, +) => { + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingPROAllowanceTx) { + return "Granting Allowance..."; + } + + return "Grant PRO Allowance"; +} + +const getApprovePStakeButtonText = ( + isAwaitingWalletInteraction: boolean, + isAwaitingPStakeAllowanceTx: boolean, +) => { + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingPStakeAllowanceTx) { + return "Granting Allowance..."; + } + + return "Grant pSTAKE Allowance"; +} + +const getUnstakeButtonText = ( + isAwaitingWalletInteraction: boolean, + isAwaitingUnstakeTx: boolean, + isSyncingStaking: boolean, +) => { + + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingUnstakeTx) { + return "Unstaking..."; + } + + if(isSyncingStaking) { + return "Syncing..."; + } + + return "Unstake"; +} + +const getStakeButtonText = ( + isAwaitingWalletInteraction: boolean, + isAwaitingStakeTx: boolean, + isSyncingStaking: boolean, +) => { + if(isAwaitingWalletInteraction) { + return "Please Check Wallet..."; + } + + if(isAwaitingStakeTx) { + return "Staking..."; + } + + if(isSyncingStaking) { + return "Syncing..."; + } + + return "Stake"; +} + +const getActiveStep = ( + mode: string, + selectedTokenAddress: string | false, + isPropyKeysStakingContractApproved: boolean, + isPropyOGsStakingContractApproved: boolean, + currentStakingContractAllowance: string, + requiredPROAllowance: string, + stakingPowerToUnstake: string, + stakingContractStakingPowerAllowance: string, +) => { + // todo + // use currentStakingContractAllowance & requiredAllowance + if(mode === "enter") { + let isSufficientAllowancePRO = new BigNumber(currentStakingContractAllowance).isGreaterThanOrEqualTo(requiredPROAllowance); + if(selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT) { + console.log({isSufficientAllowancePRO, requiredPROAllowance, currentStakingContractAllowance}) + if (!isPropyKeysStakingContractApproved) { + return 0; + } else if (isPropyKeysStakingContractApproved && !isSufficientAllowancePRO) { + return 1; + } else if(isPropyKeysStakingContractApproved && isSufficientAllowancePRO) { + return 2; + } + } else if(selectedTokenAddress === BASE_OG_STAKING_NFT) { + if (!isPropyOGsStakingContractApproved) { + return 0; + } else if (isPropyOGsStakingContractApproved && !isSufficientAllowancePRO) { + return 1; + } else if(isPropyOGsStakingContractApproved && isSufficientAllowancePRO) { + return 2; + } + } + } else if (mode === "leave") { + let isSufficientAllowanceStakingPower = new BigNumber(stakingContractStakingPowerAllowance).isGreaterThanOrEqualTo(stakingPowerToUnstake); + if(!isSufficientAllowanceStakingPower) { + return 0; + } else { + return 1; + } + } + return 0; +} + +const StakeEnterUnified = (props: PropsFromRedux & IStakeEnter) => { + + const { + mode, + postStakeSuccess, + version, + } = props; + + const uniqueId = useId(); + + const classes = useStyles(); + + const latestStakingVersion = 2; + + const maxSelection = 100; + + let isDeprecatedStakingVersion = version < latestStakingVersion; + + const queryClient = useQueryClient(); + + const { data: blockNumber } = useBlockNumber({ watch: true }) + + const { chain } = useAccount(); + + const [nftAssets, setNftAssets] = useState({}); + const [propyKeysNFT, setPropyKeysNFT] = useState(false); + const [propyKeysNFTPaginationData, setPropyKeysNFTPaginationData] = useState(false); + const [maxStakedLoadCount, setMaxStakedLoadCount] = useState(100); + const [maxUnstakedLoadCount, setMaxUnstakedLoadCount] = useState(100); + const [ogKeysNFT, setOGKeysNFT] = useState(false); + const [ogKeysNFTPaginationData, setOGKeysNFTPaginationData] = useState(); + const [selectedTokenIds, setSelectedPropyKeyTokenIds] = useState([]); + const [selectedTokenAddress, setSelectedTokenAddress] = useState(false); + const [activeStep, setActiveStep] = useState<0|1|2>(0); + const [triggerUpdateIndex, setTriggerUpdateIndex] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [showRequireTenModal, setShowRequireTenModal] = useState(false); + const [isSyncingStaking, setIsSyncingStaking] = useState(false); + const [lastSelectedPropyKeyTokenId, setLastSelectedPropyKeyTokenId] = useState(0); + const [isBlacklistedOrigin, setIsBlacklistedOrigin] = useState(false); + + const { + address, + } = useAccount(); + + const actionZoneSpring = useSpring({ + from: { + bottom: '0px', + transform: 'translateY(100%)', + }, + to: { + bottom: (selectedTokenIds.length > 0 && !isSyncingStaking) ? '100px' : '0px', + transform: `translateY(${(selectedTokenIds.length > 0 && !isSyncingStaking) ? '0%' : '100%'})`, + }, + }) + + const actionZoneSyncingSpring = useSpring({ + from: { + bottom: '0px', + transform: 'translateY(100%)', + }, + to: { + bottom: isSyncingStaking ? '100px' : '0px', + transform: `translateY(${isSyncingStaking ? '0%' : '100%'})`, + }, + }) + + useEffect(() => { + setSelectedPropyKeyTokenIds([]); + setSelectedTokenAddress(false); + }, [address]) + + useEffect(() => { + setSelectedPropyKeyTokenIds([]); + setSelectedTokenAddress(false); + setIsLoading(true); + }, [version]) + + useEffect(() => { + setSelectedPropyKeyTokenIds([]); + setSelectedTokenAddress(false); + }, [triggerUpdateIndex]) + + // refactor into react-query + useEffect(() => { + let isMounted = true; + const getStakingTokens = async () => { + if(address) { + let results; + if (mode === "enter") { + results = await Promise.all([ + AccountBalanceService.getAccountBalancesByAssetIncludeStakingStatus(address, BASE_PROPYKEYS_STAKING_NFT, maxUnstakedLoadCount), + AccountBalanceService.getAccountBalancesByAssetIncludeStakingStatus(address, BASE_OG_STAKING_NFT, maxUnstakedLoadCount), + ]) + } else if(mode === "leave") { + results = await Promise.all([ + AccountBalanceService.getAccountBalancesByAssetOnlyStaked(address, BASE_PROPYKEYS_STAKING_NFT, version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, maxStakedLoadCount), + AccountBalanceService.getAccountBalancesByAssetOnlyStaked(address, BASE_OG_STAKING_NFT, version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, maxStakedLoadCount), + ]) + } + if(isMounted) { + let propykeysRenderResults : IBalanceRecord[] = []; + let assetResults : INftAssets = {}; + if(results?.[0]) { + for(let nftRecord of results?.[0]?.data?.data) { + if(nftRecord?.asset?.address && !assetResults[nftRecord?.asset?.address]) { + assetResults[nftRecord.asset.address] = nftRecord.asset; + } + propykeysRenderResults = [...propykeysRenderResults, nftRecord]; + } + } + let ogRenderResults : IBalanceRecord[] = []; + if(results?.[1]) { + for(let nftRecord of results?.[1]?.data?.data) { + if(nftRecord?.asset?.address && !assetResults[nftRecord?.asset?.address]) { + assetResults[nftRecord.asset.address] = nftRecord.asset; + } + ogRenderResults = [...ogRenderResults, nftRecord]; + } + } + if(isMounted) { + setNftAssets(assetResults); + setPropyKeysNFT(propykeysRenderResults); + if(results?.[0]?.data?.metadata?.pagination) { + setPropyKeysNFTPaginationData(results?.[0]?.data?.metadata?.pagination); + } + setOGKeysNFT(ogRenderResults); + if(results?.[1]?.data?.metadata?.pagination) { + setOGKeysNFTPaginationData(results?.[1]?.data?.metadata?.pagination); + } + setIsLoading(false); + } + } + } + } + getStakingTokens(); + return () => { + isMounted = false; + } + }, [address, mode, triggerUpdateIndex, version, maxUnstakedLoadCount, maxStakedLoadCount]) + + const handleBalanceRecordSelected = (balanceRecord: IBalanceRecord) => { + let useCurrentSelection = balanceRecord.asset_address === selectedTokenAddress ? [...selectedTokenIds] : []; + let indexOfCurrentEntry = useCurrentSelection.indexOf(Number(balanceRecord.token_id)); + if(indexOfCurrentEntry > -1) { + let newSelection = useCurrentSelection.slice(0, indexOfCurrentEntry).concat(useCurrentSelection.slice(indexOfCurrentEntry + 1)); + if(newSelection?.length > 0) { + setSelectedTokenAddress(balanceRecord.asset_address); + } else { + setSelectedTokenAddress(false); + } + setSelectedPropyKeyTokenIds(newSelection); + } else { + if(balanceRecord?.nft?.asset_address === BASE_PROPYKEYS_STAKING_NFT) { + setLastSelectedPropyKeyTokenId(Number(balanceRecord.nft.token_id)); + } else { + setLastSelectedPropyKeyTokenId(0); + } + let newSelection = [...useCurrentSelection, Number(balanceRecord.token_id)]; + if(newSelection?.length > 0) { + setSelectedTokenAddress(balanceRecord.asset_address); + } else { + setSelectedTokenAddress(false); + } + setSelectedPropyKeyTokenIds(newSelection); + } + }; + + const selectAllOfCurrentCollection = () => { + console.log({selectedTokenAddress, BASE_PROPYKEYS_STAKING_NFT, BASE_OG_STAKING_NFT}) + if(selectedTokenAddress) { + if(selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT) { + let newSelection = (propyKeysNFT && (propyKeysNFT?.length > 0)) ? propyKeysNFT.map((balanceRecord, index) => Number(balanceRecord.token_id)).slice(0, maxSelection) : []; + setSelectedPropyKeyTokenIds(newSelection); + } + if(selectedTokenAddress === BASE_OG_STAKING_NFT) { + let newSelection = (ogKeysNFT && (ogKeysNFT?.length > 0)) ? ogKeysNFT.map((balanceRecord, index) => Number(balanceRecord.token_id)).slice(0, maxSelection) : []; + setSelectedPropyKeyTokenIds(newSelection); + } + } + } + + const deselectAllOfCurrentCollection = () => { + setSelectedPropyKeyTokenIds([]); + setSelectedTokenAddress(false); + } + + const { + data: clientCountry, + isLoading: isLoadingGeoLocation, + } = useQuery({ + queryKey: ['stakeGeoLocation', mode], + queryFn: async () => { + let geoLocateResponse = await GeoService.geoLocateClient(); + if (geoLocateResponse?.status && geoLocateResponse?.data) { + return geoLocateResponse?.data?.info?.country; + } + return null; + }, + }); + + useEffect(() => { + if(STAKING_ORIGIN_COUNTRY_BLACKLIST.indexOf(clientCountry) > -1) { + setIsBlacklistedOrigin(true); + } else { + setIsBlacklistedOrigin(false); + } + }, [clientCountry]) + + console.log({clientCountry}) + + const { + data: dataPropyKeysIsStakingContractApproved, + queryKey: dataPropyKeysIsStakingContractApprovedQueryKey + } = useReadContract({ + address: BASE_PROPYKEYS_STAKING_NFT, + abi: PropyNFTABI, + functionName: 'isApprovedForAll', + args: [address, version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2], + }); + + const { + data: lastSelectedTokenTier, + queryKey: lastSelectedTokenTierQueryKey + } = useReadContract({ + address: BASE_PROPYKEYS_STAKING_NFT, + abi: PropyNFTABI, + functionName: 'tokenTier', + args: [lastSelectedPropyKeyTokenId], + }) + + useEffect(() => { + if(Number(lastSelectedPropyKeyTokenId) > 0) { + queryClient.invalidateQueries({ queryKey: lastSelectedTokenTierQueryKey }) + } + }, [blockNumber, queryClient, lastSelectedPropyKeyTokenId, lastSelectedTokenTierQueryKey]); + + const { + data: stakerToStakedCount, + queryKey: stakerToStakedCountQueryKey, + } = useReadContract({ + address: version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + abi: PRONFTStakingABI, + functionName: 'stakerToStakedTokenCount', + args: [address], + }); + + const { + data: dataPropyOGsIsStakingContractApproved, + queryKey: dataPropyOGsIsStakingContractApprovedQueryKey, + } = useReadContract({ + address: BASE_OG_STAKING_NFT, + abi: PropyNFTABI, + functionName: 'isApprovedForAll', + args: [address, version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2], + }); + + const { + data: stakingContractPROAllowance, + queryKey: stakingContractPROAllowanceQueryKey, + } = useReadContract({ + address: PRO_BASE_L2_ADDRESS, + abi: ERC20ABI, + functionName: 'allowance', + args: [address, version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2], + }); + + const { + data: minimumRequiredPROAllowance, + isLoading: isLoadingMinimumRequiredPROAllowance, + queryKey: minimumRequiredPROAllowanceQueryKey, + } = useReadContract({ + address: version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + abi: PRONFTStakingABI, + functionName: 'getPROAmountToStake', + args: [selectedTokenAddress, selectedTokenIds], + }); + + const { + data: stakingContractStakingPowerAllowance, + queryKey: stakingContractStakingPowerAllowanceQueryKey + } = useReadContract({ + address: version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + abi: ERC20ABI, + functionName: 'allowance', + args: [address, version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2], + }); + + const { + data: sharesIssuedAgainstSelection, + queryKey: sharesIssuedAgainstSelectionQueryKey, + // isLoading: isLoadingSharesIssuedAgainstSelection, + } = useReadContract({ + address: version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + abi: PRONFTStakingABI, + functionName: 'getSharesIssued', + args: [selectedTokenAddress, selectedTokenIds], + }); + + const { + data: stakerUnlockTime, + // isLoading: isLoadingStakerUnlockTime, + } = useStakerUnlockTime( + version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + address, + chain ? chain.id : undefined + ); + + const { + data: balanceDataPRO, + queryKey: balanceDataPROQueryKey + } = useBalance({ + address: address, + token: PRO_BASE_L2_ADDRESS, + }); + + const { + data: balanceDataPropyKeys, + queryKey: balanceDataPropyKeysQueryKey, + } = useReadContract({ + address: BASE_PROPYKEYS_STAKING_NFT, + abi: PropyNFTABI, + functionName: 'balanceOf', + args: [address], + }); + + const { + data: balanceDataPropyOG, + queryKey: balanceDataPropyOGQueryKey, + } = useReadContract({ + address: BASE_OG_STAKING_NFT, + abi: PropyNFTABI, + functionName: 'balanceOf', + args: [address], + }); + + useEffect(() => { + + const queryKeys = [ + balanceDataPropyOGQueryKey, + balanceDataPropyKeysQueryKey + ]; + + if( + address + && queryKeys.every(Boolean) + ) { + queryKeys.forEach(key => { + queryClient.invalidateQueries({ queryKey: key }); + }); + } + }, [ + blockNumber, + queryClient, + address, + balanceDataPropyOGQueryKey, + balanceDataPropyKeysQueryKey, + ]); + + useEffect(() => { + + const queryKeys = [ + stakerToStakedCountQueryKey, + dataPropyKeysIsStakingContractApprovedQueryKey, + balanceDataPROQueryKey, + sharesIssuedAgainstSelectionQueryKey, + stakingContractStakingPowerAllowanceQueryKey, + dataPropyOGsIsStakingContractApprovedQueryKey, + stakingContractPROAllowanceQueryKey, + minimumRequiredPROAllowanceQueryKey, + ]; + + if (queryKeys.every(Boolean)) { + queryKeys.forEach(key => { + queryClient.invalidateQueries({ queryKey: key }); + }); + } + }, [ + blockNumber, + queryClient, + stakerToStakedCountQueryKey, + dataPropyKeysIsStakingContractApprovedQueryKey, + balanceDataPROQueryKey, + sharesIssuedAgainstSelectionQueryKey, + stakingContractStakingPowerAllowanceQueryKey, + dataPropyOGsIsStakingContractApprovedQueryKey, + stakingContractPROAllowanceQueryKey, + minimumRequiredPROAllowanceQueryKey, + ]); + + useEffect(() => { + let currentTotal = 0; + if(Number(stakerToStakedCount) > 0) { + currentTotal += Number(stakerToStakedCount); + } + if(Number(balanceDataPropyKeys) > 0) { + currentTotal += Number(balanceDataPropyKeys); + } + if(Number(balanceDataPropyOG) > 0) { + currentTotal += Number(balanceDataPropyOG); + } + if((Number(lastSelectedTokenTier) === 1) && lastSelectedPropyKeyTokenId && (currentTotal < 10) && (mode === "enter")) { + setShowRequireTenModal(true); + setLastSelectedPropyKeyTokenId(0); + } else if(mode === "leave") { + setShowRequireTenModal(false); + } + }, [lastSelectedTokenTier, lastSelectedPropyKeyTokenId, stakerToStakedCount, balanceDataPropyKeys, balanceDataPropyOG, mode]) + + // APPROVE PRO ON CHAIN CALLS BELOW + + const { + executeTransaction: executePerformPROAllowanceTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformPROAllowanceTx, + isAwaitingTx: isAwaitingPerformPROAllowanceTx, + // isLoading: isLoadingPerformPStakeAllowanceTx, + } = useUnifiedWriteContract({ + contractConfig: { + address: PRO_BASE_L2_ADDRESS, + abi: ERC20ABI, + functionName: 'approve', + args: [version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, minimumRequiredPROAllowance], + }, + successToastMessage: `Granted PRO Allowance!`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + // APPROVE PRO ON CHAIN CALLS ABOVE + + // -------------------------------------- + + // APPROVE PRO ON CHAIN CALLS BELOW + + const { + executeTransaction: executePerformPStakeAllowanceTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformPStakeAllowanceTx, + isAwaitingTx: isAwaitingPerformPStakeAllowanceTx, + // isLoading: isLoadingPerformPStakeAllowanceTx, + } = useUnifiedWriteContract({ + contractConfig: { + address: version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + abi: ERC20ABI, + functionName: 'approve', + args: [version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, sharesIssuedAgainstSelection], + }, + successToastMessage: `Granted pSTAKE Allowance!`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + // APPROVE pSTAKE ON CHAIN CALLS ABOVE + + // -------------------------------------- + + // APPROVE PROPYKEYS NFT CALLS BELOW + + const { + executeTransaction: executePerformPropyKeysSetApprovalForAllTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformPropyKeysSetApprovalForAllTx, + isAwaitingTx: isAwaitingPerformPropyKeysSetApprovalForAllTx, + // isLoading: isLoadingPerformPropyKeysSetApprovalForAllTx, + } = useUnifiedWriteContract({ + contractConfig: { + address: BASE_PROPYKEYS_STAKING_NFT, + abi: PropyNFTABI, + functionName: 'setApprovalForAll', + args: [version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, true], + }, + successToastMessage: `PropyKeys NFT Approval granted to staking contract!`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + // APPROVE PROPYKEYS NFT CALLS ABOVE + + // -------------------------------------- + + // APPROVE OG NFT CALLS BELOW + + const { + executeTransaction: executePerformOGSetApprovalForAllTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformOGSetApprovalForAllTx, + isAwaitingTx: isAwaitingPerformOGSetApprovalForAllTx, + // isLoading: isLoadingPerformOGSetApprovalForAllTx, + } = useUnifiedWriteContract({ + contractConfig: { + address: BASE_OG_STAKING_NFT, + abi: PropyNFTABI, + functionName: 'setApprovalForAll', + args: [version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, true], + }, + successToastMessage: `PropyKeys NFT Approval granted to staking contract!`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + }); + + // APPROVE OG NFT CALLS ABOVE + + // -------------------------------------- + + // STAKE CALLS BELOW + + const { + executeTransaction: executePerformStakeTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformStakeTx, + isAwaitingTx: isAwaitingPerformStakeTx, + // isLoading: isLoadingPerformStakeTx, + } = useUnifiedWriteContract({ + contractConfig: { + address: version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + abi: PRONFTStakingABI, + functionName: 'enter', + args: [selectedTokenAddress, selectedTokenIds], + }, + successToastMessage: `Stake success!`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + onSuccess: () => { + const syncStaking = async () => { + setIsSyncingStaking(true); + StakeService.triggerStakeOptimisticSync(); + await sleep(10000); + await StakeService.triggerStakeOptimisticSync(); + setTriggerUpdateIndex(t => t + 1); + if(postStakeSuccess) { + postStakeSuccess(); + } + setIsSyncingStaking(false); + } + syncStaking(); + }, + onError: (error: any) => { + if(error?.cause?.reason === 'TIER_1_STAKING_REQUIRES_10_PROPYKEYS') { + console.log("REQUIRES 10"); + setShowRequireTenModal(true); + } else if(error?.cause?.reason === 'UNSTAKE_ALL_BEFORE_ADDING_MORE') { + toast.error(`You have pending rewards, please unstake all staked tokens to claim all pending rewards before staking more tokens`); + } else { + toast.error(`${error?.details ? error.details : "Unable to complete transaction, please try again or contact support."}`); + } + } + }); + + // STAKE CALLS ABOVE + + // -------------------------------------- + + // UNSTAKING ON CHAIN CALLS BELOW + + const { + executeTransaction: executePerformUnstakeTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionPerformUnstakeTx, + isAwaitingTx: isAwaitingPerformUnstakeTx, + // isLoading: isLoadingPerformUnstakeTx, + } = useUnifiedWriteContract({ + contractConfig: { + address: version === 1 ? BASE_PROPYKEYS_STAKING_CONTRACT_V1 : BASE_PROPYKEYS_STAKING_CONTRACT_V2, + abi: PRONFTStakingABI, + functionName: 'leave', + args: [selectedTokenAddress, selectedTokenIds], + }, + successToastMessage: `Unstake success!`, + fallbackErrorMessage: "Unable to complete transaction, please try again or contact support.", + onSuccess: () => { + const syncStaking = async () => { + setIsSyncingStaking(true); + StakeService.triggerStakeOptimisticSync(); + await sleep(10000); + await StakeService.triggerStakeOptimisticSync(); + setTriggerUpdateIndex(t => t + 1); + if(postStakeSuccess) { + postStakeSuccess(); + } + setIsSyncingStaking(false) + } + syncStaking(); + }, + }); + + // UNSTAKING ON CHAIN CALLS ABOVE + + // -------------------------------------- + + useEffect(() => { + let latestActiveStep : 0|1|2 = getActiveStep( + mode, + selectedTokenAddress, + Boolean(dataPropyKeysIsStakingContractApproved), + Boolean(dataPropyOGsIsStakingContractApproved), + `${stakingContractPROAllowance}`, + `${minimumRequiredPROAllowance}`, + `${sharesIssuedAgainstSelection}`, + `${stakingContractStakingPowerAllowance}`, + ); + setActiveStep(latestActiveStep); + }, [mode, selectedTokenAddress, dataPropyKeysIsStakingContractApproved, dataPropyOGsIsStakingContractApproved, stakingContractPROAllowance, minimumRequiredPROAllowance, sharesIssuedAgainstSelection, stakingContractStakingPowerAllowance]) + + const isAwaitingWalletInteraction = useMemo(() => { + return ( + isAwaitingWalletInteractionPerformPStakeAllowanceTx || + isAwaitingWalletInteractionPerformPROAllowanceTx || + isAwaitingWalletInteractionPerformPropyKeysSetApprovalForAllTx || + isAwaitingWalletInteractionPerformOGSetApprovalForAllTx || + isAwaitingWalletInteractionPerformStakeTx || + isAwaitingWalletInteractionPerformUnstakeTx + ); + }, [ + isAwaitingWalletInteractionPerformPStakeAllowanceTx, + isAwaitingWalletInteractionPerformPROAllowanceTx, + isAwaitingWalletInteractionPerformPropyKeysSetApprovalForAllTx, + isAwaitingWalletInteractionPerformOGSetApprovalForAllTx, + isAwaitingWalletInteractionPerformStakeTx, + isAwaitingWalletInteractionPerformUnstakeTx, + ]); + + const disableSelectionAdjustments = useMemo(() => { + return ( + isAwaitingPerformPropyKeysSetApprovalForAllTx || + isAwaitingWalletInteraction || + isAwaitingPerformOGSetApprovalForAllTx || + isAwaitingPerformPROAllowanceTx || + isAwaitingPerformStakeTx || + isAwaitingPerformPStakeAllowanceTx || + isAwaitingPerformUnstakeTx || + isSyncingStaking + ) + }, [ + isAwaitingPerformPropyKeysSetApprovalForAllTx, + isAwaitingWalletInteraction, + isAwaitingPerformOGSetApprovalForAllTx, + isAwaitingPerformPROAllowanceTx, + isAwaitingPerformStakeTx, + isAwaitingPerformPStakeAllowanceTx, + isAwaitingPerformUnstakeTx, + isSyncingStaking + ]) + + const getMaxHelperText = () => { + let balance = selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT ? Number(propyKeysNFT ? propyKeysNFT.length : 0) : Number(ogKeysNFT ? ogKeysNFT?.length : 0); + let isBalanceMoreThanMaxSelection = balance > maxSelection; + let relevantTokenName = selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT ? "PropyKey" : "PropyOG"; + let actionName = mode === "enter" ? "stake" : "unstake"; + let currentTokenState = mode === "enter" ? "unstaked" : "staked"; + return ( + <> + Maximum {maxSelection} tokens per transaction, you have {balance} {currentTokenState} {relevantTokenName}{balance === 1 ? "" : "s"}, therefore you {isBalanceMoreThanMaxSelection ? <>would need to perform {Math.ceil(balance / maxSelection)} separate {actionName} transactions to {actionName} all of your {currentTokenState} tokens : <>can {actionName} all of your {currentTokenState} tokens in a single transaction} + + ) + } + + return ( +
+ {(isDeprecatedStakingVersion && (mode === "enter")) && + + + + This version of the staking contract has been deprecated, the latest version can be found here. + + + + } + {(isBlacklistedOrigin && !isDeprecatedStakingVersion && (mode === "enter")) && + + + + For regulatory reasons, entering the staking protocol is not allowed from your location. + + + + } + {((!isBlacklistedOrigin && !isDeprecatedStakingVersion) || (mode === "leave")) && + <> + + {!(isLoading || isLoadingGeoLocation) && ((ogKeysNFT && ogKeysNFT.length > 0) || (propyKeysNFT && propyKeysNFT.length > 0)) && + + + {(propyKeysNFTPaginationData && propyKeysNFTPaginationData?.total > 0) && + <> + {`Found ${propyKeysNFTPaginationData && propyKeysNFTPaginationData?.total > 0 ? propyKeysNFTPaginationData.total : 0} PropyKeys`}
+ + } + {(ogKeysNFTPaginationData && ogKeysNFTPaginationData?.total > 0) && + <> + {`Found ${ogKeysNFTPaginationData && ogKeysNFTPaginationData?.total > 0 ? ogKeysNFTPaginationData.total : 0} PropyOG tokens`}
+ + } + Please click on the token(s) that you would like to {mode === "enter" ? "stake" : "unstake"} +
+
+ } + {!(isLoading || isLoadingGeoLocation) && propyKeysNFT && propyKeysNFT.map((balanceRecord, index) => ( + + -1) && (selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT)} onBalanceRecordSelected={handleBalanceRecordSelected} selectable={true} balanceRecord={balanceRecord} assetRecord={nftAssets[balanceRecord?.asset_address]} /> + + ))} + {!(isLoading || isLoadingGeoLocation) && ogKeysNFT && ogKeysNFT.map((balanceRecord, index) => ( + + -1) && (selectedTokenAddress === BASE_OG_STAKING_NFT)} onBalanceRecordSelected={handleBalanceRecordSelected} selectable={true} balanceRecord={balanceRecord} assetRecord={nftAssets[balanceRecord?.asset_address]} /> + + ))} + {(isLoading || isLoadingGeoLocation) && + <> + + + Loading... + + + { + Array.from({length: 15}).map((entry, index) => + + + + ) + } + + } + { + (mode === "enter") && ((ogKeysNFT && ogKeysNFT?.length > 0) || (propyKeysNFT && propyKeysNFT?.length > 0)) && +
+ setMaxUnstakedLoadCount(maxUnstakedLoadCount + 100)} + showLoadingIcon={isLoading || isLoadingGeoLocation} + text={( + (propyKeysNFTPaginationData && (propyKeysNFTPaginationData?.total === propyKeysNFTPaginationData?.count)) + && (ogKeysNFTPaginationData && (ogKeysNFTPaginationData?.total === ogKeysNFTPaginationData?.count)) + ) ? "All Records Loaded" : "Load More"} + /> +
+ } + { + (mode === "leave") && ((ogKeysNFT && ogKeysNFT?.length > 0) || (propyKeysNFT && propyKeysNFT?.length > 0)) && +
+ setMaxStakedLoadCount(maxStakedLoadCount + 100)} + showLoadingIcon={isLoading || isLoadingGeoLocation} + text={( + (propyKeysNFTPaginationData && (propyKeysNFTPaginationData?.total === propyKeysNFTPaginationData?.count)) + && (ogKeysNFTPaginationData && (ogKeysNFTPaginationData?.total === ogKeysNFTPaginationData?.count)) + ) ? "All Records Loaded" : "Load More"} + /> +
+ } + {!(isLoading || isLoadingGeoLocation) && (ogKeysNFT && ogKeysNFT.length === 0) && (propyKeysNFT && propyKeysNFT.length === 0) && + + + {mode === "enter" ? "No unstaked tokens found" : "No staked tokens found"} + + + } +
+ + + + {mode === "enter" ? "Stake " : "Unstake "}{selectedTokenIds.length}{selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT ? " PropyKey" : " PropyOG"}{selectedTokenIds.length === 1 ? "" : "s"} + + + {getMaxHelperText()} + + +
+ + +
+
+ {mode === "enter" && + <> + +
+ {/* TODO feed getActiveStep the proper allowance required param */} + + + {"Approve NFT"} + + + {"Approve PRO"} + + + {"Enter Staking"} + + +
+
+
+ { + ( + selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT + && activeStep === 0 + ) && + executePerformPropyKeysSetApprovalForAllTx()} + showLoadingIcon={isAwaitingPerformPropyKeysSetApprovalForAllTx || isAwaitingWalletInteraction} + text={getApproveNFTButtonText(isAwaitingWalletInteraction, isAwaitingPerformPropyKeysSetApprovalForAllTx)} + /> + } + { + ( + (selectedTokenAddress === BASE_OG_STAKING_NFT) + && activeStep === 0 + ) && + executePerformOGSetApprovalForAllTx()} + showLoadingIcon={isAwaitingPerformOGSetApprovalForAllTx || isAwaitingWalletInteraction} + text={getApproveNFTButtonText(isAwaitingWalletInteraction, isAwaitingPerformOGSetApprovalForAllTx)} + /> + } + { + ( + ((selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT) || (selectedTokenAddress === BASE_OG_STAKING_NFT)) + && activeStep === 1 + ) && + executePerformPROAllowanceTx()} + showLoadingIcon={isAwaitingWalletInteraction || isAwaitingPerformPROAllowanceTx} + text={getApprovePROButtonText(isAwaitingWalletInteraction, isAwaitingPerformPROAllowanceTx)} + /> + } + { + ( + ((selectedTokenAddress === BASE_PROPYKEYS_STAKING_NFT) || (selectedTokenAddress === BASE_OG_STAKING_NFT)) + && activeStep === 2 + ) && +
+ PRO Balance: {priceFormat(Number(utils.formatUnits(Number(balanceDataPRO?.value ? balanceDataPRO?.value : 0), 8)), 2, 'PRO', false, true)} + {priceFormat(Number(utils.formatUnits(Number(minimumRequiredPROAllowance ? minimumRequiredPROAllowance : 0), 8)), 2, 'PRO', false, true)} Required + executePerformStakeTx()} + showLoadingIcon={isAwaitingWalletInteraction || isAwaitingPerformStakeTx || isSyncingStaking} + text={getStakeButtonText(isAwaitingWalletInteraction, isAwaitingPerformStakeTx, isSyncingStaking)} + /> + Staking causes a 3-day lockup period on all staked tokens, including tokens that are already staked. You cannot unstake any tokens during the lockup period. +
+ } +
+ + } + {mode === "leave" && + <> + + + + {"Approve pSTAKE"} + + + {"Unstake"} + + + +
+ { + ( + activeStep === 0 + ) && +
+ executePerformPStakeAllowanceTx()} + showLoadingIcon={isAwaitingPerformPStakeAllowanceTx || isAwaitingWalletInteraction} + text={getApprovePStakeButtonText(isAwaitingWalletInteraction, isAwaitingPerformPStakeAllowanceTx)} + /> +
+ } + { + ( + activeStep === 1 + ) && +
+ new Date().getTime()) || isSyncingStaking} + onClick={() => executePerformUnstakeTx()} + showLoadingIcon={isAwaitingPerformUnstakeTx || isAwaitingWalletInteraction || isSyncingStaking} + text={getUnstakeButtonText(isAwaitingWalletInteraction, isAwaitingPerformUnstakeTx, isSyncingStaking)} + /> + {(Number(stakerUnlockTime) * 1000 > new Date().getTime()) && + Locked for {countdownToTimestamp(Number(stakerUnlockTime), "")} + } +
+ } +
+ + } +
+
+ + + + Syncing Staking Contract + + <> +
+
+ +
+
+ +
+
+ {showRequireTenModal && + + + Unmet Staking Requirement + + + + In order to stake a Tier 1 PropyKey, you must have at least 10 stakeable PropyKey NFTs (this is calculated by combining your PropyKey & PropyOG balances with the quantities of PropyKey & PropyOG tokens that you have already staked). This rule only applies when trying to stake a Tier 1 PropyKey. PropyOG NFTs and PropyKeys with a Tier higher than 1 can be staked without restriction. + + + + + + + } + + } +
+ ); +} + +export default StakeEnterUnified; \ No newline at end of file diff --git a/src/containers/AllTokensBannerContainer.tsx b/src/containers/AllTokensBannerContainer.tsx deleted file mode 100644 index cc6ecc9..0000000 --- a/src/containers/AllTokensBannerContainer.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { connect, ConnectedProps } from 'react-redux'; - -import AllTokensBanner from '../components/AllTokensBanner'; - -interface RootState { - isConsideredMobile: boolean - isConsideredMedium: boolean -} - -const mapStateToProps = (state: RootState) => ({ - isConsideredMobile: state.isConsideredMobile, - isConsideredMedium: state.isConsideredMedium, -}) - -const connector = connect(mapStateToProps, {}) - -export type PropsFromRedux = ConnectedProps - -export default connector(AllTokensBanner) \ No newline at end of file diff --git a/src/containers/BridgeFinalizeWithdrawalFormContainer.tsx b/src/containers/BridgeFinalizeWithdrawalFormContainer.tsx index 9731c0d..d67f5f5 100644 --- a/src/containers/BridgeFinalizeWithdrawalFormContainer.tsx +++ b/src/containers/BridgeFinalizeWithdrawalFormContainer.tsx @@ -1,6 +1,6 @@ import { connect, ConnectedProps } from 'react-redux'; -import BridgeFinalizeWithdrawalForm from '../components/BridgeFinalizeWithdrawalForm'; +import BridgeFinalizeWithdrawalFormUnified from '../components/BridgeFinalizeWithdrawalFormUnified'; import { SupportedNetworks, @@ -22,4 +22,4 @@ const connector = connect(mapStateToProps, mapDispatchToProps) export type PropsFromRedux = ConnectedProps -export default connector(BridgeFinalizeWithdrawalForm) \ No newline at end of file +export default connector(BridgeFinalizeWithdrawalFormUnified) \ No newline at end of file diff --git a/src/containers/BridgeFormContainer.tsx b/src/containers/BridgeFormContainer.tsx index c2c8690..17f9ce9 100644 --- a/src/containers/BridgeFormContainer.tsx +++ b/src/containers/BridgeFormContainer.tsx @@ -1,6 +1,6 @@ import { connect, ConnectedProps } from 'react-redux'; -import BridgeForm from '../components/BridgeForm'; +import BridgeFormUnified from '../components/BridgeFormUnified'; import { SupportedNetworks, @@ -22,4 +22,4 @@ const connector = connect(mapStateToProps, mapDispatchToProps) export type PropsFromRedux = ConnectedProps -export default connector(BridgeForm) \ No newline at end of file +export default connector(BridgeFormUnified) \ No newline at end of file diff --git a/src/containers/BridgeProveWithdrawalFormContainer.tsx b/src/containers/BridgeProveWithdrawalFormContainer.tsx index 034df3d..2ee4f54 100644 --- a/src/containers/BridgeProveWithdrawalFormContainer.tsx +++ b/src/containers/BridgeProveWithdrawalFormContainer.tsx @@ -1,6 +1,6 @@ import { connect, ConnectedProps } from 'react-redux'; -import BridgeProveWithdrawalForm from '../components/BridgeProveWithdrawalForm'; +import BridgeProveWithdrawalFormUnified from '../components/BridgeProveWithdrawalFormUnified'; import { SupportedNetworks, @@ -22,4 +22,4 @@ const connector = connect(mapStateToProps, mapDispatchToProps) export type PropsFromRedux = ConnectedProps -export default connector(BridgeProveWithdrawalForm) \ No newline at end of file +export default connector(BridgeProveWithdrawalFormUnified) \ No newline at end of file diff --git a/src/containers/PropyKeyRepossessionContainer.tsx b/src/containers/PropyKeyRepossessionContainer.tsx index 45f4bf4..af54fd8 100644 --- a/src/containers/PropyKeyRepossessionContainer.tsx +++ b/src/containers/PropyKeyRepossessionContainer.tsx @@ -1,6 +1,6 @@ import { connect, ConnectedProps } from 'react-redux'; -import PropyKeyRepossession from '../components/PropyKeyRepossession'; +import PropyKeyRepossessionUnified from '../components/PropyKeyRepossessionUnified'; interface RootState { isConsideredMobile: boolean @@ -16,4 +16,4 @@ const connector = connect(mapStateToProps, {}) export type PropsFromRedux = ConnectedProps -export default connector(PropyKeyRepossession) \ No newline at end of file +export default connector(PropyKeyRepossessionUnified) \ No newline at end of file diff --git a/src/containers/StakePortalContainer.tsx b/src/containers/StakePortalContainer.tsx index 03ecedb..10177cb 100644 --- a/src/containers/StakePortalContainer.tsx +++ b/src/containers/StakePortalContainer.tsx @@ -1,6 +1,6 @@ import { connect, ConnectedProps } from 'react-redux'; -import StakePortal from '../components/StakePortal'; +import StakePortalUnified from '../components/StakePortalUnified'; import { SupportedNetworks, @@ -22,4 +22,4 @@ const connector = connect(mapStateToProps, mapDispatchToProps) export type PropsFromRedux = ConnectedProps -export default connector(StakePortal) \ No newline at end of file +export default connector(StakePortalUnified) \ No newline at end of file diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4904c39..8384ca2 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,6 +9,7 @@ import useStakedPROByStaker from './useStakedPROByStaker'; import useTotalStakingBalancePRO from './useTotalStakingBalancePRO'; import useApproxLeaveAmountFromShareAmount from './useApproxLeaveAmountFromShareAmount'; import useApproxStakerRewardsPending from './useApproxStakerRewardsPending'; +import useUnifiedWriteContract from './useUnifiedWriteContract'; export { useWindowSize, @@ -22,4 +23,5 @@ export { useTotalStakingBalancePRO, useApproxLeaveAmountFromShareAmount, useApproxStakerRewardsPending, + useUnifiedWriteContract, } \ No newline at end of file diff --git a/src/hooks/useUnifiedWriteContract.ts b/src/hooks/useUnifiedWriteContract.ts new file mode 100644 index 0000000..cd276fe --- /dev/null +++ b/src/hooks/useUnifiedWriteContract.ts @@ -0,0 +1,222 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'; +import { useCapabilities, useWriteContracts, useCallsStatus } from "wagmi/experimental"; +import { utils } from 'ethers'; +import { toast } from 'sonner'; + +import { API_ENDPOINT } from "../utils/constants"; + +import EntryPointV06ABI from "../abi/EntryPointV06ABI.json"; + +import { + toChecksumAddress, +} from '../utils' + +type TransactionType = 'traditional' | 'accountAbstraction'; + +const EntryPointV06Interface = new utils.Interface(EntryPointV06ABI); + +const EntryPointV06Addresses = ["0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"]; + +interface UseUnifiedTransactionProps { + contractConfig?: any; // TODO improve type + onSuccess?: () => void; + onError?: (error: any) => void; + onSettled?: () => void; + successToastMessage?: string; + fallbackErrorMessage?: string; +} + +export function useUnifiedWriteContract({ + contractConfig, + onSuccess, + onError, + onSettled, + successToastMessage, + fallbackErrorMessage, +}: UseUnifiedTransactionProps) { + + const account = useAccount(); + + const hasHandledTransaction = useRef(false); + + const [isAwaitingWalletInteraction, setIsAwaitingWalletInteraction] = useState(false); + const [isAwaitingTx, setIsAwaitingTx] = useState(false); + const [txId, setTxId] = useState(undefined); + const [hasGivenTxClosure, setHasGivenTxClosure] = useState(false); + + // Traditional transaction hooks + const { + data: traditionalData, + isPending: isLoadingTraditional, + writeContractAsync: writeTraditional + } = useWriteContract(); + + const traditionalReceipt = useWaitForTransactionReceipt({ + hash: traditionalData, + confirmations: 2, + }); + + // Account abstraction hooks + const { + data: aaData, + isPending: isLoadingAA, + writeContractsAsync: writeAA + } = useWriteContracts({ + mutation: { onSuccess: (id: string) => setTxId(id) }, + }); + + const aaCallStatus = useCallsStatus({ + id: aaData ? aaData : "", + }); + + const { data: availableCapabilities } = useCapabilities({ + account: account.address, + }); + const capabilities = useMemo(() => { + if (!availableCapabilities || !account.chainId) return {}; + const capabilitiesForChain = availableCapabilities[account.chainId]; + console.log({capabilitiesForChain}) + if ( + capabilitiesForChain["paymasterService"] && + capabilitiesForChain["paymasterService"].supported + ) { + return { + paymasterService: { + url: `${API_ENDPOINT}/paymaster`, + }, + }; + } + return {}; + }, [availableCapabilities, account.chainId]); + + const transactionType : TransactionType = useMemo(() => capabilities?.paymasterService ? 'accountAbstraction' : 'traditional', [capabilities]); + + // Unified status effect + useEffect(() => { + // console.log({transactionType, traditionalReceipt, aaData, aaCallStatus, hasGivenTxClosure, onSuccess, onError}) + if(aaCallStatus?.isStale && aaData) { + aaCallStatus.refetch(); + } + if (hasHandledTransaction.current) return; + + const handleTransactionStatus = () => { + if (transactionType === 'traditional' && traditionalReceipt) { + if (traditionalReceipt.status === 'success' && !hasGivenTxClosure) { + hasHandledTransaction.current = true; + setIsAwaitingTx(false); + setHasGivenTxClosure(true); + toast.success(successToastMessage ? successToastMessage : 'Transaction successful!'); + onSuccess?.(); + } else if (traditionalReceipt.status === 'error' && !hasGivenTxClosure) { + hasHandledTransaction.current = true; + setIsAwaitingTx(false); + setHasGivenTxClosure(true); + if(!onError) { + toast.error(traditionalReceipt.error?.message || fallbackErrorMessage || 'Transaction failed'); + } else { + onError?.(traditionalReceipt.error); + } + } + } else if (transactionType === 'accountAbstraction' && aaData && aaCallStatus?.data?.receipts && (aaCallStatus?.data?.receipts?.length > 0)) { + let containsErrorLog = false; + let errorMessage = false; + for(let receipt of aaCallStatus?.data?.receipts) { + for(let log of receipt.logs) { + if(EntryPointV06Addresses.indexOf(toChecksumAddress(log.address)) > -1) { + let parsedLog = EntryPointV06Interface.parseLog(log); + // UserOperationRevertReason (index_topic_1 bytes32 userOpHash, index_topic_2 address sender, uint256 nonce, bytes revertReason) + // 0x1c4fada7374c0a9ee8841fc38afe82932dc0f8e69012e927f061a8bae611a201 + if(parsedLog.name === "UserOperationRevertReason") { + const decodedReasonArray = utils.defaultAbiCoder.decode( + ['string'], + utils.hexDataSlice(parsedLog.args.revertReason, 4) + ) + if(decodedReasonArray[0]) { + errorMessage = decodedReasonArray[0]; + } + containsErrorLog = true; + } + } + } + } + if ((aaCallStatus?.isError || containsErrorLog) && !hasGivenTxClosure) { + hasHandledTransaction.current = true; + setIsAwaitingTx(false); + setHasGivenTxClosure(true); + if(!onError) { + toast.error(`Transaction failed${errorMessage ? ` with reason: ${errorMessage}` : ""}`); + } else { + onError?.(new Error(`Transaction failed${errorMessage ? ` with reason: ${errorMessage}` : ""}`)); + } + } else if (aaCallStatus?.status === 'success' && !hasGivenTxClosure) { + hasHandledTransaction.current = true; + setIsAwaitingTx(false); + setHasGivenTxClosure(true); + toast.success(successToastMessage ? successToastMessage : 'Transaction successful!'); + onSuccess?.(); + } + } + }; + + handleTransactionStatus(); + + return () => { + // Cleanup function + hasHandledTransaction.current = false; + }; + }, [transactionType, traditionalReceipt, aaData, aaCallStatus, hasGivenTxClosure, onSuccess, onError, successToastMessage, fallbackErrorMessage]); + + const executeTransaction = useCallback(async (overrideContractConfig: any = false) => { + hasHandledTransaction.current = false; + setHasGivenTxClosure(false); + setIsAwaitingWalletInteraction(true); + setIsAwaitingTx(true); + + let useContractConfig = overrideContractConfig || contractConfig; + + try { + if (transactionType === 'traditional') { + await writeTraditional(useContractConfig, { + onSettled: () => { + setIsAwaitingWalletInteraction(false); + onSettled?.(); + }, + }); + } else { + await writeAA({ + contracts: [useContractConfig], + capabilities, + }, { + onSettled: () => { + setIsAwaitingWalletInteraction(false); + onSettled?.(); + }, + }); + } + } catch (error: any) { + setIsAwaitingTx(false); + if(!hasGivenTxClosure) { + setHasGivenTxClosure(true); + if(!onError) { + toast.error((error as Error)?.message || error?.details || 'Unable to complete transaction, please try again or contact support.'); + } else { + onError?.(error); + } + } + } finally { + setIsAwaitingWalletInteraction(false); + } + }, [transactionType, writeTraditional, writeAA, contractConfig, onError, onSettled, capabilities, hasGivenTxClosure]); + + return { + executeTransaction, + isAwaitingWalletInteraction, + isAwaitingTx, + isLoading: transactionType === 'traditional' ? isLoadingTraditional : isLoadingAA, + txId: transactionType === 'traditional' ? null : txId, + txHash: transactionType === 'traditional' ? traditionalData : null, + }; +} + +export default useUnifiedWriteContract; \ No newline at end of file diff --git a/src/pages/PaymasterTestUnifiedPage.tsx b/src/pages/PaymasterTestUnifiedPage.tsx new file mode 100644 index 0000000..ea91591 --- /dev/null +++ b/src/pages/PaymasterTestUnifiedPage.tsx @@ -0,0 +1,296 @@ +import React, { useEffect, useMemo } from 'react'; + +import NetworkGateContainer from '../containers/NetworkGateContainer'; +import FloatingActionButton from '../components/FloatingActionButton'; + +import { useAccount, useReadContract, useBlockNumber } from "wagmi"; +import { useCapabilities } from "wagmi/experimental"; + +import { useUnifiedWriteContract } from '../hooks/useUnifiedWriteContract'; + +import { API_ENDPOINT } from "../utils/constants"; + +export const ERC20ABI = [ + { + "inputs": [ + { + "internalType": "address", + "name": "spender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "spender", + "type": "address" + } + ], + "name": "allowance", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "failOnOddMinute", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "function" + } +] as const; + +const PRO_BASE_SEPOLIA = "0x3660925E58444955c4812e42A572e532e69Dac7B"; +// prod: 0x18dD5B087bCA9920562aFf7A0199b96B9230438b +// sepolia: 0x3660925E58444955c4812e42A572e532e69Dac7B + +const randomAllowanceValue = Math.floor(Math.random() * 1000000); + +const PaymasterTestUnifiedPage = () => { + + const account = useAccount(); + + const { data: availableCapabilities } = useCapabilities({ + account: account.address, + }); + const capabilities = useMemo(() => { + if (!availableCapabilities || !account.chainId) return {}; + const capabilitiesForChain = availableCapabilities[account.chainId]; + console.log({capabilitiesForChain}) + if ( + capabilitiesForChain["paymasterService"] && + capabilitiesForChain["paymasterService"].supported + ) { + return { + paymasterService: { + url: `${API_ENDPOINT}/paymaster`, + }, + }; + } + return {}; + }, [availableCapabilities, account.chainId]); + + // WRITE SUCCESSFUL BELOW + + const { + executeTransaction, + isAwaitingWalletInteraction, + isAwaitingTx: isAwaitingApproveTx, + isLoading: isLoadingApprove, + txHash, + txId, + } = useUnifiedWriteContract({ + contractConfig: { + address: PRO_BASE_SEPOLIA, + abi: ERC20ABI, + functionName: "approve", + args: [PRO_BASE_SEPOLIA, randomAllowanceValue], + }, + onSuccess: () => { + + }, + onError: () => { + + }, + onSettled: () => {}, + }); + + const runApproval = () => { + executeTransaction(); + }; + + const getApproveButtonText = (waitingForWallet: boolean, waitingForTransaction: boolean) => { + if(waitingForWallet) { + return "Please Check Wallet..."; + } + if(waitingForTransaction) { + return "Awaiting Transaction"; + } + return "Approve"; + } + + // WRITE SUCCESSFUL ABOVE + + // WRITE INTENTIONAL FAILURE BELOW + + const { + executeTransaction: executeFailingTx, + isAwaitingWalletInteraction: isAwaitingWalletInteractionFailureTx, + isAwaitingTx: isAwaitingFailureTx, + isLoading: isLoadingFailure, + txHash: txHashFailure, + txId: txIdFailure, + } = useUnifiedWriteContract({ + contractConfig: { + address: "0xf6030646B9Df26a00bAbcBef2aE91Eab00405a56", + abi: ERC20ABI, + functionName: "failOnOddMinute", + args: [], + }, + onSuccess: () => { + + }, + onError: () => { + + }, + onSettled: () => {}, + }); + + const runFailure = () => { + executeFailingTx(); + }; + + const getFailureButtonText = (waitingForWallet: boolean, waitingForTransaction: boolean) => { + if(waitingForWallet) { + return "Please Check Wallet..."; + } + if(waitingForTransaction) { + return "Awaiting Transaction"; + } + return "Init failure tx"; + } + + // WRITE INTENTIONAL FAILURE ABOVE + + const { + data: dataL2BridgePROAllowance, + refetch: refetchDataL2BridgePROAllowance, + } = useReadContract({ + address: PRO_BASE_SEPOLIA, + abi: ERC20ABI, + functionName: 'allowance', + args: [account.address ? account.address : PRO_BASE_SEPOLIA, PRO_BASE_SEPOLIA], + }) + + const { data: blockNumber } = useBlockNumber({ watch: true }) + + useEffect(() => { + refetchDataL2BridgePROAllowance() + }, [blockNumber, refetchDataL2BridgePROAllowance]) + + return ( + +
+

Unified Transaction Testing

+

Should seamlessly support EOA wallets & Coinbase Smart Wallet

+

Should support sponsored transactions with Coinbase Smart Wallet

+

{JSON.stringify(capabilities)}

+
+
+ runApproval()} + showLoadingIcon={isAwaitingWalletInteraction || isAwaitingApproveTx || isLoadingApprove} + text={getApproveButtonText(isAwaitingWalletInteraction, isAwaitingApproveTx)} + /> +
+              
+ {`capabilities: ${JSON.stringify(capabilities)}`} +
+ {`id: ${txId}`} +
+ {`txHash: ${txHash}`} +
+ {`randomAllowanceValue: ${randomAllowanceValue}`} +
+ {`dataL2BridgePROAllowance: ${dataL2BridgePROAllowance}`} +
+ {`Allowance: ${Number(dataL2BridgePROAllowance ? dataL2BridgePROAllowance : 0)}`} +
+ {`isLoadingApprove: ${isLoadingApprove}`} +
+
+
+ runFailure()} + showLoadingIcon={isAwaitingWalletInteractionFailureTx || isAwaitingFailureTx || isLoadingFailure} + text={getFailureButtonText(isAwaitingWalletInteractionFailureTx, isAwaitingFailureTx)} + /> +
This button can be used to test failing transactions. Click this button during an even minute so that MetaMask or Coinbase smart wallet run the gas calculation and tx simulation at a time when the function will work, once the clock ticks over to an odd number, submit the transaction (transaction will succeed during even minutes and fail during odd minutes, we "trick" our wallet into thinking the transaction will pass by simulating it during the even minute and then submitting on an odd minute). If you click this button during an odd minute, it probably won't let you submit the transaction in the first place, since it will detect that the tx will fail before it lets you submit it.
+
+              
+ {`capabilities: ${JSON.stringify(capabilities)}`} +
+ {`id: ${txIdFailure}`} +
+ {`txHash: ${txHashFailure}`} +
+ {`isLoadingFailureTx: ${isLoadingFailure}`} +
+
+
+
+
+ ); +}; + +export default PaymasterTestUnifiedPage; \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index a1d4eba..41641aa 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -6,8 +6,8 @@ export const STAKING_CONTRACT = '0x00000000219ab540356cBB839Cbe05303d7705Fa' export const ENV_TO_API_ENDPOINT : {[key: string]: string} = { "local": "http://localhost:8420", - "dev": "https://dev.dappapi.propy.com/", - "prod": "https://dappapi.propy.com/", + "dev": "https://dev.dappapi.propy.com", + "prod": "https://dappapi.propy.com", } // export const API_ENDPOINT = "http://localhost:8420"; @@ -27,6 +27,8 @@ export const TOKEN_NAME_PREFIX : {[key: string]: string} = { export const RECAPTCHA_KEY = process.env.REACT_APP_RECAPTCHA_KEY; +export const GOOGLE_ANALYTICS_ID = process?.env?.REACT_APP_ENV === 'prod' ? false : false; + export const TOKEN_NAME_HIDE_ID : {[key: string]: boolean} = { "0x37f6091feF42eFD50d4F07a91c955606e8dE38c2": true, "0x8fbFe4036F13e8E42E51235C9adA6efD2ACF6A95": true, @@ -85,11 +87,10 @@ const COLLECTIONS_ENTRIES_PROD : ICollectionEntry[] = [ { network: "base", address: "0xa239b9b3E00637F29f6c7C416ac95127290b950E", - slug: "propykeys?landmark=true&sort_by=most_liked", + slug: "propykeys?landmark=true", overrideTitle: "PropyKeys AI Landmarks", optimisticTitle: "PropyKeys AI Landmarks", filterShims: ["landmark"], - sortBy: "most_liked", showInMultiCollectionGallery: true, collectionType: "NFT", },