diff --git a/apps/govern/common-util/functions/requests.ts b/apps/govern/common-util/functions/requests.ts index f4d3de8b..28c506aa 100644 --- a/apps/govern/common-util/functions/requests.ts +++ b/apps/govern/common-util/functions/requests.ts @@ -1,12 +1,16 @@ import { readContract, readContracts } from '@wagmi/core'; import { ethers } from 'ethers'; -import { AbiFunction, TransactionReceipt, parseUnits } from 'viem'; +import { Abi, AbiFunction, TransactionReceipt, parseUnits } from 'viem'; import { Address } from 'viem'; import { mainnet } from 'viem/chains'; import { sendTransaction } from '@autonolas/frontend-library'; -import { STAKING_FACTORY, VE_OLAS } from 'libs/util-contracts/src/lib/abiAndAddresses'; +import { + SERVICE_REGISTRY, + STAKING_FACTORY, + VE_OLAS, +} from 'libs/util-contracts/src/lib/abiAndAddresses'; import { getEstimatedGasLimit } from 'libs/util-functions/src'; import { SUPPORTED_CHAINS, wagmiConfig } from 'common-util/config/wagmi'; @@ -14,7 +18,13 @@ import { RPC_URLS } from 'common-util/constants/rpcs'; import { getAddressFromBytes32 } from './addresses'; import { getUnixNextWeekStartTimestamp } from './time'; -import { getOlasContract, getVeOlasContract, getVoteWeightingContract } from './web3'; +import { + getOlasContract, + getTokenomicsContract, + getTreasuryContract, + getVeOlasContract, + getVoteWeightingContract, +} from './web3'; type VoteForNomineeWeightsParams = { account: Address | undefined; @@ -288,3 +298,89 @@ export const withdrawVeolasRequest = async ({ account }: { account: Address }) = throw error; } }; + +/** + * Start new epoch + */ +export const checkpointRequest = async ({ account }: { account: Address }) => { + const contract = getTokenomicsContract(); + try { + const checkpointFn = contract.methods.checkpoint(); + const estimatedGas = await getEstimatedGasLimit(checkpointFn, account); + const fn = checkpointFn.send({ from: account, gasLimit: estimatedGas }); + + const response = await sendTransaction(fn, account, { + supportedChains: SUPPORTED_CHAINS, + rpcUrls: RPC_URLS, + }); + + return (response as TransactionReceipt)?.transactionHash; + } catch (error) { + window.console.log('Error occurred on starting new epoch'); + throw error; + } +}; + +/** + * Check services are eligible for donating + */ +export const checkServicesTerminatedOrNotDeployed = async (ids: string[]) => { + const invalidServiceIds: string[] = []; + + try { + const response = await readContracts(wagmiConfig, { + contracts: ids.map((id) => ({ + abi: SERVICE_REGISTRY.abi as Abi, + address: (SERVICE_REGISTRY.addresses as Record)[mainnet.id], + chainId: mainnet.id, + functionName: 'getService', + args: [id], + })), + }); + + response.forEach((service, index) => { + const serviceData = service.result as { state: number } | null; + if (serviceData && serviceData.state !== 4 && serviceData.state !== 5) { + invalidServiceIds.push(ids[index]); + } + }); + } catch (error) { + window.console.log('Error on checking service status'); + throw error; + } + + return invalidServiceIds; +}; + +/** + * Donate to services + */ +export const depositServiceDonationRequest = async ({ + account, + serviceIds, + amounts, + totalAmount, +}: { + account: Address; + serviceIds: string[]; + amounts: string[]; + totalAmount: string; +}) => { + const contract = getTreasuryContract(); + + try { + const depositFn = contract.methods.depositServiceDonationsETH(serviceIds, amounts); + const estimatedGas = await getEstimatedGasLimit(depositFn, account, totalAmount); + const fn = depositFn.send({ from: account, value: totalAmount, gasLimit: estimatedGas }); + + const response = await sendTransaction(fn, account, { + supportedChains: SUPPORTED_CHAINS, + rpcUrls: RPC_URLS, + }); + + return (response as TransactionReceipt)?.transactionHash; + } catch (error) { + window.console.log('Error occurred on depositing service donation'); + throw error; + } +}; diff --git a/apps/govern/common-util/functions/web3.ts b/apps/govern/common-util/functions/web3.ts index 3362c14a..0640eb5b 100644 --- a/apps/govern/common-util/functions/web3.ts +++ b/apps/govern/common-util/functions/web3.ts @@ -2,7 +2,13 @@ import { mainnet } from 'viem/chains'; import Web3 from 'web3'; import { AbiItem } from 'web3-utils'; -import { OLAS, VE_OLAS, VOTE_WEIGHTING } from 'libs/util-contracts/src/lib/abiAndAddresses'; +import { + OLAS, + TOKENOMICS, + TREASURY, + VE_OLAS, + VOTE_WEIGHTING, +} from 'libs/util-contracts/src/lib/abiAndAddresses'; import { getChainId, getProvider } from 'common-util/functions/frontend-library'; @@ -46,3 +52,17 @@ export const getVeOlasContract = () => { const contract = getContract(abi, address); return contract; }; + +export const getTokenomicsContract = () => { + const abi = TOKENOMICS.abi as unknown as AbiItem[]; + const address = TOKENOMICS.addresses[mainnet.id]; + const contract = getContract(abi, address); + return contract; +}; + +export const getTreasuryContract = () => { + const abi = TREASURY.abi as AbiItem[]; + const address = TREASURY.addresses[mainnet.id]; + const contract = getContract(abi, address); + return contract; +}; diff --git a/apps/govern/components/Donate/DonateForm.tsx b/apps/govern/components/Donate/DonateForm.tsx new file mode 100644 index 00000000..6c1c535f --- /dev/null +++ b/apps/govern/components/Donate/DonateForm.tsx @@ -0,0 +1,132 @@ +import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button, Form, Grid, InputNumber, Space, Typography } from 'antd'; +import styled from 'styled-components'; +import { useAccount } from 'wagmi'; + +const { Text } = Typography; +const { useBreakpoint } = Grid; + +export const DynamicFormContainer = styled.div` + max-width: 720px; + .ant-input-number { + width: 200px; + } +`; + +type DonateFormProps = { + isLoading: boolean; + onSubmit: ({ unitIds, amounts }: { unitIds: number[]; amounts: number[] }) => Promise; +}; + +export const DonateForm = ({ isLoading, onSubmit }: DonateFormProps) => { + const { address: account } = useAccount(); + const [form] = Form.useForm(); + + const screens = useBreakpoint(); + const inputStyle = screens.xs ? { width: '140px' } : { width: 'auto' }; + + const onFinish = async (values: { units: { unitId: number; amount: number }[] }) => { + try { + await onSubmit({ + unitIds: values.units.map((unit) => unit.unitId), + amounts: values.units.map((unit) => unit.amount), + }); + + form.resetFields(); + } catch (error) { + window.console.error(error); + } + }; + + return ( + +
+ { + if (!units || units?.length === 0) { + return Promise.reject(new Error('At least 1 unit is required')); + } + return Promise.resolve(); + }, + }, + ]} + > + {(fields, { add, remove }, { errors }) => ( + <> + {fields.map((field) => ( + + prevValues.units !== curValues.units} + > + {() => ( + + + + )} + + + + + + + {fields.length > 1 && remove(field.name)} />} + + ))} + + + + + + + + )} + + + + + + {!account && ( + + To donate, connect a wallet + + )} + +
+
+ ); +}; diff --git a/apps/govern/components/Donate/hooks.ts b/apps/govern/components/Donate/hooks.ts new file mode 100644 index 00000000..919621fd --- /dev/null +++ b/apps/govern/components/Donate/hooks.ts @@ -0,0 +1,121 @@ +import { ethers } from 'ethers'; +import { useCallback, useMemo } from 'react'; +import { mainnet } from 'viem/chains'; +import { useReadContract } from 'wagmi'; + +import { TOKENOMICS, TREASURY } from 'libs/util-contracts/src/lib/abiAndAddresses'; + +const useVeOLASThreshold = () => + useReadContract({ + address: TOKENOMICS.addresses[mainnet.id], + abi: TOKENOMICS.abi, + chainId: mainnet.id, + functionName: 'veOLASThreshold', + query: { + select: (data) => ethers.formatEther(`${data}`), + }, + }); + +const useMinAcceptedETH = () => + useReadContract({ + address: TREASURY.addresses[mainnet.id], + abi: TREASURY.abi, + chainId: mainnet.id, + functionName: 'minAcceptedETH', + }); + +const useEpochCounter = () => + useReadContract({ + address: TOKENOMICS.addresses[mainnet.id], + abi: TOKENOMICS.abi, + chainId: mainnet.id, + functionName: 'epochCounter', + }); + +const useEpochTokenomics = (epochCounter: number | undefined) => + useReadContract({ + address: TOKENOMICS.addresses[mainnet.id], + abi: TOKENOMICS.abi, + chainId: mainnet.id, + functionName: 'mapEpochTokenomics', + args: [BigInt(epochCounter || 0)], + query: { + enabled: epochCounter !== undefined, + }, + }); + +const useEpochLength = () => + useReadContract({ + address: TOKENOMICS.addresses[mainnet.id], + abi: TOKENOMICS.abi, + chainId: mainnet.id, + functionName: 'epochLen', + }); + +export const useThresholdData = () => { + const { + data: veOLASThreshold, + isFetching: isVeOLASThresholdFetching, + refetch: refetchVeOLASThreshold, + } = useVeOLASThreshold(); + const { + data: minAcceptedETH, + isFetching: isMinAcceptedETHFetching, + refetch: refetchMinAcceptedETH, + } = useMinAcceptedETH(); + const { + data: epochCounter, + isFetching: isEpochCounterFetching, + refetch: refetchEpochCounter, + } = useEpochCounter(); + const { + data: prevEpochPoint, + isFetching: isPrevEpochPointFetching, + refetch: refetchPrevEpochPoint, + } = useEpochTokenomics(epochCounter !== undefined ? Number(epochCounter) - 1 : undefined); + const { + data: epochLength, + isFetching: isEpochLengthFetching, + refetch: refetchEpochLength, + } = useEpochLength(); + + const nextEpochEndTime = useMemo(() => { + if (prevEpochPoint === undefined) return null; + if (epochLength === undefined) return null; + return prevEpochPoint.endTime + epochLength; + }, [prevEpochPoint, epochLength]); + + const refetchData = useCallback(async () => { + const promises = [ + refetchVeOLASThreshold(), + refetchMinAcceptedETH(), + refetchEpochCounter(), + refetchPrevEpochPoint(), + refetchEpochLength(), + ]; + + return Promise.all(promises); + }, [ + refetchVeOLASThreshold, + refetchMinAcceptedETH, + refetchEpochCounter, + refetchPrevEpochPoint, + refetchEpochLength, + ]); + + return { + veOLASThreshold, + minAcceptedETH: minAcceptedETH as bigint | undefined, + epochCounter, + prevEpochEndTime: prevEpochPoint?.endTime, + epochLength, + nextEpochEndTime, + isDataLoading: + isVeOLASThresholdFetching || + isMinAcceptedETHFetching || + isEpochCounterFetching || + isPrevEpochPointFetching || + isEpochLengthFetching, + refetchData, + }; +}; diff --git a/apps/govern/components/Donate/index.tsx b/apps/govern/components/Donate/index.tsx new file mode 100644 index 00000000..4269a497 --- /dev/null +++ b/apps/govern/components/Donate/index.tsx @@ -0,0 +1,203 @@ +import { Alert, Button, Card, Skeleton, Typography } from 'antd'; +import { ethers } from 'ethers'; +import isNumber from 'lodash/isNumber'; +import Link from 'next/link'; +import { useState } from 'react'; +import { useAccount } from 'wagmi'; + +import { NA, getFullFormattedDate, notifySuccess } from '@autonolas/frontend-library'; + +import { notifyError } from 'libs/util-functions/src'; + +import { + checkServicesTerminatedOrNotDeployed, + checkpointRequest, + depositServiceDonationRequest, +} from 'common-util/functions'; + +import { DonateForm } from './DonateForm'; +import { useThresholdData } from './hooks'; +import { DonateContainer, EpochCheckpointRow, EpochStatus } from './styles'; + +const { Title, Paragraph, Text } = Typography; + +const sortUnitIdsAndAmounts = (unitIds: number[], amounts: number[]) => { + const sortedUnitIds = [...unitIds].sort((a, b) => a - b); + const sortedAmounts = sortedUnitIds.map((e) => amounts[unitIds.indexOf(e)]); + return [sortedUnitIds, sortedAmounts]; +}; + +export const DonatePage = () => { + const { address: account } = useAccount(); + + const [isDonationLoading, setIsDonationLoading] = useState(false); + const [isCheckpointLoading, setIsCheckpointLoading] = useState(false); + + const { + isDataLoading, + refetchData, + veOLASThreshold, + minAcceptedETH, + epochCounter, + prevEpochEndTime, + epochLength, + nextEpochEndTime, + } = useThresholdData(); + + const onDepositServiceDonationSubmit = async (values: { + unitIds: number[]; + amounts: number[]; + }) => { + if (!account) return; + + try { + setIsDonationLoading(true); + + const [sortedUnitIds, sortedAmounts] = sortUnitIdsAndAmounts(values.unitIds, values.amounts); + const serviceIds = sortedUnitIds.map((e) => `${e}`); + const invalidServices = await checkServicesTerminatedOrNotDeployed(serviceIds); + + // deposit only if all services are deployed or not terminated + if (invalidServices.length > 0) { + throw new Error( + `Provided service IDs are not deployed or terminated ${invalidServices.join(', ')}`, + ); + } else { + const amounts = sortedAmounts.map((e) => ethers.parseUnits(`${e}`, 18).toString()); + const totalAmount = amounts.reduce( + (a, b) => ethers.toBigInt(a) + ethers.toBigInt(b), + BigInt(0), + ); + + if (minAcceptedETH !== undefined && minAcceptedETH > totalAmount) { + throw new Error( + `At least ${ethers.formatEther( + `${minAcceptedETH}`, + )} ETH of donations is required to trigger boosts.`, + ); + } else { + const params = { + account, + serviceIds, + amounts, + totalAmount: totalAmount.toString(), + }; + + await depositServiceDonationRequest(params); + notifySuccess('Deposited service donation successfully'); + } + } + } catch (error) { + console.error(error); + const errorMessage = + (error as Error).message || 'Error occurred on depositing service donation'; + notifyError(errorMessage); + throw error; + } finally { + setIsDonationLoading(false); + } + }; + + const onCheckpoint = async () => { + if (!account) return; + + try { + setIsCheckpointLoading(true); + await checkpointRequest({ account }); + await refetchData(); // update epoch details after checkpoint + notifySuccess('Started new epoch'); + } catch (error) { + console.error(error); + notifyError('Error occurred on starting new epoch'); + } finally { + setIsCheckpointLoading(false); + } + }; + + const epochStatusList = [ + { + text: 'Earliest possible expected end time', + value: nextEpochEndTime ? getFullFormattedDate(nextEpochEndTime * 1000) : NA, + }, + { + text: 'Epoch length', + value: isNumber(epochLength) ? `${epochLength / 3600 / 24} days` : NA, + }, + { + text: 'Previous epoch end time', + value: prevEpochEndTime ? getFullFormattedDate(prevEpochEndTime * 1000) : NA, + }, + { + text: 'Epoch counter', + value: epochCounter || NA, + }, + ]; + + // disable checkpoint button if expected end time is in the future + const isExpectedEndTimeInFuture = (nextEpochEndTime || 0) * 1000 > Date.now(); + + return ( + + + + Donate + + + Show appreciation for the value of an autonomous service by making a donation. The + protocol will reward devs who have contributed code for that service. + + + + To boost rewards of devs with freshly minted OLAS, you must hold at least  + {veOLASThreshold || NA} +  veOLAS. Grab your veOLAS by locking OLAS  + here. At least  + + {minAcceptedETH ? ethers.formatEther(`${minAcceptedETH}`) : NA} +  ETH + +  of donations is required to trigger boosts. + + } + className="mb-16" + /> + + + + + + + Epoch Status + + + {epochStatusList.map(({ text, value }, index) => ( + + {`${text}:`} + {isDataLoading ? ( + + ) : ( + {value} + )} + + ))} + + + + New epochs must be manually triggered by community members + + + + ); +}; diff --git a/apps/govern/components/Donate/styles.tsx b/apps/govern/components/Donate/styles.tsx new file mode 100644 index 00000000..484d0ba3 --- /dev/null +++ b/apps/govern/components/Donate/styles.tsx @@ -0,0 +1,48 @@ +import styled from 'styled-components'; + +import { MEDIA_QUERY } from 'libs/ui-theme/src'; + +export const DonateContainer = styled.div` + display: flex; + gap: 16px; + + .donate-section { + width: 720px; + } + .last-epoch-section { + flex: auto; + } + + ${MEDIA_QUERY.tabletL} { + flex-direction: column; + .donate-section { + width: 100%; + } + .last-epoch-section { + padding-left: 0; + margin-left: 0; + border-left: none; + } + } +`; + +export const EpochStatus = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + h5, + div { + margin: 0; + } +`; + +export const EpochCheckpointRow = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 1rem; + .ant-btn { + width: 200px; + } +`; diff --git a/apps/govern/components/Layout/Menu.tsx b/apps/govern/components/Layout/Menu.tsx index 6f4cb926..d981bd4a 100644 --- a/apps/govern/components/Layout/Menu.tsx +++ b/apps/govern/components/Layout/Menu.tsx @@ -12,6 +12,7 @@ const items: MenuItem[] = [ { label: 'Staking contracts', key: 'contracts', path: '/contracts' }, { label: 'Proposals', key: 'proposals', path: '/proposals' }, { label: 'veOLAS', key: 'veolas', path: '/veolas' }, + { label: 'Donate', key: 'donate', path: '/donate' }, { label: 'Docs', key: 'docs', path: '/docs' }, ]; diff --git a/apps/govern/components/Proposals/Proposals.spec.tsx b/apps/govern/components/Proposals/index.spec.tsx similarity index 95% rename from apps/govern/components/Proposals/Proposals.spec.tsx rename to apps/govern/components/Proposals/index.spec.tsx index 63d42e29..591ab2e9 100644 --- a/apps/govern/components/Proposals/Proposals.spec.tsx +++ b/apps/govern/components/Proposals/index.spec.tsx @@ -3,7 +3,7 @@ import { render, screen } from '@testing-library/react'; import { UNICODE_SYMBOLS } from 'libs/util-constants/src'; -import { ProposalsPage, proposals } from './Proposals'; +import { ProposalsPage, proposals } from './index'; describe('', () => { it('should display the page title and description', () => { diff --git a/apps/govern/components/Proposals/Proposals.tsx b/apps/govern/components/Proposals/index.tsx similarity index 100% rename from apps/govern/components/Proposals/Proposals.tsx rename to apps/govern/components/Proposals/index.tsx diff --git a/apps/govern/pages/donate.tsx b/apps/govern/pages/donate.tsx new file mode 100644 index 00000000..a655e295 --- /dev/null +++ b/apps/govern/pages/donate.tsx @@ -0,0 +1,3 @@ +import { DonatePage } from 'components/Donate'; + +export default DonatePage; diff --git a/apps/govern/pages/proposals.tsx b/apps/govern/pages/proposals.tsx index 1d63f204..c983755b 100644 --- a/apps/govern/pages/proposals.tsx +++ b/apps/govern/pages/proposals.tsx @@ -1,3 +1,3 @@ -import { ProposalsPage } from 'components/Proposals/Proposals'; +import { ProposalsPage } from 'components/Proposals'; export default ProposalsPage; diff --git a/apps/tokenomics/components/Layout/index.jsx b/apps/tokenomics/components/Layout/index.jsx index b8bf8ead..e19f7143 100644 --- a/apps/tokenomics/components/Layout/index.jsx +++ b/apps/tokenomics/components/Layout/index.jsx @@ -45,12 +45,14 @@ const Layout = ({ children }) => { useEffect(() => { if (router.pathname) { const name = router.pathname.split('/')[1]; - setSelectedMenu(name || 'veolas'); + setSelectedMenu(name || 'dev-incentives'); } }, [router.pathname]); const handleMenuItemClick = ({ key }) => { - if (key === 'docs') { + if (key === 'donate') { + window.open('https://govern.olas.network/donate', '_blank'); + } else if (key === 'docs') { window.open('https://docs.autonolas.network/protocol/tokenomics/', '_blank'); } else if (key === 'bonding-products') { window.open('https://bond.olas.network/bonding-products', '_blank'); @@ -80,8 +82,8 @@ const Layout = ({ children }) => { selectedKeys={[selectedMenu]} onClick={handleMenuItemClick} items={[ - { key: 'donate', label: 'Donate' }, { key: 'dev-incentives', label: 'Dev Rewards' }, + { key: 'donate', label: }, { key: 'bonding-products', label: }, { key: 'my-bonds', label: }, { diff --git a/apps/tokenomics/next.config.js b/apps/tokenomics/next.config.js index 56e8cd93..ff80f99a 100644 --- a/apps/tokenomics/next.config.js +++ b/apps/tokenomics/next.config.js @@ -30,8 +30,13 @@ const nextConfig = { return [ { source: '/', - destination: '/donate', + destination: '/dev-incentives', permanent: false, + }, + { + source: '/donate', + destination: 'https://govern.olas.network/donate', + permanent: true, }, { source: '/bonding-products', diff --git a/libs/util-contracts/src/lib/abiAndAddresses/treasury.js b/libs/util-contracts/src/lib/abiAndAddresses/treasury.ts similarity index 99% rename from libs/util-contracts/src/lib/abiAndAddresses/treasury.js rename to libs/util-contracts/src/lib/abiAndAddresses/treasury.ts index aeb306c3..f8766fb7 100644 --- a/libs/util-contracts/src/lib/abiAndAddresses/treasury.js +++ b/libs/util-contracts/src/lib/abiAndAddresses/treasury.ts @@ -1,4 +1,6 @@ -export const TREASURY = { +import { Contract } from "./types"; + +export const TREASURY: Contract = { contractName: 'Treasury', addresses: { 1: '0xa0DA53447C0f6C4987964d8463da7e6628B30f82', diff --git a/libs/util-functions/src/lib/requests.ts b/libs/util-functions/src/lib/requests.ts index 36533382..3ac16366 100644 --- a/libs/util-functions/src/lib/requests.ts +++ b/libs/util-functions/src/lib/requests.ts @@ -1,5 +1,6 @@ import { Contract } from 'ethers'; + const ESTIMATED_GAS_LIMIT = 500_000; /** @@ -8,17 +9,18 @@ const ESTIMATED_GAS_LIMIT = 500_000; export const getEstimatedGasLimit = async ( fn: Contract['methods'], account: `0x${string}` | string | undefined, + value?: string, ) => { if (!account) { throw new Error('Invalid account passed to estimate gas limit'); } try { - const estimatedGas = await fn.estimateGas({ from: account }); + const estimatedGas = await fn.estimateGas({ from: account, value }); return Math.ceil(Number(estimatedGas) * 1.2); } catch (error) { window.console.warn(`Error occurred on estimating gas, defaulting to ${ESTIMATED_GAS_LIMIT}`); } return ESTIMATED_GAS_LIMIT; -}; +}; \ No newline at end of file