diff --git a/package.json b/package.json index 4417ad5..d0d9559 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "use-element-position": "^1.0.13", "use-local-storage-state": "^19.2.0", "viem": "^1.21.1", - "wagmi": "^1.4.12", + "wagmi": "^1.4.13", "yup": "^1.4.0" }, "devDependencies": { diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index da93c5e..3f50a4b 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -3,6 +3,8 @@ import React from 'react'; 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 { DashboardPage } from '@pages/DashboardPage/DashboardPage.tsx'; import { DelegationsPage } from '@pages/DelegationsPage/DelegationsPage.tsx'; import { AddNewGateway } from '@pages/GatewaysPage/AddNewGateway.tsx'; @@ -24,6 +26,10 @@ export const AppRoutes = () => { } index /> } path="workers/:peerId" /> + } path="/assets"> + } index /> + } path="vestings/:address" /> + } path="/workers"> } index /> } path="add" /> diff --git a/src/api/contracts/vesting.abi.ts b/src/api/contracts/vesting.abi.ts index 6c0c1cc..7c2108e 100644 --- a/src/api/contracts/vesting.abi.ts +++ b/src/api/contracts/vesting.abi.ts @@ -1,4 +1,43 @@ export const VESTING_CONTRACT_ABI = [ + { + type: 'function', + name: 'depositedIntoProtocol', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'duration', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'end', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, { type: 'function', name: 'execute', @@ -46,4 +85,131 @@ export const VESTING_CONTRACT_ABI = [ ], stateMutability: 'nonpayable', }, + { + type: 'function', + name: 'expectedTotalAmount', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'immediateReleaseBIP', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'releasable', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'releasable', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'release', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + name: 'released', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'start', + inputs: [], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'vestedAmount', + inputs: [ + { + name: 'token', + type: 'address', + internalType: 'address', + }, + { + name: 'timestamp', + type: 'uint64', + internalType: 'uint64', + }, + ], + outputs: [ + { + name: '', + type: 'uint256', + internalType: 'uint256', + }, + ], + stateMutability: 'view', + }, ] as const; diff --git a/src/api/contracts/vesting.ts b/src/api/contracts/vesting.ts new file mode 100644 index 0000000..31a8254 --- /dev/null +++ b/src/api/contracts/vesting.ts @@ -0,0 +1,125 @@ +import { useState } from 'react'; + +import { chunk } from 'lodash-es'; +import { MulticallResult } from 'viem'; +import { useContractReads, useContractWrite, usePublicClient } from 'wagmi'; + +import { useSquidNetworkHeightHooks } from '@hooks/useSquidNetworkHeightHooks'; +import { useContracts } from '@network/useContracts'; + +import { errorMessage, WriteContractRes } from './utils'; +import { VESTING_CONTRACT_ABI } from './vesting.abi'; + +export function useVestings({ addresses }: { addresses?: `0x${string}`[] }) { + const contracts = useContracts(); + const { currentHeight } = useSquidNetworkHeightHooks(); + + const { data, isLoading } = useContractReads({ + contracts: addresses?.flatMap(address => { + const vestingContract = { abi: VESTING_CONTRACT_ABI, address } as const; + return [ + { + ...vestingContract, + functionName: 'start', + }, + { + ...vestingContract, + functionName: 'end', + }, + { + ...vestingContract, + functionName: 'depositedIntoProtocol', + }, + { + ...vestingContract, + functionName: 'releasable', + args: [contracts.SQD], + }, + { + ...vestingContract, + functionName: 'released', + args: [contracts.SQD], + }, + { + ...vestingContract, + functionName: 'expectedTotalAmount', + }, + { + ...vestingContract, + functionName: 'immediateReleaseBIP', + }, + ] as const; + }), + allowFailure: true, + enabled: !!addresses, + blockNumber: currentHeight ? BigInt(currentHeight) : undefined, + }); + + return { + data: data + ? chunk(data, 7).map(ch => ({ + start: unwrapResult(ch[0])?.toString(), + end: unwrapResult(ch[1])?.toString(), + deposited: unwrapResult(ch[2])?.toString(), + releasable: unwrapResult(ch[3])?.toString(), + released: unwrapResult(ch[4])?.toString(), + total: unwrapResult(ch[5])?.toString(), + initialRelease: Number(unwrapResult(ch[6]) || 0) / 100, + })) + : undefined, + isLoading, + }; +} + +export function useVesting({ address }: { address?: `0x${string}` }) { + const { data, isLoading } = useVestings({ addresses: address ? [address] : undefined }); + + return { + data: data?.[0], + isLoading, + }; +} + +function unwrapResult(result?: MulticallResult): T | undefined { + return result?.status === 'success' ? (result.result as T) : undefined; +} + +export function useVestingRelease({ address }: { address?: `0x${string}` }) { + const client = usePublicClient(); + const { setWaitHeight } = useSquidNetworkHeightHooks(); + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { SQD } = useContracts(); + + const { writeAsync } = useContractWrite({ + abi: VESTING_CONTRACT_ABI, + functionName: 'release', + args: [SQD], + address, + }); + + const release = async (): Promise => { + setLoading(true); + + try { + const tx = await writeAsync(); + + const receipt = await client.waitForTransactionReceipt(tx); + setWaitHeight(receipt.blockNumber, []); + + return { success: true }; + } catch (e) { + const failedReason = errorMessage(e); + setError(failedReason); + return { success: false, failedReason }; + } finally { + setLoading(false); + } + }; + + return { + release, + isLoading, + error, + }; +} diff --git a/src/layouts/NetworkLayout/NetworkMenu.tsx b/src/layouts/NetworkLayout/NetworkMenu.tsx index 3594b3c..111e191 100644 --- a/src/layouts/NetworkLayout/NetworkMenu.tsx +++ b/src/layouts/NetworkLayout/NetworkMenu.tsx @@ -142,6 +142,7 @@ export const NetworkMenu = ({ onItemClick }: NetworkMenuProps) => { {/*/>*/} + diff --git a/src/lib/formatters/formatters.ts b/src/lib/formatters/formatters.ts index ec65042..90eae13 100644 --- a/src/lib/formatters/formatters.ts +++ b/src/lib/formatters/formatters.ts @@ -1,4 +1,5 @@ import prettyBytes from 'pretty-bytes'; +import { zeroAddress } from 'viem'; import { getAddress } from 'viem/utils'; export function percentFormatter(value?: number | string) { @@ -23,7 +24,9 @@ export function bytesFormatter(val?: number | string) { return prettyBytes(Number(val), { maximumFractionDigits: 0 }); } -export function addressFormatter(val: string, shortify?: boolean) { +export function addressFormatter(val?: string, shortify?: boolean) { + val = val || zeroAddress; + const address = getAddress(val); return shortify ? `${address.substring(0, 6)}...${address?.slice(-4)}` : address; } diff --git a/src/pages/DashboardPage/Assets.tsx b/src/pages/AssetsPage/Assets.tsx similarity index 100% rename from src/pages/DashboardPage/Assets.tsx rename to src/pages/AssetsPage/Assets.tsx diff --git a/src/pages/AssetsPage/AssetsPage.tsx b/src/pages/AssetsPage/AssetsPage.tsx new file mode 100644 index 0000000..32d2a6e --- /dev/null +++ b/src/pages/AssetsPage/AssetsPage.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +import { Box } from '@mui/material'; +import { Outlet } from 'react-router-dom'; + +import { CenteredPageWrapper } from '@layouts/NetworkLayout'; + +import { MyAssets } from './Assets'; +import { MyVestings } from './Vestings'; + +export function AssetsPage() { + return ( + + + + + + + ); +} diff --git a/src/pages/DashboardPage/ClaimButton.tsx b/src/pages/AssetsPage/ClaimButton.tsx similarity index 100% rename from src/pages/DashboardPage/ClaimButton.tsx rename to src/pages/AssetsPage/ClaimButton.tsx diff --git a/src/pages/AssetsPage/ReleaseButton.tsx b/src/pages/AssetsPage/ReleaseButton.tsx new file mode 100644 index 0000000..ea3d277 --- /dev/null +++ b/src/pages/AssetsPage/ReleaseButton.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; + +import { addressFormatter } from '@lib/formatters/formatters'; +import { Button } from '@mui/material'; +import { useFormik } from 'formik'; +import * as yup from 'yup'; + +import { fromSqd } from '@api/contracts/utils'; +import { useVesting, useVestingRelease } from '@api/contracts/vesting'; +import { BlockchainContractError } from '@components/BlockchainContractError'; +import { ContractCallDialog } from '@components/ContractCallDialog'; +import { Form, FormikSelect, FormRow } from '@components/Form'; +import { Loader } from '@components/Loader'; + +export const claimSchema = yup.object({ + source: yup.string().label('Source').trim().required('Source is required'), +}); + +export function ReleaseButton({ + vesting, + disabled, +}: { + vesting: { address: string }; + disabled?: boolean; +}) { + const { release, error, isLoading } = useVestingRelease({ + address: vesting.address as `0x${string}`, + }); + const { data, isLoading: isVestingLoading } = useVesting({ + address: vesting.address as `0x${string}`, + }); + + const formik = useFormik({ + initialValues: { + source: vesting.address, + amount: 0, + }, + validationSchema: claimSchema, + validateOnChange: true, + validateOnBlur: true, + validateOnMount: true, + + onSubmit: async () => { + const { failedReason } = await release(); + + if (!failedReason) { + handleClose(); + } + }, + }); + + const [open, setOpen] = useState(false); + const handleOpen = (e: React.UIEvent) => { + e.stopPropagation(); + setOpen(true); + }; + const handleClose = () => setOpen(false); + + return ( + <> + + { + if (!confirmed) return handleClose(); + + formik.handleSubmit(); + }} + loading={isLoading} + > + {isVestingLoading ? ( + + ) : ( +
+ + + + + + + )} +
+ + ); +} diff --git a/src/pages/AssetsPage/Vesting.tsx b/src/pages/AssetsPage/Vesting.tsx new file mode 100644 index 0000000..7752663 --- /dev/null +++ b/src/pages/AssetsPage/Vesting.tsx @@ -0,0 +1,125 @@ +import React from 'react'; + +import { addressFormatter, percentFormatter } from '@lib/formatters/formatters'; +import { Divider, Stack, styled } from '@mui/material'; +import { Box } from '@mui/system'; +import { useParams, useSearchParams } from 'react-router-dom'; + +import { formatSqd, fromSqd } from '@api/contracts/utils'; +import { useVesting } from '@api/contracts/vesting'; +import { Card } from '@components/Card'; +import { CopyToClipboard } from '@components/CopyToClipboard'; +import { Loader } from '@components/Loader'; +import { CenteredPageWrapper, NetworkPageTitle } from '@layouts/NetworkLayout'; +import { useContracts } from '@network/useContracts'; + +import { ReleaseButton } from './ReleaseButton'; + +export const DescLabel = styled(Box, { + name: 'DescLabel', +})(({ theme }) => ({ + flex: 0.5, + color: theme.palette.text.secondary, + whiteSpace: 'balance', + maxWidth: theme.spacing(25), + fontSize: '1rem', +})); + +export const DescValue = styled(Box, { + name: 'DescValue', +})(({ theme }) => ({ + flex: 1, + marginLeft: theme.spacing(2), + overflowWrap: 'anywhere', +})); + +export const Title = styled(Box)(({ theme }) => ({ + fontSize: '1.25rem', + lineHeight: 1, + marginBottom: theme.spacing(3), +})); + +export const VestingAddress = styled(Box, { + name: 'VestingAddress', +})(({ theme }) => ({ + color: theme.palette.importantLink.main, + overflowWrap: 'anywhere', +})); + +export function Vesting({ backPath }: { backPath: string }) { + const { address } = useParams<{ address: `0x${string}` }>(); + const { data, isLoading } = useVesting({ address }); + const { SQD_TOKEN } = useContracts(); + + const [searchParams] = useSearchParams(); + + if (isLoading) return ; + else if (!data || !address) { + return Not found; + } + + return ( + + + + + } + /> + + }> + + + + Contract + + + + + + + + Total + {formatSqd(SQD_TOKEN, data.total, 8)} + + + Available + + {formatSqd(SQD_TOKEN, fromSqd(data.total).minus(fromSqd(data.released)), 8)} + + + + Deposited + + {data.deposited ? formatSqd(SQD_TOKEN, data.deposited, 8) : '-'} + + + + Releasable + {formatSqd(SQD_TOKEN, data.releasable, 8)} + + + + + + + Start + {data.start || '-'} + + + End + {data.end || '-'} + + + Initial release + {`${formatSqd(SQD_TOKEN, fromSqd(data.total).mul(data.initialRelease / 100), 8)} (${percentFormatter(data.initialRelease)})`} + + + + + + + ); +} diff --git a/src/pages/AssetsPage/Vestings.tsx b/src/pages/AssetsPage/Vestings.tsx new file mode 100644 index 0000000..7b7696f --- /dev/null +++ b/src/pages/AssetsPage/Vestings.tsx @@ -0,0 +1,85 @@ +import React from 'react'; + +import { addressFormatter } from '@lib/formatters/formatters.ts'; +import { Box, Stack, TableBody, TableCell, TableHead, TableRow } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; + +import { formatSqd, fromSqd } from '@api/contracts/utils'; +import { useVestings } from '@api/contracts/vesting'; +import { useMyAssets } from '@api/subsquid-network-squid'; +import { Avatar } from '@components/Avatar'; +import { Card } from '@components/Card'; +import { CopyToClipboard } from '@components/CopyToClipboard'; +import { Loader } from '@components/Loader'; +import { BorderedTable } from '@components/Table/BorderedTable'; +import { NetworkPageTitle } from '@layouts/NetworkLayout'; +import { useContracts } from '@network/useContracts'; + +import { ReleaseButton } from './ReleaseButton'; + +export function MyVestings() { + const navigate = useNavigate(); + const { assets, isLoading } = useMyAssets(); + const { data, isLoading: isVestingsLoading } = useVestings({ + addresses: assets?.vestings.map(v => v.address as `0x${string}`), + }); + const { SQD_TOKEN } = useContracts(); + + if (isLoading || isVestingsLoading) return ; + + return ( + + + {assets.vestings.length ? ( + + + + Vesting + Total + Available + Releasable + + + + + {assets.vestings.map((vesting, i) => { + const d = data?.[i]; + return ( + navigate(`vestings/${vesting.address}`)} + className="hoverable" + key={vesting.address} + > + + + + + + + {formatSqd(SQD_TOKEN, d?.total)} + + {formatSqd(SQD_TOKEN, fromSqd(d?.total).minus(fromSqd(d?.released)), 8)} + + {formatSqd(SQD_TOKEN, d?.releasable)} + + + + + + + ); + })} + + + ) : ( + No items to show + )} + + ); +} diff --git a/src/pages/DashboardPage/DashboardPage.tsx b/src/pages/DashboardPage/DashboardPage.tsx index 468d6c6..c15e1f4 100644 --- a/src/pages/DashboardPage/DashboardPage.tsx +++ b/src/pages/DashboardPage/DashboardPage.tsx @@ -1,18 +1,14 @@ import React from 'react'; -import { Box } from '@mui/material'; import { Outlet } from 'react-router-dom'; import { CenteredPageWrapper } from '@layouts/NetworkLayout'; -import { MyAssets } from './Assets'; import { Workers } from './Workers'; export function DashboardPage() { return ( - - diff --git a/yarn.lock b/yarn.lock index ed79b43..0e2731b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3169,7 +3169,7 @@ __metadata: viem: "npm:^1.21.1" vite: "npm:^5.2.6" vite-tsconfig-paths: "npm:^4.3.2" - wagmi: "npm:^1.4.12" + wagmi: "npm:^1.4.13" yup: "npm:^1.4.0" languageName: unknown linkType: soft @@ -12290,7 +12290,7 @@ __metadata: languageName: node linkType: hard -"wagmi@npm:^1.4.12": +"wagmi@npm:^1.4.13": version: 1.4.13 resolution: "wagmi@npm:1.4.13" dependencies: