diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 31d0114c2..0f35a4bf0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: env: NODE_ENV: production DEV_RPC: http://localhost:8545 - FORK_URL: https://gnosis-pokt.nodies.app + FORK_URL: https://rpc-gate.autonolas.tech/gnosis-rpc/ - run: rm -rf /dist @@ -55,7 +55,7 @@ jobs: CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_LINK: ${{ secrets.CSC_LINK }} DEV_RPC: http://localhost:8545 - FORK_URL: https://gnosis-pokt.nodies.app + FORK_URL: https://rpc-gate.autonolas.tech/gnosis-rpc/ GH_TOKEN: ${{ secrets.github_token}} NODE_ENV: production #PUBLISH_FOR_PULL_REQUEST: true #required during testing diff --git a/frontend/components/Main/MainAddFunds.tsx b/frontend/components/Main/MainAddFunds.tsx index c955f1dc7..599e809b6 100644 --- a/frontend/components/Main/MainAddFunds.tsx +++ b/frontend/components/Main/MainAddFunds.tsx @@ -20,7 +20,7 @@ import styled from 'styled-components'; import { copyToClipboard, truncateAddress } from '@/common-util'; import { COLOR, COW_SWAP_GNOSIS_XDAI_OLAS_URL } from '@/constants'; import { UNICODE_SYMBOLS } from '@/constants/unicode'; -import { useBalance } from '@/hooks'; +import { useWallet } from '@/hooks/useWallet'; import { Address } from '@/types'; import { CardSection } from '../styled/CardSection'; @@ -34,22 +34,24 @@ const CustomizedCardSection = styled(CardSection)<{ border?: boolean }>` `; export const MainAddFunds = () => { - const { wallets } = useBalance(); + const { masterSafeAddress, masterEoaAddress } = useWallet(); const [isAddFundsVisible, setIsAddFundsVisible] = useState(false); - const walletAddress = useMemo(() => wallets[0]?.address, [wallets]); + const fundingAddress: Address | undefined = + masterSafeAddress ?? masterEoaAddress; - const truncatedWalletAddress = useMemo( - () => truncateAddress(walletAddress), - [walletAddress], + const truncatedFundingAddress: string | undefined = useMemo( + () => fundingAddress && truncateAddress(fundingAddress), + [fundingAddress], ); - const handleCopyWalletAddress = useCallback( + const handleCopyAddress = useCallback( () => - copyToClipboard(walletAddress).then(() => + fundingAddress && + copyToClipboard(fundingAddress).then(() => message.success('Copied successfully!'), ), - [walletAddress], + [fundingAddress], ); return ( @@ -78,9 +80,9 @@ export const MainAddFunds = () => { <> @@ -111,19 +113,23 @@ const AddFundsWarningAlertSection = () => ( ); const AddFundsAddressSection = ({ - walletAddress, - truncatedWalletAddress, + fundingAddress, + truncatedFundingAddress, handleCopy, }: { - walletAddress: Address; - truncatedWalletAddress: string; + fundingAddress?: string; + truncatedFundingAddress?: string; handleCopy: () => void; }) => ( {walletAddress}} + title={ + + {fundingAddress ?? 'Error loading address'} + + } > - GNO: {truncatedWalletAddress} + GNO: {truncatedFundingAddress ?? '--'} diff --git a/frontend/components/Settings/Settings.tsx b/frontend/components/Settings/Settings.tsx index c7099aa0e..ded9d44f9 100644 --- a/frontend/components/Settings/Settings.tsx +++ b/frontend/components/Settings/Settings.tsx @@ -1,9 +1,14 @@ import { CloseOutlined, SettingOutlined } from '@ant-design/icons'; -import { Button, Card, Flex, Input, message, Typography } from 'antd'; +import { Alert, Button, Card, Flex, Input, message, Typography } from 'antd'; +import Link from 'next/link'; import { useMemo, useState } from 'react'; +import { truncateAddress } from '@/common-util'; +import { COLOR } from '@/constants'; +import { UNICODE_SYMBOLS } from '@/constants/unicode'; import { PageState, SettingsScreen } from '@/enums'; import { usePageState } from '@/hooks'; +import { useMasterSafe } from '@/hooks/useMasterSafe'; import { useSettings } from '@/hooks/useSettings'; import { CardTitle } from '../common/CardTitle'; @@ -27,13 +32,17 @@ export const Settings = () => { }; const SettingsMain = () => { + const { backupSafeAddress } = useMasterSafe(); const { goto } = usePageState(); - const { goto: gotoSettings } = useSettings(); - - // TODO: implement safe owners count const [isUpdating, setIsUpdating] = useState(false); + const truncatedBackupSafeAddress: string | undefined = useMemo(() => { + if (backupSafeAddress) { + return truncateAddress(backupSafeAddress); + } + }, [backupSafeAddress]); + const handleClick = () => { if (isUpdating) handleSave(); setIsUpdating((prev) => !prev); @@ -77,17 +86,58 @@ const SettingsMain = () => { - - Backup wallet - + + Backup wallet + {backupSafeAddress ? ( + + {truncatedBackupSafeAddress} {UNICODE_SYMBOLS.EXTERNAL_LINK} + + ) : ( + + )} ); }; + +const NoBackupWallet = () => { + const { goto: gotoSettings } = useSettings(); + return ( + <> + + No backup wallet added. + + + + + + Your funds are at risk! + + + You will lose any assets you send on other chains. + + + + } + /> + + + + ); +}; diff --git a/frontend/components/Setup/Create/SetupEoaFunding.tsx b/frontend/components/Setup/Create/SetupEoaFunding.tsx index d0e1568e8..1ae362b71 100644 --- a/frontend/components/Setup/Create/SetupEoaFunding.tsx +++ b/frontend/components/Setup/Create/SetupEoaFunding.tsx @@ -16,17 +16,20 @@ import { Chain } from '@/client'; import { copyToClipboard } from '@/common-util'; import { CardFlex } from '@/components/styled/CardFlex'; import { CardSection } from '@/components/styled/CardSection'; -import { COLOR, COW_SWAP_GNOSIS_XDAI_OLAS_URL } from '@/constants'; +import { + COLOR, + COW_SWAP_GNOSIS_XDAI_OLAS_URL, + MIN_ETH_BALANCE_THRESHOLDS, +} from '@/constants'; import { UNICODE_SYMBOLS } from '@/constants/unicode'; import { PageState, SetupScreen } from '@/enums'; import { useBalance, usePageState, useSetup } from '@/hooks'; +import { useWallet } from '@/hooks/useWallet'; import { WalletService } from '@/service/Wallet'; import { Address } from '@/types'; import { SetupCreateHeader } from './SetupCreateHeader'; -const MASTER_EAO_FUNDING_AMOUNT_ETH = 0.1; - enum SetupEaoFundingStatus { WaitingForEoaFunding, CreatingSafe, @@ -40,26 +43,27 @@ const loadingStatuses = [ ]; export const SetupEoaFunding = () => { - const { wallets, walletBalances } = useBalance(); + const { masterEoaAddress: masterEaoAddress, masterSafeAddress } = useWallet(); + const { walletBalances } = useBalance(); const { backupSigner } = useSetup(); const { goto } = usePageState(); const [isCreatingSafe, setIsCreatingSafe] = useState(false); - const masterEoa = wallets?.[0]?.address; - const masterEaoEthBalance = walletBalances?.[masterEoa]?.ETH; - - const masterSafe = wallets?.[0]?.safe; + const masterEaoEthBalance = + masterEaoAddress && walletBalances?.[masterEaoAddress]?.ETH; const isFundedMasterEoa = - masterEaoEthBalance && masterEaoEthBalance >= MASTER_EAO_FUNDING_AMOUNT_ETH; + masterEaoEthBalance && + masterEaoEthBalance >= + MIN_ETH_BALANCE_THRESHOLDS[Chain.GNOSIS].safeCreation; const status = useMemo(() => { if (!isFundedMasterEoa) return SetupEaoFundingStatus.WaitingForEoaFunding; if (isCreatingSafe) return SetupEaoFundingStatus.CreatingSafe; - if (masterSafe) return SetupEaoFundingStatus.Done; + if (masterSafeAddress) return SetupEaoFundingStatus.Done; return SetupEaoFundingStatus.Error; - }, [isCreatingSafe, isFundedMasterEoa, masterSafe]); + }, [isCreatingSafe, isFundedMasterEoa, masterSafeAddress]); const statusMessage = useMemo(() => { switch (status) { @@ -81,7 +85,7 @@ export const SetupEoaFunding = () => { setIsCreatingSafe(true); message.success('Funds have been received!'); // TODO: add backup signer - WalletService.createSafe(Chain.GNOSIS).catch((e) => { + WalletService.createSafe(Chain.GNOSIS, backupSigner).catch((e) => { console.error(e); message.error('Failed to create an account. Please try again later.'); }); @@ -89,8 +93,8 @@ export const SetupEoaFunding = () => { useEffect(() => { // Only progress is the safe is created and accessible via context (updates on interval) - if (masterSafe) goto(PageState.Main); - }, [goto, masterSafe]); + if (masterSafeAddress) goto(PageState.Main); + }, [goto, masterSafeAddress]); return ( @@ -99,7 +103,7 @@ export const SetupEoaFunding = () => { disabled={isCreatingSafe} /> - Deposit {MASTER_EAO_FUNDING_AMOUNT_ETH} XDAI + Deposit {MIN_ETH_BALANCE_THRESHOLDS[Chain.GNOSIS].safeCreation} XDAI The app needs these funds to create your account on-chain. @@ -115,7 +119,9 @@ export const SetupEoaFunding = () => { Status: {statusMessage} - {!isFundedMasterEoa && } + {!isFundedMasterEoa && ( + + )} ); }; diff --git a/frontend/constants/thresholds.ts b/frontend/constants/thresholds.ts index 9a24038bc..81e577534 100644 --- a/frontend/constants/thresholds.ts +++ b/frontend/constants/thresholds.ts @@ -2,7 +2,7 @@ import { Chain } from '@/client'; export const MIN_ETH_BALANCE_THRESHOLDS = { [Chain.GNOSIS]: { - safeCreation: 0.1, + safeCreation: 1.5, safeAddSigner: 0.1, }, }; diff --git a/frontend/context/BalanceProvider.tsx b/frontend/context/BalanceProvider.tsx index 1c45694f3..26234ee2f 100644 --- a/frontend/context/BalanceProvider.tsx +++ b/frontend/context/BalanceProvider.tsx @@ -23,7 +23,6 @@ import { TOKENS } from '@/constants/tokens'; import { Token } from '@/enums/Token'; import { EthersService } from '@/service'; import MulticallService from '@/service/Multicall'; -import { WalletService } from '@/service/Wallet'; import { Address, AddressNumberRecord, @@ -32,6 +31,7 @@ import { import { ServicesContext } from '.'; import { RewardContext } from './RewardProvider'; +import { WalletContext } from './WalletProvider'; export const BalanceContext = createContext<{ isLoaded: boolean; @@ -41,7 +41,7 @@ export const BalanceContext = createContext<{ olasDepositBalance?: number; totalEthBalance?: number; totalOlasBalance?: number; - wallets: Wallet[]; + wallets?: Wallet[]; walletBalances: WalletAddressNumberRecord; updateBalances: () => Promise; setIsPaused: Dispatch>; @@ -53,13 +53,15 @@ export const BalanceContext = createContext<{ olasDepositBalance: undefined, totalEthBalance: undefined, totalOlasBalance: undefined, - wallets: [], + wallets: undefined, walletBalances: {}, updateBalances: async () => {}, setIsPaused: () => {}, }); export const BalanceProvider = ({ children }: PropsWithChildren) => { + const { wallets, masterEoaAddress, masterSafeAddress } = + useContext(WalletContext); const { services, serviceAddresses } = useContext(ServicesContext); const { optimisticRewardsEarnedForEpoch } = useContext(RewardContext); @@ -68,7 +70,6 @@ export const BalanceProvider = ({ children }: PropsWithChildren) => { const [olasDepositBalance, setOlasDepositBalance] = useState(); const [olasBondBalance, setOlasBondBalance] = useState(); const [isBalanceLoaded, setIsBalanceLoaded] = useState(false); - const [wallets, setWallets] = useState([]); const [walletBalances, setWalletBalances] = useState({}); @@ -105,32 +106,32 @@ export const BalanceProvider = ({ children }: PropsWithChildren) => { const updateBalances = useCallback(async (): Promise => { try { - const wallets = await getWallets(); - if (!wallets) return; - - setWallets(wallets); - - const walletAddresses = getWalletAddresses(wallets, serviceAddresses); + const walletAddresses: Address[] = []; + if (masterEoaAddress) walletAddresses.push(masterEoaAddress); + if (masterSafeAddress) walletAddresses.push(masterSafeAddress); + if (serviceAddresses) walletAddresses.push(...serviceAddresses); const walletBalances = await getWalletBalances(walletAddresses); if (!walletBalances) return; setWalletBalances(walletBalances); const serviceId = services?.[0]?.chain_data.token; + if (!isNumber(serviceId)) { setIsLoaded(true); setIsBalanceLoaded(true); return; } - const serviceRegistryBalances = await getServiceRegistryBalances( - wallets[0].address, - serviceId, - ); + if (masterSafeAddress && serviceId) { + const serviceRegistryBalances = await getServiceRegistryBalances( + masterSafeAddress, + serviceId, + ); - // update olas balances - setOlasDepositBalance(serviceRegistryBalances.depositValue); - setOlasBondBalance(serviceRegistryBalances.bondValue); + setOlasDepositBalance(serviceRegistryBalances.depositValue); + setOlasBondBalance(serviceRegistryBalances.bondValue); + } // update balance loaded state setIsLoaded(true); @@ -140,7 +141,7 @@ export const BalanceProvider = ({ children }: PropsWithChildren) => { message.error('Unable to retrieve wallet balances'); setIsBalanceLoaded(true); } - }, [serviceAddresses, services]); + }, [masterEoaAddress, masterSafeAddress, serviceAddresses, services]); useInterval( () => { @@ -195,9 +196,6 @@ export const getOlasBalances = async ( return olasBalances; }; -export const getWallets = async (): Promise => - WalletService.getWallets(); - export const getWalletAddresses = ( wallets: Wallet[], serviceAddresses: Address[], diff --git a/frontend/context/MasterSafeProvider.tsx b/frontend/context/MasterSafeProvider.tsx index 5f364da95..9bd27e393 100644 --- a/frontend/context/MasterSafeProvider.tsx +++ b/frontend/context/MasterSafeProvider.tsx @@ -9,43 +9,32 @@ import { useMemo, useState, } from 'react'; +import { useInterval } from 'usehooks-ts'; import { GnosisSafeService } from '@/service/GnosisSafe'; import { Address } from '@/types'; -import { BalanceContext } from '.'; +import { WalletContext } from './WalletProvider'; export const MasterSafeContext = createContext<{ backupSafeAddress?: Address; masterSafeAddress?: Address; masterEoaAddress?: Address; masterSafeOwners?: Address[]; - updateOwners?: () => Promise; + updateMasterSafeOwners?: () => Promise; }>({ backupSafeAddress: undefined, masterSafeAddress: undefined, masterEoaAddress: undefined, masterSafeOwners: undefined, - updateOwners: async () => {}, + updateMasterSafeOwners: async () => {}, }); export const MasterSafeProvider = ({ children }: PropsWithChildren) => { - const { wallets } = useContext(BalanceContext); + const { masterSafeAddress, masterEoaAddress } = useContext(WalletContext); const [masterSafeOwners, setMasterSafeOwners] = useState(); - const masterSafeAddress = useMemo
(() => { - if (!wallets) return; - if (!wallets.length) return; - return wallets[0].safe; - }, [wallets]); - - const masterEoaAddress = useMemo
(() => { - if (!wallets) return; - if (!wallets.length) return; - return wallets[0].address; - }, [wallets]); - const backupSafeAddress = useMemo
(() => { if (!masterEoaAddress) return; if (!masterSafeOwners) return; @@ -62,22 +51,31 @@ export const MasterSafeProvider = ({ children }: PropsWithChildren) => { return currentBackupAddress; }, [masterEoaAddress, masterSafeOwners]); - const updateOwners = async () => { + const updateMasterSafeOwners = async () => { if (!masterSafeAddress) return; - const safeSigners = await GnosisSafeService.getOwners({ - address: masterSafeAddress, - }); - setMasterSafeOwners(safeSigners); + try { + const safeSigners = await GnosisSafeService.getOwners({ + address: masterSafeAddress, + }); + if (!safeSigners) return; + setMasterSafeOwners(safeSigners); + } catch (error) { + console.error('Error fetching safe owners', error); + } }; + useInterval( + updateMasterSafeOwners, + masterSafeOwners && masterSafeOwners.length >= 2 ? null : 5000, + ); + return ( {children} diff --git a/frontend/context/WalletProvider.tsx b/frontend/context/WalletProvider.tsx new file mode 100644 index 000000000..533594308 --- /dev/null +++ b/frontend/context/WalletProvider.tsx @@ -0,0 +1,46 @@ +import { createContext, PropsWithChildren, useState } from 'react'; +import { useInterval } from 'usehooks-ts'; + +import { Wallet } from '@/client'; +import { WalletService } from '@/service/Wallet'; +import { Address } from '@/types'; + +export const WalletContext = createContext<{ + masterEoaAddress?: Address; + masterSafeAddress?: Address; + wallets?: Wallet[]; + updateWallets: () => Promise; +}>({ + masterEoaAddress: undefined, + masterSafeAddress: undefined, + wallets: undefined, + updateWallets: async () => {}, +}); + +export const WalletProvider = ({ children }: PropsWithChildren) => { + const [wallets, setWallets] = useState(); + + const masterEoaAddress: Address | undefined = wallets?.[0]?.address; + const masterSafeAddress: Address | undefined = wallets?.[0]?.safe; + + const updateWallets = async () => { + const wallets = await WalletService.getWallets(); + if (!wallets) return; + setWallets(wallets); + }; + + useInterval(updateWallets, 5000); + + return ( + + {children} + + ); +}; diff --git a/frontend/hooks/useMasterSafe.tsx b/frontend/hooks/useMasterSafe.tsx new file mode 100644 index 000000000..ec5d52059 --- /dev/null +++ b/frontend/hooks/useMasterSafe.tsx @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import { MasterSafeContext } from '@/context/MasterSafeProvider'; + +export const useMasterSafe = () => { + return useContext(MasterSafeContext); +}; diff --git a/frontend/hooks/useWallet.tsx b/frontend/hooks/useWallet.tsx new file mode 100644 index 000000000..be2b58b88 --- /dev/null +++ b/frontend/hooks/useWallet.tsx @@ -0,0 +1,15 @@ +import { useContext } from 'react'; + +import { WalletContext } from '@/context/WalletProvider'; + +export const useWallet = () => { + const { wallets, masterEoaAddress, masterSafeAddress, updateWallets } = + useContext(WalletContext); + + return { + wallets, + masterEoaAddress, + masterSafeAddress, + updateWallets, + }; +}; diff --git a/frontend/pages/_app.tsx b/frontend/pages/_app.tsx index a2f40fe3b..3a8fc90f4 100644 --- a/frontend/pages/_app.tsx +++ b/frontend/pages/_app.tsx @@ -12,8 +12,10 @@ import { SetupProvider, } from '@/context'; import { BalanceProvider } from '@/context/BalanceProvider'; +import { MasterSafeProvider } from '@/context/MasterSafeProvider'; import { RewardProvider } from '@/context/RewardProvider'; import { SettingsProvider } from '@/context/SettingsProvider'; +import { WalletProvider } from '@/context/WalletProvider'; import { mainTheme } from '@/theme'; export default function App({ Component, pageProps }: AppProps) { @@ -29,23 +31,27 @@ export default function App({ Component, pageProps }: AppProps) { return ( - - - - - - {isMounted ? ( - - - - - - ) : null} - - - - - + + + + + + + + {isMounted ? ( + + + + + + ) : null} + + + + + + + ); diff --git a/operate/cli.py b/operate/cli.py index b7dbb8487..6f90cad1b 100644 --- a/operate/cli.py +++ b/operate/cli.py @@ -90,6 +90,14 @@ def __init__( ) self.password: t.Optional[str] = os.environ.get("OPERATE_USER_PASSWORD") + def create_user_account(self, password: str) -> UserAccount: + """Create a user account.""" + self.password = password + return UserAccount.new( + password=password, + path=self._path / "user.json", + ) + def service_manager(self) -> services.manage.ServiceManager: """Load service manager.""" return services.manage.ServiceManager( @@ -248,9 +256,8 @@ async def _setup_account(request: Request) -> t.Dict: ) data = await request.json() - UserAccount.new( + operate.create_user_account( password=data["password"], - path=operate._path / "user.json", # pylint: disable=protected-access ) return JSONResponse(content={"error": None}) @@ -417,6 +424,11 @@ async def _create_safe(request: Request) -> t.List[t.Dict]: chain_type=chain_type, owner=data.get("owner"), ) + wallet.transfer( + to=t.cast(str, wallet.safe), + amount=int(1e18), + chain_type=chain_type, + ) return JSONResponse(content={"safe": wallet.safe, "message": "Safe created!"}) @app.put("/api/wallet/safe") @@ -485,6 +497,7 @@ async def _create_services(request: Request) -> JSONResponse: on_chain_user_params=services.manage.OnChainUserParams.from_json( template["configuration"] ), + from_safe=True, ) update = True else: @@ -498,11 +511,9 @@ async def _create_services(request: Request) -> JSONResponse: ) if template.get("deploy", False): - manager.deploy_service_onchain(hash=service.hash, update=update) - manager.stake_service_on_chain(hash=service.hash) - manager.fund_service(hash=service.hash) + manager.deploy_service_onchain_from_safe(hash=service.hash, update=update) + manager.stake_service_on_chain_from_safe(hash=service.hash) manager.deploy_service_locally(hash=service.hash) - schedule_funding_job(service=service.hash) return JSONResponse( content=operate.service_manager().create_or_load(hash=service.hash).json @@ -521,11 +532,9 @@ async def _update_services(request: Request) -> JSONResponse: ) if template.get("deploy", False): manager = operate.service_manager() - manager.deploy_service_onchain(hash=service.hash, update=True) - manager.stake_service_on_chain(hash=service.hash) - manager.fund_service(hash=service.hash) + manager.deploy_service_onchain_from_safe(hash=service.hash, update=True) + manager.stake_service_on_chain_from_safe(hash=service.hash) manager.deploy_service_locally(hash=service.hash) - schedule_funding_job(service=service.hash) return JSONResponse(content=service.json) @app.get("/api/services/{service}") diff --git a/operate/ledger/__init__.py b/operate/ledger/__init__.py index b0352423d..078a93d1e 100644 --- a/operate/ledger/__init__.py +++ b/operate/ledger/__init__.py @@ -28,15 +28,13 @@ from operate.types import ChainType, LedgerType -ETHEREUM_PUBLIC_RPC = "https://ethereum.publicnode.com" -GNOSIS_PUBLIC_RPC = "https://gnosis-rpc.publicnode.com" -GOERLI_PUBLIC_RPC = "https://ethereum-goerli.publicnode.com" -SOLANA_PUBLIC_RPC = "https://api.mainnet-beta.solana.com" +ETHEREUM_PUBLIC_RPC = os.environ.get("DEV_RPC", "https://ethereum.publicnode.com") +GNOSIS_PUBLIC_RPC = os.environ.get("DEV_RPC", "https://gnosis-rpc.publicnode.com") +GOERLI_PUBLIC_RPC = os.environ.get("DEV_RPC", "https://ethereum-goerli.publicnode.com") +SOLANA_PUBLIC_RPC = os.environ.get("DEV_RPC", "https://api.mainnet-beta.solana.com") ETHEREUM_RPC = os.environ.get("DEV_RPC", "https://ethereum.publicnode.com") -GNOSIS_RPC = os.environ.get( - "DEV_RPC", "https://go.getblock.io/2a1fa1ade5d547ca86eab099c35ce2a7" -) +GNOSIS_RPC = os.environ.get("DEV_RPC", "https://rpc-gate.autonolas.tech/gnosis-rpc/") GOERLI_RPC = os.environ.get("DEV_RPC", "https://ethereum-goerli.publicnode.com") SOLANA_RPC = os.environ.get("DEV_RPC", "https://api.mainnet-beta.solana.com") diff --git a/operate/services/manage.py b/operate/services/manage.py index b6bc540ba..182d9300c 100644 --- a/operate/services/manage.py +++ b/operate/services/manage.py @@ -33,7 +33,7 @@ from operate.keys import Key, KeysManager from operate.ledger import PUBLIC_RPCS from operate.ledger.profiles import CONTRACTS, OLAS, STAKING -from operate.services.protocol import OnChainManager, StakingState +from operate.services.protocol import EthSafeTxBuilder, OnChainManager, StakingState from operate.services.service import ( Deployment, OnChainData, @@ -108,6 +108,14 @@ def get_on_chain_manager(self, service: Service) -> OnChainManager: contracts=CONTRACTS[service.ledger_config.chain], ) + def get_eth_safe_tx_builder(self, service: Service) -> EthSafeTxBuilder: + """Get EthSafeTxBuilder instance.""" + return EthSafeTxBuilder( + rpc=service.ledger_config.rpc, + wallet=self.wallet_manager.load(service.ledger_config.type), + contracts=CONTRACTS[service.ledger_config.chain], + ) + def create_or_load( self, hash: str, @@ -289,6 +297,233 @@ def deploy_service_onchain( # pylint: disable=too-many-statements ) service.store() + def deploy_service_onchain_from_safe( # pylint: disable=too-many-statements,too-many-locals + self, + hash: str, + update: bool = False, + ) -> None: + """ + Deploy as service on-chain + + :param hash: Service hash + :param update: Update the existing deployment + """ + self.logger.info("Loading service") + service = self.create_or_load(hash=hash) + user_params = service.chain_data.user_params + keys = service.keys or [ + self.keys_manager.get(self.keys_manager.create()) + for _ in range(service.helper.config.number_of_agents) + ] + instances = [key.address for key in keys] + wallet = self.wallet_manager.load(service.ledger_config.type) + sftxb = self.get_eth_safe_tx_builder(service=service) + if user_params.use_staking and not sftxb.staking_slots_available( + staking_contract=STAKING[service.ledger_config.chain] + ): + raise ValueError("No staking slots available") + + if service.chain_data.token > -1: + self.logger.info("Syncing service state") + info = sftxb.info(token_id=service.chain_data.token) + service.chain_data.on_chain_state = OnChainState(info["service_state"]) + service.chain_data.instances = info["instances"] + service.chain_data.multisig = info["multisig"] + service.store() + self.logger.info(f"Service state: {service.chain_data.on_chain_state.name}") + + if user_params.use_staking: + self.logger.info("Checking staking compatibility") + if service.chain_data.on_chain_state in ( + OnChainState.NOTMINTED, + OnChainState.MINTED, + ): + required_olas = ( + user_params.olas_cost_of_bond + user_params.olas_required_to_stake + ) + elif service.chain_data.on_chain_state == OnChainState.ACTIVATED: + required_olas = user_params.olas_required_to_stake + else: + required_olas = 0 + + balance = ( + registry_contracts.erc20.get_instance( + ledger_api=sftxb.ledger_api, + contract_address=OLAS[service.ledger_config.chain], + ) + .functions.balanceOf(wallet.safe) + .call() + ) + if balance < required_olas: + raise ValueError( + "You don't have enough olas to stake, " + f"address: {wallet.safe}; required olas: {required_olas}; your balance {balance}" + ) + + if service.chain_data.on_chain_state == OnChainState.NOTMINTED: + self.logger.info("Minting service") + receipt = ( + sftxb.new_tx() + .add( + sftxb.get_mint_tx_data( + package_path=service.service_path, + agent_id=user_params.agent_id, + number_of_slots=service.helper.config.number_of_agents, + cost_of_bond=( + user_params.olas_cost_of_bond + if user_params.use_staking + else user_params.cost_of_bond + ), + threshold=user_params.threshold, + nft=IPFSHash(user_params.nft), + update_token=service.chain_data.token if update else None, + token=( + OLAS[service.ledger_config.chain] + if user_params.use_staking + else None + ), + ) + ) + .settle() + ) + event_data, *_ = t.cast( + t.Tuple, + registry_contracts.service_registry.process_receipt( + ledger_api=sftxb.ledger_api, + contract_address="0x9338b5153AE39BB89f50468E608eD9d764B755fD", + event="CreateService", + receipt=receipt, + ).get("events"), + ) + service.chain_data.token = event_data["args"]["serviceId"] + service.chain_data.on_chain_state = OnChainState.MINTED + service.store() + + if service.chain_data.on_chain_state == OnChainState.MINTED: + cost_of_bond = user_params.cost_of_bond + if user_params.use_staking: + token_utility = "0xa45E64d13A30a51b91ae0eb182e88a40e9b18eD8" + olas_token = "0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f" + self.logger.info( + f"Approving OLAS as bonding token from {wallet.safe} to {token_utility}" + ) + cost_of_bond = ( + registry_contracts.service_registry_token_utility.get_agent_bond( + ledger_api=sftxb.ledger_api, + contract_address=token_utility, + service_id=service.chain_data.token, + agent_id=user_params.agent_id, + ).get("bond") + ) + sftxb.new_tx().add( + sftxb.get_olas_approval_data( + spender=token_utility, + amount=cost_of_bond, + olas_contract=olas_token, + ) + ).settle() + token_utility_allowance = ( + registry_contracts.erc20.get_instance( + ledger_api=sftxb.ledger_api, + contract_address=olas_token, + ) + .functions.allowance( + wallet.safe, + token_utility, + ) + .call() + ) + self.logger.info( + f"Approved {token_utility_allowance} OLAS from {wallet.safe} to {token_utility}" + ) + + self.logger.info("Activating service") + sftxb.new_tx().add( + sftxb.get_activate_data( + service_id=service.chain_data.token, + cost_of_bond=cost_of_bond, + ) + ).settle() + service.chain_data.on_chain_state = OnChainState.ACTIVATED + service.store() + + if service.chain_data.on_chain_state == OnChainState.ACTIVATED: + cost_of_bond = user_params.cost_of_bond + if user_params.use_staking: + token_utility = "0xa45E64d13A30a51b91ae0eb182e88a40e9b18eD8" + olas_token = "0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f" + self.logger.info( + f"Approving OLAS as bonding token from {wallet.safe} to {token_utility}" + ) + cost_of_bond = ( + registry_contracts.service_registry_token_utility.get_agent_bond( + ledger_api=sftxb.ledger_api, + contract_address=token_utility, + service_id=service.chain_data.token, + agent_id=user_params.agent_id, + ).get("bond") + ) + sftxb.new_tx().add( + sftxb.get_olas_approval_data( + spender=token_utility, + amount=cost_of_bond, + olas_contract=olas_token, + ) + ).settle() + token_utility_allowance = ( + registry_contracts.erc20.get_instance( + ledger_api=sftxb.ledger_api, + contract_address=olas_token, + ) + .functions.allowance( + wallet.safe, + token_utility, + ) + .call() + ) + self.logger.info( + f"Approved {token_utility_allowance} OLAS from {wallet.safe} to {token_utility}" + ) + cost_of_bond = 0 + + self.logger.info( + f"Registering service: {service.chain_data.token} -> {instances}" + ) + sftxb.new_tx().add( + sftxb.get_register_instances_data( + service_id=service.chain_data.token, + instances=instances, + agents=[user_params.agent_id for _ in instances], + cost_of_bond=cost_of_bond, + ) + ).settle() + service.chain_data.on_chain_state = OnChainState.REGISTERED + service.keys = keys + service.store() + + if service.chain_data.on_chain_state == OnChainState.REGISTERED: + self.logger.info("Deploying service") + sftxb.new_tx().add( + sftxb.get_deploy_data( + service_id=service.chain_data.token, + reuse_multisig=update, + ) + ).settle() + service.chain_data.on_chain_state = OnChainState.DEPLOYED + service.store() + + info = sftxb.info(token_id=service.chain_data.token) + service.keys = keys + service.chain_data = OnChainData( + token=service.chain_data.token, + instances=info["instances"], + multisig=info["multisig"], + staked=False, + on_chain_state=service.chain_data.on_chain_state, + user_params=service.chain_data.user_params, + ) + service.store() + def terminate_service_on_chain(self, hash: str) -> None: """ Terminate service on-chain @@ -313,6 +548,27 @@ def terminate_service_on_chain(self, hash: str) -> None: service.chain_data.on_chain_state = OnChainState.TERMINATED service.store() + def terminate_service_on_chain_from_safe(self, hash: str) -> None: + """ + Terminate service on-chain + + :param hash: Service hash + """ + service = self.create_or_load(hash=hash) + if service.chain_data.on_chain_state != OnChainState.DEPLOYED: + self.logger.info("Cannot terminate service") + return + + self.logger.info("Terminating service") + sftxb = self.get_eth_safe_tx_builder(service=service) + sftxb.new_tx().add( + sftxb.get_terminate_data( + service_id=service.chain_data.token, + ) + ).settle() + service.chain_data.on_chain_state = OnChainState.TERMINATED + service.store() + def unbond_service_on_chain(self, hash: str) -> None: """ Unbond service on-chain @@ -337,6 +593,27 @@ def unbond_service_on_chain(self, hash: str) -> None: service.chain_data.on_chain_state = OnChainState.UNBONDED service.store() + def unbond_service_on_chain_from_safe(self, hash: str) -> None: + """ + Terminate service on-chain + + :param hash: Service hash + """ + service = self.create_or_load(hash=hash) + if service.chain_data.on_chain_state != OnChainState.TERMINATED: + self.logger.info("Cannot unbond service") + return + + self.logger.info("Unbonding service") + sftxb = self.get_eth_safe_tx_builder(service=service) + sftxb.new_tx().add( + sftxb.get_unbond_data( + service_id=service.chain_data.token, + ) + ).settle() + service.chain_data.on_chain_state = OnChainState.TERMINATED + service.store() + def stake_service_on_chain(self, hash: str) -> None: """ Stake service on-chain @@ -373,6 +650,54 @@ def stake_service_on_chain(self, hash: str) -> None: service.chain_data.staked = True service.store() + def stake_service_on_chain_from_safe(self, hash: str) -> None: + """ + Stake service on-chain + + :param hash: Service hash + """ + service = self.create_or_load(hash=hash) + if not service.chain_data.user_params.use_staking: + self.logger.info("Cannot stake service, `use_staking` is set to false") + return + + if service.chain_data.on_chain_state != OnChainState.DEPLOYED: + self.logger.info("Cannot stake service, it's not in deployed state") + return + + sftxb = self.get_eth_safe_tx_builder(service=service) + state = sftxb.staking_status( + service_id=service.chain_data.token, + staking_contract=STAKING[service.ledger_config.chain], + ) + self.logger.info(f"Checking staking status for: {service.chain_data.token}") + if state == StakingState.STAKED: + self.logger.info(f"{service.chain_data.token} is already staked") + service.chain_data.staked = True + service.store() + return + + self.logger.info(f"Approving staking: {service.chain_data.token}") + sftxb.new_tx().add( + sftxb.get_staking_approval_data( + service_id=service.chain_data.token, + service_registry=CONTRACTS[service.ledger_config.chain][ + "service_registry" + ], + staking_contract=STAKING[service.ledger_config.chain], + ) + ).settle() + + self.logger.info(f"Staking service: {service.chain_data.token}") + sftxb.new_tx().add( + sftxb.get_staking_data( + service_id=service.chain_data.token, + staking_contract=STAKING[service.ledger_config.chain], + ) + ).settle() + service.chain_data.staked = True + service.store() + def unstake_service_on_chain(self, hash: str) -> None: """ Unbond service on-chain @@ -395,6 +720,7 @@ def unstake_service_on_chain(self, hash: str) -> None: service.store() return + self.logger.info(f"Unstaking service: {service.chain_data.token}") ocm.unstake( service_id=service.chain_data.token, staking_contract=STAKING[service.ledger_config.chain], @@ -402,6 +728,44 @@ def unstake_service_on_chain(self, hash: str) -> None: service.chain_data.staked = False service.store() + def unstake_service_on_chain_from_safe(self, hash: str) -> None: + """ + Unbond service on-chain + + :param hash: Service hash + """ + service = self.create_or_load(hash=hash) + if not service.chain_data.user_params.use_staking: + self.logger.info("Cannot unstake service, `use_staking` is set to false") + return + + sftxb = self.get_eth_safe_tx_builder(service=service) + state = sftxb.staking_status( + service_id=service.chain_data.token, + staking_contract=STAKING[service.ledger_config.chain], + ) + self.logger.info(f"Checking staking status for: {service.chain_data.token}") + if state != StakingState.STAKED: + self.logger.info("Cannot unstake service, it's not staked") + service.chain_data.staked = False + service.store() + return + + self.logger.info(f"Unstaking service: {service.chain_data.token}") + sftxb.new_tx().add( + sftxb.get_unstaking_data( + service_id=service.chain_data.token, + staking_contract=STAKING[service.ledger_config.chain], + ) + ).add( + sftxb.get_staking_data( + service_id=service.chain_data.token, + staking_contract=STAKING[service.ledger_config.chain], + ) + ).settle() + service.chain_data.staked = True + service.store() + def fund_service( self, hash: str, @@ -507,27 +871,51 @@ def update_service( new_hash: str, rpc: t.Optional[str] = None, on_chain_user_params: t.Optional[OnChainUserParams] = None, + from_safe: bool = True, ) -> Service: """Update a service.""" old_service = self.create_or_load( hash=old_hash, ) - self.unstake_service_on_chain( + ( + self.unstake_service_on_chain_from_safe + if from_safe + else self.unstake_service_on_chain + )( hash=old_hash, ) - self.terminate_service_on_chain( + ( + self.terminate_service_on_chain_from_safe + if from_safe + else self.terminate_service_on_chain + )( hash=old_hash, ) - self.unbond_service_on_chain( + ( + self.unbond_service_on_chain_from_safe + if from_safe + else self.unbond_service_on_chain + )( hash=old_hash, ) - ocm = self.get_on_chain_manager(service=old_service) + owner, *_ = old_service.chain_data.instances - ocm.swap( - service_id=old_service.chain_data.token, - multisig=old_service.chain_data.multisig, - owner_key=str(self.keys_manager.get(key=owner).private_key), - ) + if from_safe: + sftx = self.get_eth_safe_tx_builder(service=old_service) + sftx.new_tx().add( + sftx.get_swap_data( + service_id=old_service.chain_data.token, + multisig=old_service.chain_data.multisig, + owner_key=str(self.keys_manager.get(key=owner).private_key), + ) + ).settle() + else: + ocm = self.get_on_chain_manager(service=old_service) + ocm.swap( + service_id=old_service.chain_data.token, + multisig=old_service.chain_data.multisig, + owner_key=str(self.keys_manager.get(key=owner).private_key), + ) new_service = self.create_or_load( hash=new_hash, diff --git a/operate/services/protocol.py b/operate/services/protocol.py index 1c665fbf2..07ccdf4e5 100644 --- a/operate/services/protocol.py +++ b/operate/services/protocol.py @@ -27,6 +27,7 @@ import tempfile import time import typing as t +from datetime import datetime from enum import Enum from pathlib import Path from typing import Optional, Union @@ -37,7 +38,16 @@ from aea_ledger_ethereum.ethereum import EthereumCrypto from autonomy.chain.base import registry_contracts from autonomy.chain.config import ChainConfigs, ChainType, ContractConfigs -from autonomy.chain.service import get_agent_instances, get_service_info +from autonomy.chain.constants import ( + GNOSIS_SAFE_PROXY_FACTORY_CONTRACT, + GNOSIS_SAFE_SAME_ADDRESS_MULTISIG_CONTRACT, +) +from autonomy.chain.service import ( + get_agent_instances, + get_delployment_payload, + get_reuse_multisig_payload, + get_service_info, +) from autonomy.chain.tx import TxSettler from autonomy.cli.helpers.chain import MintHelper as MintManager from autonomy.cli.helpers.chain import OnChainHelper @@ -63,6 +73,9 @@ from operate.wallet.master import MasterWallet +ETHEREUM_ERC20 = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" + + class StakingState(Enum): """Staking state enumeration for the staking.""" @@ -71,6 +84,105 @@ class StakingState(Enum): EVICTED = 2 +class GnosisSafeTransaction: + """Safe transaction""" + + def __init__( + self, + ledger_api: LedgerApi, + crypto: Crypto, + chain_type: ChainType, + safe: str, + ) -> None: + """Initiliaze a Gnosis safe tx""" + self.ledger_api = ledger_api + self.crypto = crypto + self.chain_type = chain_type + self.safe = safe + self._txs: t.List[t.Dict] = [] + self.tx: t.Optional[t.Dict] = None + + def add(self, tx: t.Dict) -> "GnosisSafeTransaction": + """Add a transaction""" + self._txs.append(tx) + return self + + def build(self) -> t.Dict: + """Build the transaction.""" + multisend_data = bytes.fromhex( + registry_contracts.multisend.get_tx_data( + ledger_api=self.ledger_api, + contract_address=ContractConfigs.multisend.contracts[self.chain_type], + multi_send_txs=self._txs, + ).get("data")[2:] + ) + safe_tx_hash = registry_contracts.gnosis_safe.get_raw_safe_transaction_hash( + ledger_api=self.ledger_api, + contract_address=self.safe, + value=0, + safe_tx_gas=0, + to_address=ContractConfigs.multisend.contracts[self.chain_type], + data=multisend_data, + operation=SafeOperation.DELEGATE_CALL.value, + ).get("tx_hash")[2:] + payload_data = hash_payload_to_hex( + safe_tx_hash=safe_tx_hash, + ether_value=0, + safe_tx_gas=0, + to_address=ContractConfigs.multisend.contracts[self.chain_type], + operation=SafeOperation.DELEGATE_CALL.value, + data=multisend_data, + ) + owner = self.ledger_api.api.to_checksum_address(self.crypto.address) + tx_params = skill_input_hex_to_payload(payload=payload_data) + safe_tx_bytes = binascii.unhexlify(tx_params["safe_tx_hash"]) + signatures = { + owner: self.crypto.sign_message( + message=safe_tx_bytes, + is_deprecated_mode=True, + )[2:] + } + tx = registry_contracts.gnosis_safe.get_raw_safe_transaction( + ledger_api=self.ledger_api, + contract_address=self.safe, + sender_address=owner, + owners=(owner,), # type: ignore + to_address=tx_params["to_address"], + value=tx_params["ether_value"], + data=tx_params["data"], + safe_tx_gas=tx_params["safe_tx_gas"], + signatures_by_owner=signatures, + operation=SafeOperation.DELEGATE_CALL.value, + nonce=self.ledger_api.api.eth.get_transaction_count(owner), + ) + self.tx = self.crypto.sign_transaction(tx) + return t.cast(t.Dict, self.tx) + + def settle(self) -> t.Dict: + """Settle the transaction.""" + retries = 0 + deadline = datetime.now().timestamp() + ON_CHAIN_INTERACT_TIMEOUT + while ( + retries < ON_CHAIN_INTERACT_RETRIES + and datetime.now().timestamp() < deadline + ): + try: + self.build() + tx_digest = self.ledger_api.send_signed_transaction(self.tx) + except Exception as e: # pylint: disable=broad-except + print(f"Error sending the safe tx: {e}") + tx_digest = None + + if tx_digest is not None: + receipt = self.ledger_api.api.eth.wait_for_transaction_receipt( + tx_digest + ) + if receipt["status"] != 0: + return receipt + time.sleep(ON_CHAIN_INTERACT_SLEEP) + raise RuntimeError("Timeout while waiting for safe transaction to go through") + + class StakingManager(OnChainHelper): """Helper class for staking a service.""" @@ -128,13 +240,12 @@ def service_info(self, staking_contract: str, service_id: int) -> dict: service_id, ).get("data") - def stake( + def check_staking_compatibility( self, service_id: int, - service_registry: str, staking_contract: str, ) -> None: - """Stake the service""" + """Check if service can be staked.""" status = self.status(service_id, staking_contract) if status == StakingState.STAKED: raise ValueError("Service already stacked") @@ -145,6 +256,17 @@ def stake( if not self.slots_available(staking_contract): raise ValueError("No sataking slots available.") + def stake( + self, + service_id: int, + service_registry: str, + staking_contract: str, + ) -> None: + """Stake the service""" + self.check_staking_compatibility( + service_id=service_id, staking_contract=staking_contract + ) + tx_settler = TxSettler( ledger_api=self.ledger_api, crypto=self.crypto, @@ -154,34 +276,28 @@ def stake( sleep=ON_CHAIN_INTERACT_SLEEP, ) - # Check if token usage is already approved - if service_id not in self.staking_ctr.get_service_ids( - ledger_api=self.ledger_api, - contract_address=staking_contract, - ): - # we make use of the ERC20 contract to build the approval transaction - # since it has the same interface as ERC721 we might want to create - # a ERC721 contract package - - def _build_approval_tx( # pylint: disable=unused-argument - *args: t.Any, **kargs: t.Any - ) -> t.Dict: - return registry_contracts.erc20.get_approve_tx( - ledger_api=self.ledger_api, - contract_address=service_registry, - spender=staking_contract, - sender=self.crypto.address, - amount=service_id, - ) - - setattr(tx_settler, "build", _build_approval_tx) # noqa: B010 - tx_settler.transact( - method=lambda: {}, - contract="", - kwargs={}, - dry_run=False, + # we make use of the ERC20 contract to build the approval transaction + # since it has the same interface as ERC721 we might want to create + # a ERC721 contract package + def _build_approval_tx( # pylint: disable=unused-argument + *args: t.Any, **kargs: t.Any + ) -> t.Dict: + return registry_contracts.erc20.get_approve_tx( + ledger_api=self.ledger_api, + contract_address=service_registry, + spender=staking_contract, + sender=self.crypto.address, + amount=service_id, ) + setattr(tx_settler, "build", _build_approval_tx) # noqa: B010 + tx_settler.transact( + method=lambda: {}, + contract="", + kwargs={}, + dry_run=False, + ) + def _build_staking_tx( # pylint: disable=unused-argument *args: t.Any, **kargs: t.Any ) -> t.Dict: @@ -206,12 +322,18 @@ def _build_staking_tx( # pylint: disable=unused-argument dry_run=False, ) - def _can_unstake_service( + def check_if_unstaking_possible( self, service_id: int, staking_contract: str, - ) -> bool: + ) -> None: """Check unstaking availability""" + if ( + self.status(service_id=service_id, staking_contract=staking_contract) + != StakingState.STAKED + ): + raise ValueError("Service not staked.") + ts_start = t.cast(int, self.service_info(staking_contract, service_id)[3]) available_rewards = t.cast( int, @@ -227,19 +349,10 @@ def _can_unstake_service( ) staked_duration = time.time() - ts_start if staked_duration < minimum_staking_duration and available_rewards > 0: - return False - return True + raise ValueError("Service cannot be unstaked yet.") def unstake(self, service_id: int, staking_contract: str) -> None: """Unstake the service""" - if ( - self.status(service_id=service_id, staking_contract=staking_contract) - != StakingState.STAKED - ): - raise ValueError("Service not staked.") - - if not self._can_unstake_service(service_id, staking_contract): - raise ValueError("Service cannot be unstaked yet.") tx_settler = TxSettler( ledger_api=self.ledger_api, @@ -274,8 +387,58 @@ def _build_unstaking_tx( # pylint: disable=unused-argument dry_run=False, ) + def get_stake_approval_tx_data( + self, + service_id: int, + service_registry: str, + staking_contract: str, + ) -> bytes: + """Get stake approval tx data.""" + self.check_staking_compatibility( + service_id=service_id, + staking_contract=staking_contract, + ) + return registry_contracts.erc20.get_instance( + ledger_api=self.ledger_api, + contract_address=service_registry, + ).encodeABI( + fn_name="approve", + args=[ + staking_contract, + service_id, + ], + ) + + def get_stake_tx_data(self, service_id: int, staking_contract: str) -> bytes: + """Get stake approval tx data.""" + self.check_staking_compatibility( + service_id=service_id, + staking_contract=staking_contract, + ) + return self.staking_ctr.get_instance( + ledger_api=self.ledger_api, + contract_address=staking_contract, + ).encodeABI( + fn_name="stake", + args=[service_id], + ) + + def get_unstake_tx_data(self, service_id: int, staking_contract: str) -> bytes: + """Unstake the service""" + self.check_if_unstaking_possible( + service_id=service_id, + staking_contract=staking_contract, + ) + return self.staking_ctr.get_instance( + ledger_api=self.ledger_api, + contract_address=staking_contract, + ).encodeABI( + fn_name="unstake", + args=[service_id], + ) -class OnChainManager: + +class _ChainUtil: """On chain service management.""" def __init__( @@ -359,6 +522,10 @@ def info(self, token_id: int) -> t.Dict: instances=instances, ) + +class OnChainManager(_ChainUtil): + """On chain service management.""" + def mint( # pylint: disable=too-many-arguments,too-many-locals self, package_path: Path, @@ -685,3 +852,316 @@ def staking_status(self, service_id: int, staking_contract: str) -> StakingState service_id=service_id, staking_contract=staking_contract, ) + + +class EthSafeTxBuilder(_ChainUtil): + """Safe Transaction builder.""" + + def new_tx(self) -> GnosisSafeTransaction: + """Create a new GnosisSafeTransaction instance.""" + return GnosisSafeTransaction( + ledger_api=self.ledger_api, + crypto=self.crypto, + chain_type=self.chain_type, + safe=t.cast(str, self.wallet.safe), + ) + + def get_mint_tx_data( # pylint: disable=too-many-arguments + self, + package_path: Path, + agent_id: int, + number_of_slots: int, + cost_of_bond: int, + threshold: int, + nft: Optional[Union[Path, IPFSHash]], + update_token: t.Optional[int] = None, + token: t.Optional[str] = None, + ) -> t.Dict: + """Build mint transaction.""" + # TODO: Support for update + self._patch() + manager = MintManager( + chain_type=self.chain_type, + key=self.wallet.key_path, + password=self.wallet.password, + update_token=update_token, + timeout=ON_CHAIN_INTERACT_TIMEOUT, + retries=ON_CHAIN_INTERACT_RETRIES, + sleep=ON_CHAIN_INTERACT_SLEEP, + ) + # Prepare for minting + ( + manager.load_package_configuration( + package_path=package_path, package_type=PackageType.SERVICE + ) + .load_metadata() + .verify_nft(nft=nft) + .verify_service_dependencies(agent_id=agent_id) + .publish_metadata() + ) + instance = registry_contracts.service_manager.get_instance( + ledger_api=self.ledger_api, + contract_address=self.contracts["service_manager"], + ) + + txd = instance.encodeABI( + fn_name="create" if update_token is None else "update", + args=[ + self.wallet.safe, + token or ETHEREUM_ERC20, + manager.metadata_hash, + [agent_id], + [[number_of_slots, cost_of_bond]], + threshold, + ], + ) + + return { + "to": self.contracts["service_manager"], + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def get_olas_approval_data( + self, + spender: str, + amount: int, + olas_contract: str, + ) -> t.Dict: + """Get activate tx data.""" + instance = registry_contracts.erc20.get_instance( + ledger_api=self.ledger_api, + contract_address=olas_contract, + ) + txd = instance.encodeABI( + fn_name="approve", + args=[spender, amount], + ) + return { + "to": olas_contract, + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def get_activate_data(self, service_id: int, cost_of_bond: int) -> t.Dict: + """Get activate tx data.""" + instance = registry_contracts.service_manager.get_instance( + ledger_api=self.ledger_api, + contract_address=self.contracts["service_manager"], + ) + txd = instance.encodeABI( + fn_name="activateRegistration", + args=[service_id], + ) + return { + "from": self.wallet.safe, + "to": self.contracts["service_manager"], + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": cost_of_bond, + } + + def get_register_instances_data( + self, + service_id: int, + instances: t.List[str], + agents: t.List[int], + cost_of_bond: int, + ) -> t.Dict: + """Get register instances tx data.""" + instance = registry_contracts.service_manager.get_instance( + ledger_api=self.ledger_api, + contract_address=self.contracts["service_manager"], + ) + txd = instance.encodeABI( + fn_name="registerAgents", + args=[ + service_id, + instances, + agents, + ], + ) + return { + "from": self.wallet.safe, + "to": self.contracts["service_manager"], + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": cost_of_bond, + } + + def get_deploy_data( + self, + service_id: int, + reuse_multisig: bool = False, + ) -> t.Dict: + """Get deploy tx data.""" + instance = registry_contracts.service_manager.get_instance( + ledger_api=self.ledger_api, + contract_address=self.contracts["service_manager"], + ) + if reuse_multisig: + _deployment_payload, error = get_reuse_multisig_payload( + ledger_api=self.ledger_api, + crypto=self.crypto, + chain_type=self.chain_type, + service_id=service_id, + ) + if _deployment_payload is None: + raise ValueError(error) + deployment_payload = _deployment_payload + gnosis_safe_multisig = ContractConfigs.get( + GNOSIS_SAFE_SAME_ADDRESS_MULTISIG_CONTRACT.name + ).contracts[self.chain_type] + else: + deployment_payload = get_delployment_payload() + gnosis_safe_multisig = ContractConfigs.get( + GNOSIS_SAFE_PROXY_FACTORY_CONTRACT.name + ).contracts[self.chain_type] + + txd = instance.encodeABI( + fn_name="deploy", + args=[ + service_id, + gnosis_safe_multisig, + deployment_payload, + ], + ) + return { + "to": self.contracts["service_manager"], + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def get_terminate_data(self, service_id: int) -> t.Dict: + """Get terminate tx data.""" + instance = registry_contracts.service_manager.get_instance( + ledger_api=self.ledger_api, + contract_address=self.contracts["service_manager"], + ) + txd = instance.encodeABI( + fn_name="terminate", + args=[service_id], + ) + return { + "to": self.contracts["service_manager"], + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def get_unbond_data(self, service_id: int) -> t.Dict: + """Get unbond tx data.""" + instance = registry_contracts.service_manager.get_instance( + ledger_api=self.ledger_api, + contract_address=self.contracts["service_manager"], + ) + txd = instance.encodeABI( + fn_name="unbond", + args=[service_id], + ) + return { + "to": self.contracts["service_manager"], + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def get_staking_approval_data( + self, + service_id: int, + service_registry: str, + staking_contract: str, + ) -> t.Dict: + """Get staking approval data""" + self._patch() + txd = StakingManager( + key=self.wallet.key_path, + password=self.wallet.password, + chain_type=self.chain_type, + ).get_stake_approval_tx_data( + service_id=service_id, + service_registry=service_registry, + staking_contract=staking_contract, + ) + return { + "from": self.wallet.safe, + "to": self.contracts["service_registry"], + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def get_staking_data( + self, + service_id: int, + staking_contract: str, + ) -> t.Dict: + """Get staking tx data""" + self._patch() + txd = StakingManager( + key=self.wallet.key_path, + password=self.wallet.password, + chain_type=self.chain_type, + ).get_stake_tx_data( + service_id=service_id, + staking_contract=staking_contract, + ) + return { + "to": staking_contract, + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def get_unstaking_data( + self, + service_id: int, + staking_contract: str, + ) -> t.Dict: + """Get unstaking tx data""" + self._patch() + txd = StakingManager( + key=self.wallet.key_path, + password=self.wallet.password, + chain_type=self.chain_type, + ).get_unstake_tx_data( + service_id=service_id, + staking_contract=staking_contract, + ) + return { + "to": staking_contract, + "data": txd[2:], + "operation": MultiSendOperation.CALL, + "value": 0, + } + + def staking_slots_available(self, staking_contract: str) -> bool: + """Stake service.""" + self._patch() + return StakingManager( + key=self.wallet.key_path, + password=self.wallet.password, + chain_type=self.chain_type, + ).slots_available( + staking_contract=staking_contract, + ) + + def staking_status(self, service_id: int, staking_contract: str) -> StakingState: + """Stake the service""" + self._patch() + return StakingManager( + key=self.wallet.key_path, + password=self.wallet.password, + chain_type=self.chain_type, + ).status( + service_id=service_id, + staking_contract=staking_contract, + ) + + def get_swap_data(self, service_id: int, multisig: str, owner_key: str) -> t.Dict: + """Swap safe owner.""" + # TODO: Discuss implementation + raise NotImplementedError() diff --git a/package.json b/package.json index 9be8970b7..0c1072de4 100644 --- a/package.json +++ b/package.json @@ -52,5 +52,5 @@ "start": "electron .", "build": "rm -rf dist/ && electron-builder build" }, - "version": "0.1.0-rc15.9.7.9.2" + "version": "0.1.0-rc15.9.7.9.3" } diff --git a/scripts/fund.py b/scripts/fund.py index a4724b508..324649833 100644 --- a/scripts/fund.py +++ b/scripts/fund.py @@ -11,13 +11,15 @@ load_dotenv() +RPC = os.environ.get("DEV_RPC", "http://localhost:8545") + OLAS_CONTRACT_ADDRESS_GNOSIS = "0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f" def fund(address: str, amount: float = 10.0) -> None: """Fund an address.""" staking_keys_path = os.environ.get("STAKING_TEST_KEYS_PATH", None) - ledger_api = EthereumApi(address="http://localhost:8545") + ledger_api = EthereumApi(address=RPC) crypto = EthereumCrypto("scripts/keys/gnosis.txt") tx = ledger_api.get_transfer_transaction( sender_address=crypto.address, diff --git a/scripts/setup_wallet.py b/scripts/setup_wallet.py index 85728496b..f17dbdcb6 100644 --- a/scripts/setup_wallet.py +++ b/scripts/setup_wallet.py @@ -55,7 +55,7 @@ print(wallet) print("Funding wallet: ", end="") -fund(wallet["wallet"]["address"]) +fund(wallet["wallet"]["address"], amount=20) print( requests.post( diff --git a/scripts/transfer_olas.py b/scripts/transfer_olas.py new file mode 100644 index 000000000..a9768a783 --- /dev/null +++ b/scripts/transfer_olas.py @@ -0,0 +1,84 @@ +""" +Script for transferring OLAS + +Usage: + python transfer_olas.py PATH_TO_KEY_CONTAINING_OLAS ADDRESS_TO_TRANSFER AMOUNT + +Example: + python transfer_olas.py keys/gnosis.txt 0xce11e14225575945b8e6dc0d4f2dd4c570f79d9f 2 +""" + +import json +import os +import sys +from pathlib import Path + +from aea_ledger_ethereum.ethereum import EthereumApi, EthereumCrypto + + +RPC = os.environ.get("DEV_RPC", "http://localhost:8545") +OLAS_CONTRACT_ADDRESS_GNOSIS = "0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f" +WEI_MULTIPLIER = 1e18 + + +def fund(wallet: str, address: str, amount: float = 20.0) -> None: + """Fund wallet with OLAS token""" + staking_wallet = EthereumCrypto(wallet) + ledger_api = EthereumApi(address=RPC) + olas_contract = ledger_api.api.eth.contract( + address=ledger_api.api.to_checksum_address(OLAS_CONTRACT_ADDRESS_GNOSIS), + abi=json.loads( + Path( + "operate", + "data", + "contracts", + "uniswap_v2_erc20", + "build", + "IUniswapV2ERC20.json", + ).read_text(encoding="utf-8") + ).get("abi"), + ) + print( + f"Balance of {address} = {olas_contract.functions.balanceOf(address).call()/1e18} OLAS" + ) + print(f"Transferring {amount} OLAS from {staking_wallet.address} to {address}") + + tx = olas_contract.functions.transfer( + address, int(amount * WEI_MULTIPLIER) + ).build_transaction( + { + "chainId": 100, + "gas": 100000, + "gasPrice": ledger_api.api.to_wei("50", "gwei"), + "nonce": ledger_api.api.eth.get_transaction_count(staking_wallet.address), + } + ) + + signed_txn = ledger_api.api.eth.account.sign_transaction( + tx, staking_wallet.private_key + ) + ledger_api.api.eth.send_raw_transaction(signed_txn.rawTransaction) + print( + f"Balance of {address} = {olas_contract.functions.balanceOf(address).call()/1e18} OLAS" + ) + + +if __name__ == "__main__": + args = sys.argv[1:] + if len(args) == 2: + fund(wallet=args[0], address=args[1]) + sys.exit() + + if len(args) == 3: + fund(wallet=args[0], address=args[1], amount=float(args[2])) + sys.exit() + + print( + """Script for transferring OLAS + +Usage: + python transfer_olas.py PATH_TO_KEY_CONTAINING_OLAS ADDRESS_TO_TRANSFER AMOUNT + +Example: + python transfer_olas.py keys/gnosis.txt 0xce11e14225575945b8e6dc0d4f2dd4c570f79d9f 2""" + )