From 7b803fb82fb034f2bdf12d0ec41416ad3d9b75ec Mon Sep 17 00:00:00 2001 From: belopash Date: Tue, 24 Dec 2024 21:26:47 +0500 Subject: [PATCH] feat: add BuyBack contracts page --- src/AppRoutes.tsx | 4 + src/api/contracts/subsquid.generated.ts | 72 +++++++++ src/network/useContracts.ts | 3 + src/pages/BuyBackPage/BuyBackName.tsx | 26 ++++ src/pages/BuyBackPage/BuyBackPage.tsx | 117 +++++++++++++++ src/pages/BuyBackPage/DepositButton.tsx | 191 ++++++++++++++++++++++++ wagmi.config.ts | 22 +++ 7 files changed, 435 insertions(+) create mode 100644 src/pages/BuyBackPage/BuyBackName.tsx create mode 100644 src/pages/BuyBackPage/BuyBackPage.tsx create mode 100644 src/pages/BuyBackPage/DepositButton.tsx diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 6ee64f9..53d2861 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -5,6 +5,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { NetworkLayout } from '@layouts/NetworkLayout'; import { AssetsPage } from '@pages/AssetsPage/AssetsPage.tsx'; import { Vesting } from '@pages/AssetsPage/Vesting.tsx'; +import { BuyBacksPage } from '@pages/BuyBackPage/BuyBackPage.tsx'; import { DashboardPage } from '@pages/DashboardPage/DashboardPage.tsx'; import { DelegationsPage } from '@pages/DelegationsPage/DelegationsPage.tsx'; import { Gateway } from '@pages/GatewaysPage/Gateway.tsx'; @@ -43,6 +44,9 @@ export const AppRoutes = () => { } path=":peerId" /> } /> + + } index /> + } path="*" /> diff --git a/src/api/contracts/subsquid.generated.ts b/src/api/contracts/subsquid.generated.ts index 9ff2a35..12e03dd 100644 --- a/src/api/contracts/subsquid.generated.ts +++ b/src/api/contracts/subsquid.generated.ts @@ -28,6 +28,30 @@ export const arbMulticallAbi = [ }, ] as const +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// BuyBack +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const buyBackAbi = [ + { + type: 'function', + inputs: [{ name: 'amount', internalType: 'uint256', type: 'uint256' }], + name: 'deposit', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'receiver', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + ], + name: 'withdraw', + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // GatewayRegistry ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -664,6 +688,54 @@ export const useReadArbMulticallGetL1BlockNumber = functionName: 'getL1BlockNumber', }) +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link buyBackAbi}__ + */ +export const useWriteBuyBack = /*#__PURE__*/ createUseWriteContract({ + abi: buyBackAbi, +}) + +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link buyBackAbi}__ and `functionName` set to `"deposit"` + */ +export const useWriteBuyBackDeposit = /*#__PURE__*/ createUseWriteContract({ + abi: buyBackAbi, + functionName: 'deposit', +}) + +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link buyBackAbi}__ and `functionName` set to `"withdraw"` + */ +export const useWriteBuyBackWithdraw = /*#__PURE__*/ createUseWriteContract({ + abi: buyBackAbi, + functionName: 'withdraw', +}) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link buyBackAbi}__ + */ +export const useSimulateBuyBack = /*#__PURE__*/ createUseSimulateContract({ + abi: buyBackAbi, +}) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link buyBackAbi}__ and `functionName` set to `"deposit"` + */ +export const useSimulateBuyBackDeposit = + /*#__PURE__*/ createUseSimulateContract({ + abi: buyBackAbi, + functionName: 'deposit', + }) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link buyBackAbi}__ and `functionName` set to `"withdraw"` + */ +export const useSimulateBuyBackWithdraw = + /*#__PURE__*/ createUseSimulateContract({ + abi: buyBackAbi, + functionName: 'withdraw', + }) + /** * Wraps __{@link useReadContract}__ with `abi` set to __{@link gatewayRegistryAbi}__ */ diff --git a/src/network/useContracts.ts b/src/network/useContracts.ts index 63c2cea..4215692 100644 --- a/src/network/useContracts.ts +++ b/src/network/useContracts.ts @@ -14,6 +14,7 @@ export function useContracts(): { SQD_TOKEN: string; CHAIN_ID_L1: number; MULTICALL: `0x${string}`; + BUYBACK: `0x${string}`; } { const network = getSubsquidNetwork(); @@ -31,6 +32,7 @@ export function useContracts(): { ROUTER: '0xD2093610c5d27c201CD47bCF1Df4071610114b64', CHAIN_ID_L1: sepolia.id, MULTICALL: '0x7eCfBaa8742fDf5756DAC92fbc8b90a19b8815bF', + BUYBACK: '0xe34189ad45044e93d3af7d93ac520d02651faf72', }; } case NetworkName.Mainnet: { @@ -46,6 +48,7 @@ export function useContracts(): { ROUTER: '0x67F56D27dab93eEb07f6372274aCa277F49dA941', CHAIN_ID_L1: mainnet.id, MULTICALL: '0x7eCfBaa8742fDf5756DAC92fbc8b90a19b8815bF', + BUYBACK: '0x4efab28e320ef16907930a06e2a5aaadb7425b48', }; } } diff --git a/src/pages/BuyBackPage/BuyBackName.tsx b/src/pages/BuyBackPage/BuyBackName.tsx new file mode 100644 index 0000000..3fc9b94 --- /dev/null +++ b/src/pages/BuyBackPage/BuyBackName.tsx @@ -0,0 +1,26 @@ +import { addressFormatter } from '@lib/formatters/formatters'; +import { Box, Stack, styled, Typography } from '@mui/material'; + +import { Avatar } from '@components/Avatar'; +import { CopyToClipboard } from '@components/CopyToClipboard'; + +const Name = styled(Box, { + name: 'Name', +})(({ theme }) => ({ + marginBottom: theme.spacing(0.25), + whiteSpace: 'nowrap', +})); + +export function SourceWalletName({ source }: { source: { id: string } }) { + return ( + + + + Contract + + + + + + ); +} diff --git a/src/pages/BuyBackPage/BuyBackPage.tsx b/src/pages/BuyBackPage/BuyBackPage.tsx new file mode 100644 index 0000000..74171f4 --- /dev/null +++ b/src/pages/BuyBackPage/BuyBackPage.tsx @@ -0,0 +1,117 @@ +import { tokenFormatter } from '@lib/formatters/formatters'; +import { fromSqd, unwrapMulticallResult } from '@lib/network/utils'; +import { Warning } from '@mui/icons-material'; +import { Alert, Box, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; +import { keepPreviousData } from '@tanstack/react-query'; +import { Outlet } from 'react-router-dom'; +import { erc20Abi } from 'viem'; +import { useReadContracts } from 'wagmi'; + +import { useSourcesQuery, useSquid } from '@api/subsquid-network-squid'; +import SquaredChip from '@components/Chip/SquaredChip'; +import { DashboardTable, NoItems } from '@components/Table'; +import { CenteredPageWrapper } from '@layouts/NetworkLayout'; +import { ConnectedWalletRequired } from '@network/ConnectedWalletRequired'; +import { useAccount } from '@network/useAccount'; +import { useContracts } from '@network/useContracts'; + +import { SourceWalletName } from './BuyBackName'; +import { DepositButton } from './DepositButton'; + +export function OtcContracts() { + const account = useAccount(); + const squid = useSquid(); + + const { data: sourcesQuery, isLoading: isSourcesQueryLoading } = useSourcesQuery(squid, { + address: account.address as `0x${string}`, + }); + const { SQD_TOKEN, SQD, BUYBACK } = useContracts(); + + const BUYBACKs = [BUYBACK]; + const sources = sourcesQuery?.accounts; + + const { data: balances, isLoading: isBalancesLoading } = useReadContracts({ + contracts: BUYBACKs.flatMap(s => { + return [ + { + abi: erc20Abi, + address: SQD, + functionName: 'balanceOf', + args: [s as `0x${string}`], + }, + ] as const; + }), + allowFailure: true, + query: { + placeholderData: keepPreviousData, + select: res => { + if (res?.some(r => r.status === 'success')) { + return res.map(v => ({ + balance: unwrapMulticallResult(v), + })); + } else if (res?.length === 0) { + return []; + } + + return undefined; + }, + }, + }); + + const isLoading = isSourcesQueryLoading || isBalancesLoading; + + return ( + }> + + + Contract + Balance + + + + + {BUYBACKs.length ? ( + <> + {BUYBACKs.map((address, i) => { + const d = balances?.[i]; + return ( + + + + + {tokenFormatter(fromSqd(d?.balance), SQD_TOKEN)} + + + + + + + ); + })} + + ) : ( + + No vesting was found + + )} + + + ); +} + +export function BuyBacksPage() { + return ( + + + }> + + Please do not deposit tokens until you know what you are doing. It won't be possible to + return them back! + + + + + + + ); +} diff --git a/src/pages/BuyBackPage/DepositButton.tsx b/src/pages/BuyBackPage/DepositButton.tsx new file mode 100644 index 0000000..c1fa63f --- /dev/null +++ b/src/pages/BuyBackPage/DepositButton.tsx @@ -0,0 +1,191 @@ +import React, { useMemo, useState } from 'react'; + +import { fromSqd, toSqd } from '@lib/network/utils'; +import { LoadingButton } from '@mui/lab'; +import { Chip } from '@mui/material'; +import * as yup from '@schema'; +import { useFormik } from 'formik'; +import toast from 'react-hot-toast'; + +import { buyBackAbi } from '@api/contracts'; +import { useWriteSQDTransaction } from '@api/contracts/useWriteTransaction'; +import { errorMessage } from '@api/contracts/utils'; +import { AccountType, SourceWalletWithBalance } from '@api/subsquid-network-squid'; +import { ContractCallDialog } from '@components/ContractCallDialog'; +import { Form, FormikSelect, FormikTextInput, FormRow } from '@components/Form'; +import { SourceWalletOption } from '@components/SourceWallet'; +import { useSquidHeight } from '@hooks/useSquidNetworkHeightHooks'; + +export const depositSchema = yup.object({ + source: yup.string().label('Source').trim().required().typeError('${path} is invalid'), + amount: yup + .decimal() + .label('Amount') + .required() + .positive() + .max(yup.ref('max'), 'Insufficient balance') + .typeError('${path} is invalid'), + max: yup.string().label('Max').required().typeError('${path} is invalid'), +}); + +export function DepositButton({ + sources, + address, + disabled, + variant = 'outlined', +}: { + sources?: SourceWalletWithBalance[]; + address?: string; + variant?: 'outlined' | 'contained'; + disabled?: boolean; +}) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} + variant={variant} + color={variant === 'contained' ? 'info' : 'secondary'} + > + DEPOSIT + + setOpen(false)} + sources={sources} + address={address} + /> + + ); +} + +export function DepositDialog({ + open, + sources, + address, + onClose, +}: { + open: boolean; + sources?: SourceWalletWithBalance[]; + address?: string; + onClose: () => void; +}) { + const { writeTransactionAsync, isPending } = useWriteSQDTransaction(); + + const { setWaitHeight } = useSquidHeight(); + + const isSourceDisabled = (source: SourceWalletWithBalance) => source.balance === '0'; + + const initialValues = useMemo(() => { + const source = sources?.find(c => !isSourceDisabled(c)) || sources?.[0]; + + return { + source: source?.id || '', + amount: '0', + max: fromSqd(source?.balance).toString(), + }; + }, [sources]); + + const formik = useFormik({ + initialValues, + validationSchema: depositSchema, + validateOnChange: true, + validateOnBlur: true, + validateOnMount: true, + enableReinitialize: true, + + onSubmit: async values => { + if (!address) return; + + try { + const { amount, source: sourceId } = depositSchema.cast(values); + + const source = sources?.find(w => w?.id === sourceId); + if (!source) return; + + const sqdAmount = BigInt(toSqd(amount)); + + const receipt = await writeTransactionAsync({ + abi: buyBackAbi, + address: address as `0x${string}`, + functionName: 'deposit', + args: [sqdAmount], + vesting: source.type === AccountType.Vesting ? (source.id as `0x${string}`) : undefined, + approve: sqdAmount, + }); + setWaitHeight(receipt.blockNumber, []); + + onClose(); + } catch (e) { + toast.error(errorMessage(e)); + } + }, + }); + + return ( + { + if (!confirmed) return onClose(); + + formik.handleSubmit(); + }} + loading={isPending} + disableConfirmButton={!formik.isValid} + > +
+ + { + return { + label: , + value: s.id, + disabled: isSourceDisabled(s), + }; + }) || [] + } + formik={formik} + onChange={e => { + const source = sources?.find(w => w?.id === e.target.value); + if (!source) return; + + formik.setFieldValue('source', source.id); + formik.setFieldValue('max', fromSqd(source.balance).toString()); + }} + /> + + + { + formik.setValues({ + ...formik.values, + amount: formik.values.max, + }); + }} + label="Max" + /> + ), + }} + /> + +
+
+ ); +} diff --git a/wagmi.config.ts b/wagmi.config.ts index cd985f2..404f3f1 100644 --- a/wagmi.config.ts +++ b/wagmi.config.ts @@ -852,6 +852,28 @@ export default defineConfig({ }, ], }, + { + name: 'BuyBack', + abi: [ + { + type: 'function', + name: 'deposit', + inputs: [{ name: 'amount', type: 'uint256', internalType: 'uint256' }], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'withdraw', + inputs: [ + { name: 'receiver', type: 'address', internalType: 'address' }, + { name: 'amount', type: 'uint256', internalType: 'uint256' }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + ], + }, ], plugins: [react({})], });