From 2440fa4243ee21e336e83f15df7a72ea492ae5f4 Mon Sep 17 00:00:00 2001 From: belopash Date: Tue, 24 Dec 2024 21:26:47 +0500 Subject: [PATCH] feat: add OTC contracts page --- src/AppRoutes.tsx | 4 + src/api/contracts/subsquid.generated.ts | 73 ++++++++++ src/network/useContracts.ts | 3 + src/pages/OtcPage/DepositButton.tsx | 186 ++++++++++++++++++++++++ src/pages/OtcPage/OtcName.tsx | 26 ++++ src/pages/OtcPage/OtcPage.tsx | 120 +++++++++++++++ wagmi.config.ts | 22 +++ 7 files changed, 434 insertions(+) create mode 100644 src/pages/OtcPage/DepositButton.tsx create mode 100644 src/pages/OtcPage/OtcName.tsx create mode 100644 src/pages/OtcPage/OtcPage.tsx diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 6ee64f9..01d2e4f 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -9,6 +9,7 @@ import { DashboardPage } from '@pages/DashboardPage/DashboardPage.tsx'; import { DelegationsPage } from '@pages/DelegationsPage/DelegationsPage.tsx'; import { Gateway } from '@pages/GatewaysPage/Gateway.tsx'; import { GatewaysPage } from '@pages/GatewaysPage/GatewaysPage.tsx'; +import { OtcContractsPage } from '@pages/OtcPage/OtcPage.tsx'; import { Worker } from '@pages/WorkersPage/Worker.tsx'; import { WorkersPage } from '@pages/WorkersPage/WorkersPage.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..2e68fad 100644 --- a/src/api/contracts/subsquid.generated.ts +++ b/src/api/contracts/subsquid.generated.ts @@ -184,6 +184,30 @@ export const networkControllerAbi = [ }, ] as const +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// OverTheCounter +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +export const overTheCounterAbi = [ + { + 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 + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // RewardTreasury ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -898,6 +922,55 @@ export const useReadNetworkControllerWorkerEpochLength = functionName: 'workerEpochLength', }) +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link overTheCounterAbi}__ + */ +export const useWriteOverTheCounter = /*#__PURE__*/ createUseWriteContract({ + abi: overTheCounterAbi, +}) + +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link overTheCounterAbi}__ and `functionName` set to `"deposit"` + */ +export const useWriteOverTheCounterDeposit = + /*#__PURE__*/ createUseWriteContract({ + abi: overTheCounterAbi, + functionName: 'deposit', + }) + +/** + * Wraps __{@link useWriteContract}__ with `abi` set to __{@link overTheCounterAbi}__ and `functionName` set to `"withdraw"` + */ +export const useWriteOverTheCounterWithdraw = + /*#__PURE__*/ createUseWriteContract({ + abi: overTheCounterAbi, + functionName: 'withdraw', + }) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link overTheCounterAbi}__ + */ +export const useSimulateOverTheCounter = + /*#__PURE__*/ createUseSimulateContract({ abi: overTheCounterAbi }) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link overTheCounterAbi}__ and `functionName` set to `"deposit"` + */ +export const useSimulateOverTheCounterDeposit = + /*#__PURE__*/ createUseSimulateContract({ + abi: overTheCounterAbi, + functionName: 'deposit', + }) + +/** + * Wraps __{@link useSimulateContract}__ with `abi` set to __{@link overTheCounterAbi}__ and `functionName` set to `"withdraw"` + */ +export const useSimulateOverTheCounterWithdraw = + /*#__PURE__*/ createUseSimulateContract({ + abi: overTheCounterAbi, + functionName: 'withdraw', + }) + /** * Wraps __{@link useWriteContract}__ with `abi` set to __{@link rewardTreasuryAbi}__ */ diff --git a/src/network/useContracts.ts b/src/network/useContracts.ts index 63c2cea..3c3eca8 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}`; + OTC: `0x${string}`; } { const network = getSubsquidNetwork(); @@ -31,6 +32,7 @@ export function useContracts(): { ROUTER: '0xD2093610c5d27c201CD47bCF1Df4071610114b64', CHAIN_ID_L1: sepolia.id, MULTICALL: '0x7eCfBaa8742fDf5756DAC92fbc8b90a19b8815bF', + OTC: '0xe34189ad45044e93d3af7d93ac520d02651faf72', }; } case NetworkName.Mainnet: { @@ -46,6 +48,7 @@ export function useContracts(): { ROUTER: '0x67F56D27dab93eEb07f6372274aCa277F49dA941', CHAIN_ID_L1: mainnet.id, MULTICALL: '0x7eCfBaa8742fDf5756DAC92fbc8b90a19b8815bF', + OTC: '0x1c77ad535552E7428630bCDF5B10B1E992F9f16c', }; } } diff --git a/src/pages/OtcPage/DepositButton.tsx b/src/pages/OtcPage/DepositButton.tsx new file mode 100644 index 0000000..bef1085 --- /dev/null +++ b/src/pages/OtcPage/DepositButton.tsx @@ -0,0 +1,186 @@ +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 { overTheCounterAbi } 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, + otc, + disabled, + variant = 'outlined', +}: { + sources?: SourceWalletWithBalance[]; + otc?: 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} otc={otc} /> + + ); +} + +export function DepositDialog({ + open, + sources, + otc, + onClose, +}: { + open: boolean; + sources?: SourceWalletWithBalance[]; + otc?: 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 (!otc) 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: overTheCounterAbi, + address: otc 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/src/pages/OtcPage/OtcName.tsx b/src/pages/OtcPage/OtcName.tsx new file mode 100644 index 0000000..3fc9b94 --- /dev/null +++ b/src/pages/OtcPage/OtcName.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/OtcPage/OtcPage.tsx b/src/pages/OtcPage/OtcPage.tsx new file mode 100644 index 0000000..b26d016 --- /dev/null +++ b/src/pages/OtcPage/OtcPage.tsx @@ -0,0 +1,120 @@ +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 { DepositButton } from './DepositButton'; +import { SourceWalletName } from './OtcName'; + +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, OTC } = useContracts(); + + const OTCs = [OTC]; + const sources = sourcesQuery?.accounts; + + const { data: balances, isLoading: isBalancesLoading } = useReadContracts({ + contracts: OTCs.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 + + + + + {OTCs.length ? ( + <> + {OTCs.map((address, i) => { + const d = balances?.[i]; + return ( + + + + + {tokenFormatter(fromSqd(d?.balance), SQD_TOKEN)} + + + + + + + ); + })} + + ) : ( + + No vesting was found + + )} + + + ); +} + +export function OtcContractsPage() { + 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/wagmi.config.ts b/wagmi.config.ts index cd985f2..4c4dfc9 100644 --- a/wagmi.config.ts +++ b/wagmi.config.ts @@ -852,6 +852,28 @@ export default defineConfig({ }, ], }, + { + name: 'OverTheCounter', + 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({})], });