diff --git a/frontend/components/Main/Main.tsx b/frontend/components/Main/Main.tsx index 84c575d53..81864ae22 100644 --- a/frontend/components/Main/Main.tsx +++ b/frontend/components/Main/Main.tsx @@ -8,7 +8,7 @@ import { useBalance, usePageState, useServices } from '@/hooks'; import { KeepAgentRunning } from './KeepAgentRunning'; import { MainAddFunds } from './MainAddFunds'; import { MainGasBalance } from './MainGasBalance'; -import { MainHeader } from './MainHeader'; +import { MainHeader } from './MainHeader/MainHeader'; import { MainNeedsFunds } from './MainNeedsFunds'; import { MainOlasBalance } from './MainOlasBalance'; import { MainRewards } from './MainRewards'; diff --git a/frontend/components/Main/MainHeader/CannotStartAgent.tsx b/frontend/components/Main/MainHeader/CannotStartAgent.tsx new file mode 100644 index 000000000..4e37f4227 --- /dev/null +++ b/frontend/components/Main/MainHeader/CannotStartAgent.tsx @@ -0,0 +1,80 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Popover, PopoverProps, Typography } from 'antd'; + +import { COLOR, SUPPORT_URL } from '@/constants'; +import { UNICODE_SYMBOLS } from '@/constants/unicode'; +import { useStakingContractInfo } from '@/hooks/useStakingContractInfo'; + +const { Paragraph, Text } = Typography; + +const cannotStartAgentText = ( + + Cannot start agent  + + +); + +const evictedDescription = + "You didn't run your agent enough and it missed its targets multiple times. Please wait a few days and try to run your agent again."; +const AgentEvictedPopover = () => ( + {evictedDescription}} + > + {cannotStartAgentText} + +); + +const otherPopoverProps: PopoverProps = { + arrow: false, + placement: 'bottomRight', +}; + +const JoinOlasCommunity = () => ( +
+ + Join the Olas community Discord server to report or stay up to date on the + issue. + + + + Olas community Discord server {UNICODE_SYMBOLS.EXTERNAL_LINK} + +
+); + +const NoRewardsAvailablePopover = () => ( + } + > + {cannotStartAgentText} + +); + +const NoJobsAvailablePopover = () => ( + } + > + {cannotStartAgentText} + +); + +export const CannotStartAgent = () => { + const { + canStartAgent, + hasEnoughServiceSlots, + isRewardsAvailable, + isAgentEvicted, + } = useStakingContractInfo(); + + if (canStartAgent) return null; + if (!hasEnoughServiceSlots) return ; + if (!isRewardsAvailable) return ; + if (isAgentEvicted) return ; + throw new Error('Cannot start agent, please contact support'); +}; diff --git a/frontend/components/Main/MainHeader/FirstRunModal.tsx b/frontend/components/Main/MainHeader/FirstRunModal.tsx new file mode 100644 index 000000000..50b06f92b --- /dev/null +++ b/frontend/components/Main/MainHeader/FirstRunModal.tsx @@ -0,0 +1,52 @@ +import { Button, Flex, Modal, Typography } from 'antd'; +import Image from 'next/image'; +import { FC } from 'react'; + +import { useReward } from '@/hooks/useReward'; + +const { Title, Paragraph } = Typography; + +type FirstRunModalProps = { open: boolean; onClose: () => void }; + +export const FirstRunModal: FC = ({ open, onClose }) => { + const { minimumStakedAmountRequired } = useReward(); + + if (!open) return null; + return ( + + Got it + , + ]} + > + + OLAS logo + + + {`Your agent is running and you've staked ${minimumStakedAmountRequired} OLAS!`} + + Your agent is working towards earning rewards. + + Pearl is designed to make it easy for you to earn staking rewards every + day. Simply leave the app and agent running in the background for ~1hr a + day. + + + ); +}; diff --git a/frontend/components/Main/MainHeader.tsx b/frontend/components/Main/MainHeader/MainHeader.tsx similarity index 75% rename from frontend/components/Main/MainHeader.tsx rename to frontend/components/Main/MainHeader/MainHeader.tsx index d00be73d1..24c971486 100644 --- a/frontend/components/Main/MainHeader.tsx +++ b/frontend/components/Main/MainHeader/MainHeader.tsx @@ -1,24 +1,47 @@ import { InfoCircleOutlined } from '@ant-design/icons'; -import { Badge, Button, Flex, Modal, Popover, Typography } from 'antd'; -import { formatUnits } from 'ethers/lib/utils'; +import { Badge, Button, Flex, Popover, Typography } from 'antd'; import Image from 'next/image'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { Chain, DeploymentStatus } from '@/client'; -import { COLOR, LOW_BALANCE, SERVICE_TEMPLATES } from '@/constants'; +import { COLOR, LOW_BALANCE } from '@/constants'; import { useBalance, useServiceTemplates } from '@/hooks'; import { useElectronApi } from '@/hooks/useElectronApi'; import { useReward } from '@/hooks/useReward'; import { useServices } from '@/hooks/useServices'; +import { useStakingContractInfo } from '@/hooks/useStakingContractInfo'; import { useStore } from '@/hooks/useStore'; import { useWallet } from '@/hooks/useWallet'; import { ServicesService } from '@/service'; import { WalletService } from '@/service/Wallet'; -const { Text, Title, Paragraph } = Typography; +import { CannotStartAgent } from './CannotStartAgent'; +import { requiredGas, requiredOlas } from './constants'; +import { FirstRunModal } from './FirstRunModal'; + +const { Text } = Typography; const LOADING_MESSAGE = 'Starting the agent may take a while, so feel free to minimize the app. We’ll notify you once it’s running. Please, don’t quit the app.'; +const StartingButtonPopover = () => ( + + + + + {LOADING_MESSAGE} + + } + > + + +); enum ServiceButtonLoadingState { Starting, @@ -26,59 +49,28 @@ enum ServiceButtonLoadingState { NotLoading, } -const FirstRunModal = ({ - open, - onClose, -}: { - open: boolean; - onClose: () => void; -}) => { - const { minimumStakedAmountRequired } = useReward(); +const useSetupTrayIcon = () => { + const { safeBalance } = useBalance(); + const { serviceStatus } = useServices(); + const { setTrayIcon } = useElectronApi(); - if (!open) return null; - return ( - - Got it - , - ]} - > - - OLAS logo - - - {`Your agent is running and you've staked ${minimumStakedAmountRequired} OLAS!`} - - Your agent is working towards earning rewards. - - Pearl is designed to make it easy for you to earn staking rewards every - day. Simply leave the app and agent running in the background for ~1hr a - day. - - - ); + useEffect(() => { + if (safeBalance && safeBalance.ETH < LOW_BALANCE) { + setTrayIcon?.('low-gas'); + } else if (serviceStatus === DeploymentStatus.DEPLOYED) { + setTrayIcon?.('running'); + } else if (serviceStatus === DeploymentStatus.STOPPED) { + setTrayIcon?.('paused'); + } + }, [safeBalance, serviceStatus, setTrayIcon]); + + return null; }; export const MainHeader = () => { const { storeState } = useStore(); const { services, serviceStatus, setServiceStatus } = useServices(); - const { showNotification, setTrayIcon } = useElectronApi(); + const { showNotification } = useElectronApi(); const { getServiceTemplates } = useServiceTemplates(); const { wallets, masterSafeAddress } = useWallet(); const { @@ -94,6 +86,12 @@ export const MainHeader = () => { const { minimumStakedAmountRequired } = useReward(); + const { isStakingContractInfoLoading, canStartAgent } = + useStakingContractInfo(); + + // hook to setup tray icon + useSetupTrayIcon(); + const safeOlasBalanceWithStaked = useMemo(() => { if (safeBalance?.OLAS === undefined) return; if (totalOlasStakedBalance === undefined) return; @@ -108,16 +106,6 @@ export const MainHeader = () => { [getServiceTemplates], ); - useEffect(() => { - if (safeBalance && safeBalance.ETH < LOW_BALANCE) { - setTrayIcon?.('low-gas'); - } else if (serviceStatus === DeploymentStatus.DEPLOYED) { - setTrayIcon?.('running'); - } else if (serviceStatus === DeploymentStatus.STOPPED) { - setTrayIcon?.('paused'); - } - }, [safeBalance, serviceStatus, setTrayIcon]); - const agentHead = useMemo(() => { if ( serviceButtonState === ServiceButtonLoadingState.Starting || @@ -227,25 +215,7 @@ export const MainHeader = () => { } if (serviceButtonState === ServiceButtonLoadingState.Starting) { - return ( - - - - - {LOADING_MESSAGE} - - } - > - - - ); + return ; } if (serviceStatus === DeploymentStatus.DEPLOYED) { @@ -272,29 +242,6 @@ export const MainHeader = () => { ); } - const olasCostOfBond = Number( - formatUnits( - `${SERVICE_TEMPLATES[0].configuration.olas_cost_of_bond}`, - 18, - ), - ); - - const olasRequiredToStake = Number( - formatUnits( - `${SERVICE_TEMPLATES[0].configuration.olas_required_to_stake}`, - 18, - ), - ); - - const requiredOlas = olasCostOfBond + olasRequiredToStake; - - const requiredGas = Number( - formatUnits( - `${SERVICE_TEMPLATES[0].configuration.monthly_gas_estimate}`, - 18, - ), - ); - const isDeployable = (() => { // case where required values are undefined (not fetched from the server) if (totalEthBalance === undefined) return false; @@ -327,7 +274,12 @@ export const MainHeader = () => { } return ( - ); @@ -341,12 +293,15 @@ export const MainHeader = () => { services, storeState?.isInitialFunded, totalEthBalance, + canStartAgent, ]); return ( {agentHead} - {serviceToggleButton} + {isStakingContractInfoLoading ? null : ( + <>{canStartAgent ? serviceToggleButton : } + )} ); diff --git a/frontend/components/Main/MainHeader/constants.tsx b/frontend/components/Main/MainHeader/constants.tsx new file mode 100644 index 000000000..70ca0a431 --- /dev/null +++ b/frontend/components/Main/MainHeader/constants.tsx @@ -0,0 +1,18 @@ +import { formatUnits } from 'ethers/lib/utils'; + +import { SERVICE_TEMPLATES } from '@/constants'; + +const olasCostOfBond = Number( + formatUnits(`${SERVICE_TEMPLATES[0].configuration.olas_cost_of_bond}`, 18), +); +const olasRequiredToStake = Number( + formatUnits( + `${SERVICE_TEMPLATES[0].configuration.olas_required_to_stake}`, + 18, + ), +); + +export const requiredOlas = olasCostOfBond + olasRequiredToStake; +export const requiredGas = Number( + formatUnits(`${SERVICE_TEMPLATES[0].configuration.monthly_gas_estimate}`, 18), +); diff --git a/frontend/context/StakingContractInfoProvider.tsx b/frontend/context/StakingContractInfoProvider.tsx new file mode 100644 index 000000000..c53300e0c --- /dev/null +++ b/frontend/context/StakingContractInfoProvider.tsx @@ -0,0 +1,70 @@ +import { createContext, PropsWithChildren, useEffect, useState } from 'react'; + +import { AutonolasService } from '@/service/Autonolas'; + +type StakingContractInfoContextProps = { + isStakingContractInfoLoading: boolean; + isRewardsAvailable: boolean; + hasEnoughServiceSlots: boolean; + isAgentEvicted: boolean; // TODO: Implement this + canStartAgent: boolean; +}; + +export const StakingContractInfoContext = + createContext({ + isStakingContractInfoLoading: true, + isRewardsAvailable: false, + hasEnoughServiceSlots: false, + isAgentEvicted: false, + canStartAgent: false, + }); + +export const StakingContractInfoProvider = ({ + children, +}: PropsWithChildren) => { + const [isStakingContractInfoLoading, setIsStakingContractInfoLoading] = + useState(true); + const [isRewardsAvailable, setIsRewardsAvailable] = useState(false); + const [hasEnoughServiceSlots, setHasEnoughServiceSlots] = useState(false); + const [isAgentEvicted, setIsAgentEvicted] = useState(false); + const [canStartAgent, setCanStartAgent] = useState(false); + + useEffect(() => { + (async () => { + try { + setIsStakingContractInfoLoading(true); + + const info = await AutonolasService.getStakingContractInfo(); + if (!info) return; + + const { availableRewards, maxNumServices, serviceIds } = info; + const isRewardsAvailable = availableRewards > 0; + const hasEnoughServiceSlots = serviceIds.length < maxNumServices; + const canStartAgent = isRewardsAvailable && hasEnoughServiceSlots; + + setIsRewardsAvailable(isRewardsAvailable); + setHasEnoughServiceSlots(hasEnoughServiceSlots); + setCanStartAgent(canStartAgent); + setIsAgentEvicted(false); // TODO: Implement this + } catch (error) { + console.error('Failed to fetch staking contract info', error); + } finally { + setIsStakingContractInfoLoading(false); + } + })(); + }, []); + + return ( + + {children} + + ); +}; diff --git a/frontend/hooks/useStakingContractInfo.ts b/frontend/hooks/useStakingContractInfo.ts new file mode 100644 index 000000000..69301068e --- /dev/null +++ b/frontend/hooks/useStakingContractInfo.ts @@ -0,0 +1,21 @@ +import { useContext } from 'react'; + +import { StakingContractInfoContext } from '@/context/StakingContractInfoProvider'; + +export const useStakingContractInfo = () => { + const { + canStartAgent, + hasEnoughServiceSlots, + isAgentEvicted, + isRewardsAvailable, + isStakingContractInfoLoading, + } = useContext(StakingContractInfoContext); + + return { + canStartAgent, + hasEnoughServiceSlots, + isAgentEvicted, + isRewardsAvailable, + isStakingContractInfoLoading, + }; +}; diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index 9c91f90b1..4cbfa6f85 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -12,6 +12,7 @@ import { MasterSafeProvider } from '@/context/MasterSafeProvider'; import { OnlineStatusProvider } from '@/context/OnlineStatusProvider'; import { RewardProvider } from '@/context/RewardProvider'; import { SettingsProvider } from '@/context/SettingsProvider'; +import { StakingContractInfoProvider } from '@/context/StakingContractInfoProvider'; import { StoreProvider } from '@/context/StoreProvider'; import { WalletProvider } from '@/context/WalletProvider'; import { mainTheme } from '@/theme'; @@ -38,13 +39,15 @@ export default function App({ Component, pageProps }: AppProps) { - {isMounted ? ( - - - - - - ) : null} + + {isMounted ? ( + + + + + + ) : null} + diff --git a/frontend/service/Autonolas.ts b/frontend/service/Autonolas.ts index 4959ee7ef..a20fd9c96 100644 --- a/frontend/service/Autonolas.ts +++ b/frontend/service/Autonolas.ts @@ -1,4 +1,4 @@ -import { ethers } from 'ethers'; +import { BigNumber, ethers } from 'ethers'; import { Contract as MulticallContract } from 'ethers-multicall'; import { @@ -18,7 +18,7 @@ import { } from '@/constants'; import { gnosisMulticallProvider } from '@/constants/providers'; import { ServiceRegistryL2ServiceState } from '@/enums'; -import { Address, StakingRewardsInfo } from '@/types'; +import { Address, StakingContractInfo, StakingRewardsInfo } from '@/types'; const REQUIRED_MECH_REQUESTS_SAFETY_MARGIN = 1; @@ -158,6 +158,37 @@ const getAvailableRewardsForEpoch = async (): Promise => { ); }; +/** + * function to get the staking contract info + */ +const getStakingContractInfo = async (): Promise< + StakingContractInfo | undefined +> => { + const contractCalls = [ + serviceStakingTokenMechUsageContract.availableRewards(), + serviceStakingTokenMechUsageContract.maxNumServices(), + serviceStakingTokenMechUsageContract.getServiceIds(), + ]; + + await gnosisMulticallProvider.init(); + + const multicallResponse = await gnosisMulticallProvider.all(contractCalls); + const [availableRewardsInBN, maxNumServicesInBN, getServiceIdsInBN] = + multicallResponse; + + const availableRewards = parseFloat( + ethers.utils.formatUnits(availableRewardsInBN, 18), + ); + const serviceIds = getServiceIdsInBN.map((id: BigNumber) => id.toNumber()); + const maxNumServices = maxNumServicesInBN.toNumber(); + + return { + availableRewards, + maxNumServices, + serviceIds, + }; +}; + const getServiceRegistryInfo = async ( operatorAddress: Address, // generally masterSafeAddress serviceId: number, @@ -200,4 +231,5 @@ export const AutonolasService = { getAgentStakingRewardsInfo, getAvailableRewardsForEpoch, getServiceRegistryInfo, + getStakingContractInfo, }; diff --git a/frontend/types/Autonolas.ts b/frontend/types/Autonolas.ts index dfc441581..7855a89f1 100644 --- a/frontend/types/Autonolas.ts +++ b/frontend/types/Autonolas.ts @@ -9,3 +9,9 @@ export type StakingRewardsInfo = { accruedServiceStakingRewards: number; minimumStakedAmount: number; }; + +export type StakingContractInfo = { + availableRewards: number; + maxNumServices: number; + serviceIds: number[]; +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0f813ba46..18458a9fa 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5621,6 +5621,7 @@ string-length@^4.0.1: strip-ansi "^6.0.0" "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + name string-width-cjs version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6142,6 +6143,7 @@ word-wrap@^1.2.5: integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==