diff --git a/frontend/client/types.ts b/frontend/client/types.ts index 93ebb1db9..1e00ca252 100644 --- a/frontend/client/types.ts +++ b/frontend/client/types.ts @@ -89,11 +89,11 @@ export type UpdateServicePayload = { }; export type DeleteServicesPayload = { - hashes: Array; + hashes: ServiceHash[]; }; export type DeleteServicesResponse = { - hashes: Array; + hashes: ServiceHash[]; }; export type AppInfo = { diff --git a/frontend/components/Layout/Navbar/Navbar.tsx b/frontend/components/Layout/Navbar/Navbar.tsx index bfd42b50a..dc420dbd7 100644 --- a/frontend/components/Layout/Navbar/Navbar.tsx +++ b/frontend/components/Layout/Navbar/Navbar.tsx @@ -1,16 +1,10 @@ import { Flex } from 'antd'; -import { SettingsButton } from './SettingsButton'; -import { NotificationButton } from './NotificationButton'; import Image from 'next/image'; export const Navbar = () => { return ( - - - - ); }; diff --git a/frontend/components/Marketplace/Marketplace.tsx b/frontend/components/Marketplace/Marketplace.tsx index 1d2ea6c75..2c7afc6b9 100644 --- a/frontend/components/Marketplace/Marketplace.tsx +++ b/frontend/components/Marketplace/Marketplace.tsx @@ -1,10 +1,10 @@ import { Flex } from 'antd'; import { MarketplaceItemCard } from './MarketplaceItemCard'; import { useMemo } from 'react'; -import { useMarketplace } from '@/hooks'; +import { useServiceTemplates } from '@/hooks'; export const Marketplace = () => { - const { getServiceTemplates } = useMarketplace(); + const { getServiceTemplates } = useServiceTemplates(); const serviceTemplates = useMemo( () => getServiceTemplates(), diff --git a/frontend/components/Spawn/Funding/FundRequirement/FundRequirement.tsx b/frontend/components/Spawn/Funding/FundRequirement/FundRequirement.tsx index 6d03a76eb..8b68784fa 100644 --- a/frontend/components/Spawn/Funding/FundRequirement/FundRequirement.tsx +++ b/frontend/components/Spawn/Funding/FundRequirement/FundRequirement.tsx @@ -1,6 +1,6 @@ import { copyToClipboard } from '@/common-util/copyToClipboard'; import { useModals } from '@/hooks'; -import { Address, AddressBooleanRecord } from '@/types'; +import { Address, SpawnData } from '@/types'; import { Button, Flex, Typography, message } from 'antd'; import { Dispatch, @@ -24,7 +24,7 @@ type FundRequirementProps = { rpc: string, contractAddress?: Address, ) => Promise; - setReceivedFunds: Dispatch>; + setSpawnData: Dispatch>; }; /** @@ -41,7 +41,7 @@ export const FundRequirement = ({ hasReceivedFunds, isErc20, getBalance, - setReceivedFunds, + setSpawnData, }: FundRequirementProps) => { const { qrModalOpen } = useModals(); @@ -77,11 +77,36 @@ export const FundRequirement = ({ .then((balance: number) => { if (balance >= requirement) { setIsPollingBalance(false); - setReceivedFunds((prev: AddressBooleanRecord) => ({ - ...prev, - [address]: true, - })); - message.success(`Funded ${address}`); + setSpawnData((prev: SpawnData) => { + // update agent fund requirements + if (prev.agentFundRequirements[address]) { + return { + ...prev, + agentFundRequirements: { + ...prev.agentFundRequirements, + [address]: { + ...prev.agentFundRequirements[address], + received: true, + }, + }, + }; + } + // update master wallet fund requirements + if (prev.masterWalletFundRequirements[address]) { + return { + ...prev, + masterWalletFundRequirements: { + ...prev.masterWalletFundRequirements, + [address]: { + ...prev.masterWalletFundRequirements[address], + received: true, + }, + }, + }; + } + // do nothing + return prev; + }); } }) .catch(() => { diff --git a/frontend/components/Spawn/Funding/FundRequirement/FundRequirementERC20.tsx b/frontend/components/Spawn/Funding/FundRequirement/FundRequirementERC20.tsx index b110649b1..e509dffae 100644 --- a/frontend/components/Spawn/Funding/FundRequirement/FundRequirementERC20.tsx +++ b/frontend/components/Spawn/Funding/FundRequirement/FundRequirementERC20.tsx @@ -1,7 +1,7 @@ import { Dispatch, SetStateAction } from 'react'; import { FundRequirement } from './FundRequirement'; -import { Address, AddressBooleanRecord } from '@/types'; -import EthersService from '@/service/Ethers'; +import { Address, SpawnData } from '@/types'; +import { EthersService } from '@/service'; type FundRequirementERC20Props = { address: Address; @@ -10,7 +10,7 @@ type FundRequirementERC20Props = { symbol: string; contractAddress?: Address; hasReceivedFunds: boolean; - setReceivedFunds: Dispatch>; + setSpawnData: Dispatch>; }; export const FundRequirementERC20 = (props: FundRequirementERC20Props) => { diff --git a/frontend/components/Spawn/Funding/FundRequirement/FundRequirementETH.tsx b/frontend/components/Spawn/Funding/FundRequirement/FundRequirementETH.tsx index 63cdf4c4c..d827023b1 100644 --- a/frontend/components/Spawn/Funding/FundRequirement/FundRequirementETH.tsx +++ b/frontend/components/Spawn/Funding/FundRequirement/FundRequirementETH.tsx @@ -1,7 +1,7 @@ import { Dispatch, SetStateAction } from 'react'; import { FundRequirement } from './FundRequirement'; -import { Address, AddressBooleanRecord } from '@/types'; -import EthersService from '@/service/Ethers'; +import { Address, SpawnData } from '@/types'; +import { EthersService } from '@/service'; type FundRequirementETHProps = { address: Address; @@ -9,7 +9,7 @@ type FundRequirementETHProps = { requirement: number; rpc: string; hasReceivedFunds: boolean; - setReceivedFunds: Dispatch>; + setSpawnData: Dispatch>; }; export const FundRequirementETH = (props: FundRequirementETHProps) => { diff --git a/frontend/components/Spawn/Funding/Funding.tsx b/frontend/components/Spawn/Funding/Funding.tsx index 07c44dd5e..c2bc46528 100644 --- a/frontend/components/Spawn/Funding/Funding.tsx +++ b/frontend/components/Spawn/Funding/Funding.tsx @@ -1,12 +1,9 @@ -import { COLOR } from '@/constants'; import { SpawnScreen } from '@/enums'; import { useSpawn } from '@/hooks'; -import { Address, AddressNumberRecord } from '@/types'; -import { AddressBooleanRecord } from '@/types'; -import { TimelineItemProps, Flex, Typography, Timeline } from 'antd'; +import { Address, FundingRecord, SpawnData } from '@/types'; +import { TimelineItemProps, Flex, Typography, Timeline, theme } from 'antd'; import { isEmpty } from 'lodash'; import { - useState, useMemo, useEffect, SetStateAction, @@ -15,7 +12,7 @@ import { } from 'react'; type FundRequirementComponentProps = { - setReceivedFunds: Dispatch>; + setSpawnData: Dispatch>; rpc: string; address: Address; requirement: number; @@ -25,7 +22,7 @@ type FundRequirementComponentProps = { }; type FundingProps = { - fundRequirements: AddressNumberRecord; + fundRequirements: FundingRecord; FundRequirementComponent: ( props: FundRequirementComponentProps, ) => ReactElement; @@ -43,54 +40,52 @@ export const Funding = ({ symbol, contractAddress, }: FundingProps) => { - const { setSpawnData, rpc } = useSpawn(); - - const [receivedFunds, setReceivedFunds] = useState({ - ...(Object.keys(fundRequirements) as Address[]).reduce( - (acc: AddressBooleanRecord, address: Address) => { - acc[address] = false; - return acc; - }, - {}, - ), - }); + const { + setSpawnData, + spawnData: { rpc }, + } = useSpawn(); + const { token } = theme.useToken(); const timelineItems: TimelineItemProps[] = useMemo( () => (Object.keys(fundRequirements) as Address[]).map((address) => { + const { required, received } = fundRequirements[address]; return { children: ( ), - color: receivedFunds[address] ? COLOR.GREEN_2 : COLOR.RED, + color: received ? token.green : token.red, }; }) as TimelineItemProps[], [ FundRequirementComponent, contractAddress, fundRequirements, - receivedFunds, rpc, + setSpawnData, symbol, + token.green, + token.red, ], ); const hasSentAllFunds = useMemo(() => { if (isEmpty(fundRequirements)) return false; - return (Object.keys(receivedFunds) as Address[]).reduce( - (acc: boolean, address) => acc && receivedFunds[address], + return (Object.keys(fundRequirements) as Address[]).reduce( + (acc: boolean, address) => acc && fundRequirements[address].received, true, ); - }, [fundRequirements, receivedFunds]); + }, [fundRequirements]); + // if all funds have been sent, move to next page useEffect(() => { hasSentAllFunds && setSpawnData((prev) => ({ ...prev, screen: nextPage })); }, [hasSentAllFunds, nextPage, setSpawnData]); diff --git a/frontend/components/Spawn/SpawnAgentFunding.tsx b/frontend/components/Spawn/SpawnAgentFunding.tsx index e6f08e61d..ef9c1ba47 100644 --- a/frontend/components/Spawn/SpawnAgentFunding.tsx +++ b/frontend/components/Spawn/SpawnAgentFunding.tsx @@ -1,17 +1,61 @@ import { Funding } from './Funding/Funding'; import { SpawnScreen } from '@/enums'; import { FundRequirementETH } from './Funding/FundRequirement/FundRequirementETH'; -import { useSpawn } from '@/hooks'; +import { useAppInfo, useSpawn } from '@/hooks'; +import { Spin } from 'antd'; +import { useState, useEffect } from 'react'; +import MulticallService from '@/service/Multicall'; +import { Address, AddressNumberRecord } from '@/types'; type SpawnAgentFundingProps = { nextPage: SpawnScreen; }; export const SpawnAgentFunding = (props: SpawnAgentFundingProps) => { - const { agentFundRequirements: fundRequirements } = useSpawn(); + const { + setSpawnData, + spawnData: { rpc, agentFundRequirements }, + } = useSpawn(); + const { userPublicKey } = useAppInfo(); + const [isInitialLoaded, setIsInitialLoaded] = useState(false); + + useEffect(() => { + if (!(!isInitialLoaded && userPublicKey)) return; + const agentAddresses = Object.keys(agentFundRequirements) as Address[]; + MulticallService.getEthBalances(agentAddresses, rpc).then( + (balances: AddressNumberRecord) => { + setSpawnData((prev) => ({ + ...prev, + agentFundRequirements: agentAddresses.reduce( + (acc, address) => ({ + ...acc, + [address]: { + ...agentFundRequirements[address], + received: balances[address] > 1, + }, + }), + {}, + ), + })); + setIsInitialLoaded(true); + }, + ); + }, [ + agentFundRequirements, + isInitialLoaded, + rpc, + setSpawnData, + userPublicKey, + ]); + + // if not inital loaded, show loader + if (agentFundRequirements === undefined || !isInitialLoaded) { + return ; + } + return ( { + const { resetSpawn } = useSpawn(); const router = useRouter(); return ( {message} - diff --git a/frontend/components/Spawn/SpawnMasterWalletFunding.tsx b/frontend/components/Spawn/SpawnMasterWalletFunding.tsx index 6b64c400e..5eed88f23 100644 --- a/frontend/components/Spawn/SpawnMasterWalletFunding.tsx +++ b/frontend/components/Spawn/SpawnMasterWalletFunding.tsx @@ -2,65 +2,52 @@ import { SpawnScreen } from '@/enums'; import { Funding } from './Funding/Funding'; import { FundRequirementETH } from './Funding/FundRequirement/FundRequirementETH'; import { useAppInfo, useSpawn } from '@/hooks'; -import { useEffect, useMemo, useState } from 'react'; -import { AddressNumberRecord } from '@/types'; -import { Spin } from 'antd'; -import EthersService from '@/service/Ethers'; +import { useEffect, useState } from 'react'; +import { Spin, message } from 'antd'; +import { EthersService } from '@/service'; export const SpawnMasterWalletFunding = ({ nextPage, }: { nextPage: SpawnScreen; }) => { - const { setSpawnData, rpc } = useSpawn(); + const { + setSpawnData, + spawnData: { rpc, masterWalletFundRequirements }, + } = useSpawn(); const { userPublicKey } = useAppInfo(); - - const [masterWalletBalance, setMasterWalletBalance] = useState< - number | undefined - >(); - - const masterWalletFundRequirements: AddressNumberRecord | undefined = useMemo( - () => - userPublicKey - ? { - [userPublicKey]: 1, - } - : undefined, - [userPublicKey], - ); - - const isMasterWalletFunded: boolean | undefined = useMemo( - () => - userPublicKey && - masterWalletBalance !== undefined && - masterWalletFundRequirements - ? masterWalletBalance >= masterWalletFundRequirements[userPublicKey] - : undefined, - [masterWalletBalance, masterWalletFundRequirements, userPublicKey], - ); + const [isInitialLoaded, setIsInitialLoaded] = useState(false); useEffect(() => { - userPublicKey && - EthersService.getEthBalance(userPublicKey, rpc).then( - setMasterWalletBalance, - ); - }, [rpc, userPublicKey]); + if (!isInitialLoaded && userPublicKey) { + EthersService.getEthBalance(userPublicKey, rpc) + .then((balance) => { + setSpawnData((prev) => ({ + ...prev, + masterWalletFundRequirements: { + [userPublicKey]: { + ...prev.masterWalletFundRequirements[userPublicKey], + received: balance > 1, + }, + }, + })); + setIsInitialLoaded(true); + }) + .catch(() => message.error('Failed to get master wallet balance')); + } + }, [ + isInitialLoaded, + masterWalletFundRequirements, + rpc, + setSpawnData, + userPublicKey, + ]); // if not inital loaded, show loader - if ( - masterWalletBalance === undefined || - isMasterWalletFunded === undefined || - masterWalletFundRequirements === undefined - ) { + if (masterWalletFundRequirements === undefined || !isInitialLoaded) { return ; } - // if master wallet is already funded, don't show the funding component, skip to next page - if (isMasterWalletFunded) { - setSpawnData((prev) => ({ ...prev, screen: nextPage })); - return <>; - } - return ( { - const { setSpawnData, rpc } = useSpawn(); + const { + setSpawnData, + spawnData: { rpc }, + } = useSpawn(); const { userPublicKey } = useAppInfo(); const [isCheckingRpc, setIsCheckingRpc] = useState(false); @@ -41,7 +44,6 @@ export const SpawnRPC = ({ nextPage }: { nextPage: SpawnScreen }) => { [setSpawnData], ); - // eslint-disable-next-line react-hooks/exhaustive-deps const debounceCheckRpc = useCallback( debounce((rpcInput: string) => { if (isCheckingRpc) return; @@ -60,6 +62,8 @@ export const SpawnRPC = ({ nextPage }: { nextPage: SpawnScreen }) => { }) .finally(() => setIsCheckingRpc(false)); }, 1000), + // Does not require deps; updating the function will destroy the debounce + // eslint-disable-next-line react-hooks/exhaustive-deps [], ); @@ -86,18 +90,7 @@ export const SpawnRPC = ({ nextPage }: { nextPage: SpawnScreen }) => { return; } - // TEMPORARY BALANCE CHECK (TO BE RESOLVED WITH MASTER WALLET CONTEXT & HOOK WHEN PUBLIC RPC IS AUTHORISED) - const nativeBalance: number | undefined = await EthersService.getEthBalance( - userPublicKey, - rpc, - ).catch(() => undefined); - - if (nativeBalance === undefined) { - message.error('Failed to get master wallet balance'); - return; - } - - setSpawnData((prev) => ({ ...prev, screen: nextPage, nativeBalance })); + setSpawnData((prev) => ({ ...prev, screen: nextPage })); }, [nextPage, rpc, rpcState, setSpawnData, userPublicKey]); const isContinueDisabled: boolean = useMemo( diff --git a/frontend/components/Spawn/SpawnStakingCheck.tsx b/frontend/components/Spawn/SpawnStakingCheck.tsx index 4e15d3f44..53a4ba82f 100644 --- a/frontend/components/Spawn/SpawnStakingCheck.tsx +++ b/frontend/components/Spawn/SpawnStakingCheck.tsx @@ -1,9 +1,8 @@ import { Service } from '@/client'; import { TOKENS } from '@/constants'; import { SpawnScreen } from '@/enums'; -import { useServices, useAppInfo, useSpawn } from '@/hooks'; -import EthersService from '@/service/Ethers'; -import { Address, AddressNumberRecord } from '@/types'; +import { useAppInfo, useSpawn } from '@/hooks'; +import { EthersService } from '@/service'; import { Button, Flex, Skeleton, Typography, message } from 'antd'; import { ethers } from 'ethers'; import { useCallback, useMemo, useState } from 'react'; @@ -18,8 +17,11 @@ type SpawnStakingCheckProps = { }; export const SpawnStakingCheck = ({ nextPage }: SpawnStakingCheckProps) => { - const { setSpawnData, serviceTemplate, rpc } = useSpawn(); - const { createService } = useServices(); + const { + spawnData: { rpc, serviceTemplate }, + setSpawnData, + createService, + } = useSpawn(); const { userPublicKey } = useAppInfo(); const [isCreating, setIsCreating] = useState(false); @@ -28,66 +30,38 @@ export const SpawnStakingCheck = ({ nextPage }: SpawnStakingCheckProps) => { /** * Creates service, then performs relevant state updates */ - const create = useCallback( - async (useStaking: boolean) => { + const createAndNext = useCallback( + async ({ isStaking }: { isStaking: boolean }) => { if (isCreating) { message.error('Service creation already in progress'); return; } - if (!serviceTemplate || !rpc) { - setSpawnData((prev) => ({ ...prev, screen: SpawnScreen.ERROR })); - return; - } - setIsCreating(true); - let service: Service; try { - service = await createService({ - ...serviceTemplate, - configuration: { - ...serviceTemplate.configuration, - rpc, - use_staking: useStaking, - }, - }); + const service: Service | undefined = await createService(isStaking); + if (!service) throw new Error('Failed to create service'); + message.success('Service created successfully'); + setSpawnData((prev) => ({ + ...prev, + isStaking, + screen: nextPage, + })); } catch (e) { message.error('Failed to create service'); + } finally { setIsCreating(false); - return; + setButtonClicked(undefined); } - - // Set agent funding requirements - let agentFundRequirements: AddressNumberRecord = {}; - if (service.chain_data?.instances) { - agentFundRequirements = service.chain_data.instances.reduce( - (acc: AddressNumberRecord, address: Address) => ({ - ...acc, - [address]: serviceTemplate.configuration.fund_requirements.agent, - }), - {}, - ); - } - - // Set multisig funding requirements from multisig/safe - if (service.chain_data?.multisig) { - const { multisig } = service.chain_data; - const { safe } = serviceTemplate.configuration.fund_requirements; - agentFundRequirements[multisig] = safe; - } - - setSpawnData((prev) => ({ ...prev, agentFundRequirements })); - - return service; }, - [createService, isCreating, rpc, serviceTemplate, setSpawnData], + [createService, isCreating, nextPage, setSpawnData], ); /** * Checks if the user has the required OLAS to stake */ - const preflightStakingCheck = useCallback((): Promise => { + const preflightStakingCheck = useCallback(async (): Promise => { if (!userPublicKey) { return Promise.reject('No public key found'); } @@ -129,44 +103,16 @@ export const SpawnStakingCheck = ({ nextPage }: SpawnStakingCheckProps) => { return setButtonClicked(undefined); } - const service: Service | undefined = await create(true); - - if (!service) { - message.error('Failed to create service'); - } else { - message.success('Service created successfully'); - - setSpawnData((prev) => ({ - ...prev, - isStaking: true, - screen: nextPage, - })); - } - - setButtonClicked(undefined); + createAndNext({ isStaking: true }); }; const handleNo = async () => { setButtonClicked(ButtonOptions.NO); - - const service: Service | undefined = await create(false); - - if (!service) { - message.error('Failed to create service'); - } else { - message.success('Service created successfully'); - - setSpawnData((prev) => ({ - ...prev, - isStaking: false, - screen: nextPage, - })); - } - setButtonClicked(undefined); + createAndNext({ isStaking: false }); }; const stakingRequirement: string | undefined = useMemo(() => { - if (!serviceTemplate?.configuration) return undefined; + if (!serviceTemplate?.configuration) return; const { olas_required_to_stake, olas_cost_of_bond } = serviceTemplate.configuration; return ethers.utils.formatUnits( diff --git a/frontend/components/YourAgents/ServiceCard/ServiceCard.tsx b/frontend/components/YourAgents/ServiceCard/ServiceCard.tsx index 9ce9ad5a7..8d86d409c 100644 --- a/frontend/components/YourAgents/ServiceCard/ServiceCard.tsx +++ b/frontend/components/YourAgents/ServiceCard/ServiceCard.tsx @@ -8,6 +8,7 @@ import { message, Tooltip, Popconfirm, + theme, } from 'antd'; import Image from 'next/image'; import { useCallback, useMemo, useState } from 'react'; @@ -20,28 +21,23 @@ import { ServiceHash, ServiceTemplate, } from '@/client'; -import { useMarketplace, useServices } from '@/hooks'; +import { useServiceTemplates, useServices } from '@/hooks'; import { ServiceCardTotalBalance } from './ServiceCardTotalBalance'; import { SERVICE_CARD_RPC_POLLING_INTERVAL, SERVICE_CARD_STATUS_POLLING_INTERVAL, } from '@/constants/intervals'; -import EthersService from '@/service/Ethers'; +import { ServicesService, EthersService } from '@/service'; +import { ServiceCardSettings } from './ServiceCardSettings'; type ServiceCardProps = { service: Service; }; export const ServiceCard = ({ service }: ServiceCardProps) => { - const { - stopService, - deployService, - deleteServices, - getServiceStatus, - deleteServiceState, - } = useServices(); - const { getServiceTemplate } = useMarketplace(); + const { deleteServiceState } = useServices(); + const { getServiceTemplate } = useServiceTemplates(); const [serviceStatus, setServiceStatus] = useState< DeploymentStatus | undefined @@ -54,12 +50,12 @@ export const ServiceCard = ({ service }: ServiceCardProps) => { const updateServiceStatus = useCallback( (serviceHash: ServiceHash): Promise => - getServiceStatus(serviceHash) + ServicesService.getServiceStatus(serviceHash) .then((r: Deployment) => setServiceStatus(r.status)) .catch(() => { setServiceStatus(undefined); }), - [getServiceStatus], + [], ); useInterval( @@ -70,7 +66,7 @@ export const ServiceCard = ({ service }: ServiceCardProps) => { const handleStart = useCallback(async () => { if (isStarting) return; setIsStarting(true); - deployService(service.hash) + ServicesService.deployService(service.hash) .then(async () => { message.success('Service started successfully'); }) @@ -82,12 +78,12 @@ export const ServiceCard = ({ service }: ServiceCardProps) => { .catch(() => message.error('Failed to update service status')) .finally(() => setIsStarting(false)); }); - }, [isStarting, deployService, service.hash, updateServiceStatus]); + }, [isStarting, service.hash, updateServiceStatus]); const handleStop = useCallback(async (): Promise => { if (isStopping) return; setIsStopping(true); - stopService(service.hash) + ServicesService.stopService(service.hash) .then(() => { message.success('Service stopped successfully'); }) @@ -99,17 +95,19 @@ export const ServiceCard = ({ service }: ServiceCardProps) => { .catch(() => message.error('Failed to update service status')) .finally(() => setIsStopping(false)); }); - }, [isStopping, service.hash, stopService, updateServiceStatus]); + }, [isStopping, service.hash, updateServiceStatus]); const handleDelete = useCallback(async () => { if (isDeleting) return; setIsDeleting(true); - deleteServices([service.hash]) - .catch(() => message.error('Failed to delete service')) - .finally(() => { + ServicesService.deleteServices({ hashes: [service.hash] }) + .then(() => { + message.success('Service deleted successfully'); deleteServiceState(service.hash); - }); - }, [deleteServiceState, deleteServices, isDeleting, service.hash]); + }) + .catch(() => message.error('Failed to delete service')) + .finally(() => setIsDeleting(false)); + }, [deleteServiceState, isDeleting, service.hash]); const buttons = useMemo( () => ({ @@ -130,10 +128,10 @@ export const ServiceCard = ({ service }: ServiceCardProps) => { <> Are you sure you want to delete this service?
- Your agent's private keys will be lost. + Your funds may be lost. } - placement="leftBottom" + placement="topLeft" onConfirm={handleDelete} > + + ); +}; diff --git a/frontend/context/AppInfoProvider.tsx b/frontend/context/AppInfoProvider.tsx index f052e1081..72490e7e8 100644 --- a/frontend/context/AppInfoProvider.tsx +++ b/frontend/context/AppInfoProvider.tsx @@ -31,7 +31,7 @@ export const AppInfoProvider = ({ children }: PropsWithChildren) => { return ( - {children} + {appInfo && children} ); }; diff --git a/frontend/context/SpawnProvider/SpawnProvider.tsx b/frontend/context/SpawnProvider/SpawnProvider.tsx index 9bd2b13a3..2281fe5a1 100644 --- a/frontend/context/SpawnProvider/SpawnProvider.tsx +++ b/frontend/context/SpawnProvider/SpawnProvider.tsx @@ -1,6 +1,4 @@ -import { ServiceTemplate } from '@/client'; -import { SpawnScreen } from '@/enums'; -import { AddressBooleanRecord, AddressNumberRecord } from '@/types'; +import { SpawnData } from '@/types'; import { Dispatch, PropsWithChildren, @@ -9,33 +7,20 @@ import { useState, } from 'react'; -type SpawnData = { - agentFundsReceived: AddressBooleanRecord; - agentFundRequirements: AddressNumberRecord; - isStaking?: boolean; - nativeBalance?: number; - rpc: string; - screen: SpawnScreen; - serviceTemplateHash?: string; - serviceTemplate?: ServiceTemplate; -}; - type SpawnContextType = { spawnData: SpawnData; setSpawnData: Dispatch>; }; -const FIRST_SPAWN_SCREEN: SpawnScreen = SpawnScreen.RPC; - export const DEFAULT_SPAWN_DATA: SpawnData = { - agentFundsReceived: {}, agentFundRequirements: {}, + masterWalletFundRequirements: {}, isStaking: undefined, nativeBalance: undefined, rpc: '', - screen: FIRST_SPAWN_SCREEN, - serviceTemplateHash: undefined, + screen: undefined, serviceTemplate: undefined, + service: undefined, }; export const SpawnContext = createContext({ @@ -45,7 +30,6 @@ export const SpawnContext = createContext({ export const SpawnProvider = ({ children }: PropsWithChildren) => { const [spawnData, setSpawnData] = useState(DEFAULT_SPAWN_DATA); - return ( { +export const useServiceTemplates = () => { const getServiceTemplates = (): ServiceTemplate[] => serviceTemplates; const getServiceTemplate = (hash: string): ServiceTemplate | undefined => serviceTemplates.find((template) => template.hash === hash); diff --git a/frontend/hooks/useServices.tsx b/frontend/hooks/useServices.tsx index 36d4ad27e..8cf79a9c5 100644 --- a/frontend/hooks/useServices.tsx +++ b/frontend/hooks/useServices.tsx @@ -1,30 +1,47 @@ import { Service, ServiceHash, ServiceTemplate } from '@/client'; import { ServicesContext } from '@/context'; import { ServicesService } from '@/service'; +import MulticallService from '@/service/Multicall'; +import { Address, AddressBooleanRecord } from '@/types'; import { useContext } from 'react'; -export const useServices = () => { - const { services, updateServicesState, hasInitialLoaded, setServices } = - useContext(ServicesContext); +const checkServiceIsFunded = async ( + service: Service, + serviceTemplate: ServiceTemplate, +): Promise => { + const { + chain_data: { instances, multisig }, + } = service; + + if (!instances || !multisig) return Promise.resolve(false); - // SERVICES SERVICE METHODS - const createService = async (serviceTemplate: Required) => - ServicesService.createService(serviceTemplate); + const addresses = [...instances, multisig]; - const deployService = async (serviceHash: ServiceHash) => - ServicesService.deployService(serviceHash); + const balances = await MulticallService.getEthBalances( + addresses, + service.ledger.rpc, + ); - const stopService = async (serviceHash: string) => - ServicesService.stopService(serviceHash); + if (!balances) return Promise.resolve(false); - const deleteServices = async (hashes: ServiceHash[]) => - ServicesService.deleteServices({ hashes }); + const fundRequirements: AddressBooleanRecord = addresses.reduce( + (acc: AddressBooleanRecord, address: Address) => ({ + ...acc, + [address]: instances.includes(address) + ? balances[address] > + serviceTemplate.configuration.fund_requirements.agent + : balances[address] > + serviceTemplate.configuration.fund_requirements.safe, + }), + {}, + ); - const getService = async (serviceHash: ServiceHash) => - ServicesService.getService(serviceHash); + return Promise.resolve(Object.values(fundRequirements).every((f) => f)); +}; - const getServiceStatus = async (serviceHash: ServiceHash) => - ServicesService.getServiceStatus(serviceHash); +export const useServices = () => { + const { services, updateServicesState, hasInitialLoaded, setServices } = + useContext(ServicesContext); // STATE METHODS const getServiceFromState = ( @@ -40,7 +57,7 @@ export const useServices = () => { hasInitialLoaded ? services : []; const updateServiceState = (serviceHash: ServiceHash) => - getService(serviceHash).then((service: Service) => + ServicesService.getService(serviceHash).then((service: Service) => setServices((prev) => { const index = prev.findIndex((s) => s.hash === serviceHash); // findIndex returns -1 if not found if (index === -1) return [...prev, service]; @@ -54,17 +71,12 @@ export const useServices = () => { setServices((prev) => prev.filter((s) => s.hash !== serviceHash)); return { - getService, getServiceFromState, getServicesFromState, - getServiceStatus, + checkServiceIsFunded, updateServicesState, updateServiceState, deleteServiceState, - createService, - deployService, - stopService, - deleteServices, hasInitialLoaded, }; }; diff --git a/frontend/hooks/useSpawn.tsx b/frontend/hooks/useSpawn.tsx index 650407ac6..0157d1ae2 100644 --- a/frontend/hooks/useSpawn.tsx +++ b/frontend/hooks/useSpawn.tsx @@ -1,17 +1,57 @@ -import { ServiceTemplate } from '@/client'; +import { Service, ServiceTemplate } from '@/client'; import { DEFAULT_SPAWN_DATA, SpawnContext } from '@/context'; import { SpawnScreen } from '@/enums'; import { useCallback, useContext, useMemo } from 'react'; -import { useMarketplace } from '.'; +import { message } from 'antd'; +import { ServicesService } from '@/service'; +import { useAppInfo, useServiceTemplates, useServices } from '.'; +import { Address, FundingRecord } from '@/types'; + +/** + * Generates agent fund requirements from valid service and service template + */ +const getAgentFundRequirements = ({ + serviceTemplate, + service, +}: { + serviceTemplate: ServiceTemplate; + service: Service; +}): FundingRecord | undefined => { + if (!serviceTemplate || !service?.chain_data.instances) return; + + // Agent funding requirements + let agentFundRequirements: FundingRecord = {}; + + const required = serviceTemplate.configuration.fund_requirements.agent; + + agentFundRequirements = service.chain_data.instances.reduce( + (acc: FundingRecord, address: Address) => ({ + ...acc, + [address]: { required, received: false }, + }), + {}, + ); + + // Multisig funding requirements + if (service.chain_data?.multisig) { + const { multisig } = service.chain_data; + const { safe } = serviceTemplate.configuration.fund_requirements; + agentFundRequirements[multisig] = { required: safe, received: false }; + } + + return agentFundRequirements; +}; export const useSpawn = () => { - const { getServiceTemplate } = useMarketplace(); const { spawnData, setSpawnData } = useContext(SpawnContext); - const { serviceTemplateHash, screen } = spawnData; + const { getServiceFromState } = useServices(); + const { getServiceTemplate } = useServiceTemplates(); + const { userPublicKey } = useAppInfo(); + // MEMOS const spawnPercentage: number = useMemo(() => { // Staking path - switch (screen) { + switch (spawnData.screen) { case SpawnScreen.RPC: return 0; case SpawnScreen.STAKING_CHECK: @@ -23,12 +63,41 @@ export const useSpawn = () => { default: return 0; } - }, [screen]); + }, [spawnData.screen]); + + const createService = useCallback( + async (useStaking: boolean): Promise => { + if (!spawnData.serviceTemplate || !spawnData.rpc) { + setSpawnData((prev) => ({ ...prev, screen: SpawnScreen.ERROR })); + return; + } - const serviceTemplate: ServiceTemplate | undefined = useMemo(() => { - if (!serviceTemplateHash) return; - return getServiceTemplate(serviceTemplateHash); - }, [getServiceTemplate, serviceTemplateHash]); + let service: Service; + try { + service = await ServicesService.createService({ + ...spawnData.serviceTemplate, + configuration: { + ...spawnData.serviceTemplate.configuration, + rpc: spawnData.rpc, + use_staking: useStaking, + }, + }); + } catch (e) { + message.error('Failed to create service'); + return; + } + + const agentFundRequirements = getAgentFundRequirements({ + serviceTemplate: spawnData.serviceTemplate, + service, + }); + if (!agentFundRequirements) return; + + setSpawnData((prev) => ({ ...prev, service, agentFundRequirements })); + return service; + }, + [setSpawnData, spawnData.rpc, spawnData.serviceTemplate], + ); const resetSpawn = useCallback( (): void => setSpawnData(DEFAULT_SPAWN_DATA), @@ -37,11 +106,78 @@ export const useSpawn = () => { [], ); + /** + * Call once to load spawn data + */ + const loadSpawn = useCallback( + ({ + serviceTemplateHash, + screen, + }: { + serviceTemplateHash: string; + screen: SpawnScreen | null | undefined; + }) => { + if (!userPublicKey) return; + try { + const serviceTemplate = getServiceTemplate(serviceTemplateHash); + if (!serviceTemplate) throw new Error('Service template not found'); + + if (screen) { + // Funding resume required + const service = getServiceFromState(serviceTemplateHash); + if (!service) throw new Error('Service not found'); + + const { + ledger: { rpc }, + } = service; + + const agentFundRequirements = getAgentFundRequirements({ + serviceTemplate, + service, + }); + + if (!agentFundRequirements) + throw new Error('Agent fund requirements not found'); + + setSpawnData((prev) => ({ + ...prev, + service, + serviceTemplate, + screen, + rpc, + masterWalletFundRequirements: { + [userPublicKey]: { required: 1, received: false }, + }, + agentFundRequirements, + })); + } else { + // No resume required + setSpawnData((prev) => ({ + ...prev, + serviceTemplate, + screen: SpawnScreen.RPC, + masterWalletFundRequirements: { + [userPublicKey]: { required: 1, received: false }, + }, + })); + } + } catch (e) { + setSpawnData((prev) => ({ + ...prev, + screen: SpawnScreen.ERROR, + })); + } + }, + [getServiceFromState, getServiceTemplate, setSpawnData, userPublicKey], + ); + return { - ...spawnData, + spawnData, spawnPercentage, - serviceTemplate, - setSpawnData, + getAgentFundRequirements, + createService, + loadSpawn, resetSpawn, + setSpawnData, }; }; diff --git a/frontend/package.json b/frontend/package.json index e6bccd236..98c6b0f0d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,7 @@ { "author": "Valory AG", "dependencies": { + "@ant-design/cssinjs": "^1.18.4", "@ant-design/icons": "^5.3.0", "antd": "^5.14.0", "ethers": "5.7.2", @@ -8,10 +9,9 @@ "next": "14.1.0", "react": "^18", "react-dom": "^18", + "sudo-prompt": "9.2.1", "swr": "^2.2.4", - "usehooks-ts": "^2.14.0", - "@ant-design/cssinjs": "^1.18.4", - "sudo-prompt": "9.2.1" + "usehooks-ts": "^2.14.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.2", diff --git a/frontend/pages/spawn/[serviceTemplateHash].tsx b/frontend/pages/spawn/[serviceTemplateHash].tsx index 06352fa1b..ed4946695 100644 --- a/frontend/pages/spawn/[serviceTemplateHash].tsx +++ b/frontend/pages/spawn/[serviceTemplateHash].tsx @@ -3,7 +3,7 @@ import { SpawnScreen } from '@/enums'; import { useSpawn } from '@/hooks'; import { GetServerSidePropsContext } from 'next'; import dynamic from 'next/dynamic'; -import { ReactElement, useEffect, useMemo } from 'react'; +import { ReactElement, useEffect, useMemo, useState } from 'react'; const SpawnAgentFunding = dynamic( () => @@ -47,30 +47,30 @@ const SpawnMasterWalletFunding = dynamic( export const getServerSideProps = async ( context: GetServerSidePropsContext, ) => { - const { serviceTemplateHash } = context.query; - return { props: { serviceTemplateHash } }; + const { serviceTemplateHash, screen } = context.query; + return { props: { serviceTemplateHash, screen: screen ? screen : null } }; }; type SpawnPageProps = { serviceTemplateHash: string; + screen: SpawnScreen | null; }; -export const SpawnPage = ({ serviceTemplateHash }: SpawnPageProps) => { - const { screen, setSpawnData } = useSpawn(); +export const SpawnPage = (props: SpawnPageProps) => { + const { loadSpawn, spawnData } = useSpawn(); + const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { - setSpawnData((prev) => { - return { - ...prev, - serviceTemplateHash, - }; + if (isLoaded) return; + loadSpawn({ + serviceTemplateHash: props.serviceTemplateHash, + screen: props.screen, }); - // Not required to run this effect on every render, only once - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + setIsLoaded(true); + }, [isLoaded, loadSpawn, props.screen, props.serviceTemplateHash, spawnData]); const spawnScreen: ReactElement = useMemo(() => { - switch (screen) { + switch (spawnData.screen) { case SpawnScreen.RPC: return ; @@ -91,7 +91,7 @@ export const SpawnPage = ({ serviceTemplateHash }: SpawnPageProps) => { default: return ; } - }, [screen]); + }, [spawnData.screen]); return ( <> diff --git a/frontend/service/Ethers.ts b/frontend/service/Ethers.ts index 56197a6a6..4933adc5b 100644 --- a/frontend/service/Ethers.ts +++ b/frontend/service/Ethers.ts @@ -87,10 +87,8 @@ const checkRpc = async (rpc: string): Promise => { } }; -const EthersService = { +export const EthersService = { getEthBalance, getErc20Balance, checkRpc, }; - -export default EthersService; diff --git a/frontend/service/index.ts b/frontend/service/index.ts index 341c136cd..a8e95c1ce 100644 --- a/frontend/service/index.ts +++ b/frontend/service/index.ts @@ -1,2 +1,4 @@ export * from './AppInfo'; export * from './Services'; +export * from './Ethers'; +export * from './Multicall'; diff --git a/frontend/types/Records.ts b/frontend/types/Records.ts index b0c82ea3f..ffeb2271e 100644 --- a/frontend/types/Records.ts +++ b/frontend/types/Records.ts @@ -2,3 +2,7 @@ import { Address } from '.'; export type AddressNumberRecord = Record; export type AddressBooleanRecord = Record; +export type FundingRecord = Record< + Address, + { required: number; received: boolean } +>; diff --git a/frontend/types/SpawnData.ts b/frontend/types/SpawnData.ts new file mode 100644 index 000000000..51606126c --- /dev/null +++ b/frontend/types/SpawnData.ts @@ -0,0 +1,14 @@ +import { ServiceTemplate, Service } from '@/client'; +import { SpawnScreen } from '@/enums'; +import { FundingRecord } from '.'; + +export type SpawnData = { + agentFundRequirements: FundingRecord; + masterWalletFundRequirements: FundingRecord; + isStaking?: boolean; + nativeBalance?: number; + rpc: string; + screen?: SpawnScreen; + serviceTemplate?: ServiceTemplate; + service?: Service; +}; diff --git a/frontend/types/index.ts b/frontend/types/index.ts index 31d520470..51e41edc0 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -1,3 +1,4 @@ export type * from './QRModalData'; export type * from './Address'; export type * from './Records'; +export type * from './SpawnData'; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 27a17a503..a6f81ff83 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5436,6 +5436,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.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5946,6 +5947,7 @@ which@^2.0.1: isexe "^2.0.0" "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.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== diff --git a/package.json b/package.json index 486f9b317..ca8d186cc 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,9 @@ "ps-tree": "^1.2.0", "react": "^18", "react-dom": "^18", + "sudo-prompt": "9.2.1", "swr": "^2.2.4", - "usehooks-ts": "^2.14.0", - "sudo-prompt": "9.2.1" + "usehooks-ts": "^2.14.0" }, "devDependencies": { "concurrently": "^8.2.2",