From 200c0f477366b0d0ceda931ac71478fd7d32d64a Mon Sep 17 00:00:00 2001 From: Hemanth Sai Date: Thu, 25 Apr 2024 14:31:02 +0530 Subject: [PATCH] feat: Implement CosmWasm contracts (#1222) * wip * wip: cosmwasm contracts ui * wip * feat: add store for cosmwasm * wip: cosmwasm contracts * wip * wip: add attach funds option * wip * wip: deploy contract * chore: add loading and error handling * wip * feat: add state management * fix: lint issues * fix: lint issues * chore * refactor * chore * chore: review changes (#1224) * chore: review changes * chore: refactor code * feat: allow user to provide address list for instantiation * chore: review changes * chore * chore: add cosmwasm icon in sidebar --------- Co-authored-by: chary <57086313+charymalloju@users.noreply.github.com> --- frontend/package.json | 3 + frontend/public/cosmwasm-icon-active.svg | 5 + frontend/public/cosmwasm-icon.svg | 11 + .../src/app/(routes)/cosmwasm/Cosmwasm.tsx | 37 ++ .../cosmwasm/[network]/ChainContracts.tsx | 25 ++ .../cosmwasm/[network]/PageContracts.tsx | 68 ++++ .../app/(routes)/cosmwasm/[network]/page.tsx | 9 + .../cosmwasm/components/AddAddresses.tsx | 60 +++ .../cosmwasm/components/AddressInputField.tsx | 57 +++ .../cosmwasm/components/AllContracts.tsx | 7 + .../cosmwasm/components/AmountInputField.tsx | 50 +++ .../cosmwasm/components/AttachFunds.tsx | 90 +++++ .../cosmwasm/components/ContractInfo.tsx | 117 ++++++ .../cosmwasm/components/ContractItem.tsx | 24 ++ .../components/ContractNotSelected.tsx | 18 + .../cosmwasm/components/ContractSelected.tsx | 28 ++ .../cosmwasm/components/Contracts.tsx | 60 +++ .../cosmwasm/components/CustomTextField.tsx | 39 ++ .../cosmwasm/components/DeployContract.tsx | 39 ++ .../components/DialogSearchContract.tsx | 161 ++++++++ .../components/DialogTxExecuteStatus.tsx | 154 ++++++++ .../components/DialogTxInstantiateStatus.tsx | 156 ++++++++ .../components/DialogTxUploadCodeStatus.tsx | 152 ++++++++ .../cosmwasm/components/ExecuteContract.tsx | 198 ++++++++++ .../app/(routes)/cosmwasm/components/Fund.tsx | 57 +++ .../components/InstantiateContract.tsx | 287 ++++++++++++++ .../cosmwasm/components/ProvideFundsJson.tsx | 71 ++++ .../cosmwasm/components/QueryContract.tsx | 174 +++++++++ .../cosmwasm/components/SearchContracts.tsx | 56 +++ .../cosmwasm/components/SearchInputField.tsx | 36 ++ .../components/SelectDeploymentType.tsx | 35 ++ .../cosmwasm/components/SelectFunds.tsx | 59 +++ .../components/SelectPermissionType.tsx | 52 +++ .../cosmwasm/components/SelectSearchType.tsx | 35 ++ .../cosmwasm/components/TokensList.tsx | 50 +++ .../cosmwasm/components/UploadContract.tsx | 226 +++++++++++ .../ActionButtonsGroup.tsx | 38 ++ .../txn-status-components/CustomCopyField.tsx | 38 ++ .../src/app/(routes)/cosmwasm/cosmwasm.css | 146 +++++++ frontend/src/app/(routes)/cosmwasm/page.tsx | 9 + frontend/src/app/(routes)/cosmwasm/styles.ts | 91 +++++ .../multiops/[network]/PageMultiops.tsx | 2 +- frontend/src/components/SideBar.tsx | 6 +- frontend/src/custom-hooks/useContracts.ts | 330 ++++++++++++++++ frontend/src/custom-hooks/useDummyWallet.ts | 29 ++ frontend/src/custom-hooks/useGetChainInfo.ts | 1 + .../features/cosmwasm/cosmwasmService.ts | 86 +++++ .../store/features/cosmwasm/cosmwasmSlice.ts | 364 ++++++++++++++++++ .../src/store/features/wallet/walletSlice.ts | 1 - frontend/src/store/store.ts | 2 + frontend/src/types/cosmwasm.d.ts | 156 ++++++++ frontend/src/types/store.d.ts | 1 + frontend/src/utils/constants.ts | 10 + frontend/src/utils/util.ts | 46 +++ frontend/yarn.lock | 28 ++ 55 files changed, 4085 insertions(+), 5 deletions(-) create mode 100644 frontend/public/cosmwasm-icon-active.svg create mode 100644 frontend/public/cosmwasm-icon.svg create mode 100644 frontend/src/app/(routes)/cosmwasm/Cosmwasm.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/[network]/ChainContracts.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/[network]/PageContracts.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/[network]/page.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/AddAddresses.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/AddressInputField.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/AllContracts.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/AmountInputField.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/AttachFunds.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/ContractInfo.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/ContractItem.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/ContractNotSelected.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/ContractSelected.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/Contracts.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/CustomTextField.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/DeployContract.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/DialogTxExecuteStatus.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/DialogTxInstantiateStatus.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/DialogTxUploadCodeStatus.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/ExecuteContract.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/Fund.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/InstantiateContract.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/ProvideFundsJson.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/SearchContracts.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/SearchInputField.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/SelectDeploymentType.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/SelectFunds.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/SelectPermissionType.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/SelectSearchType.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/TokensList.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/UploadContract.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/txn-status-components/ActionButtonsGroup.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/components/txn-status-components/CustomCopyField.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/cosmwasm.css create mode 100644 frontend/src/app/(routes)/cosmwasm/page.tsx create mode 100644 frontend/src/app/(routes)/cosmwasm/styles.ts create mode 100644 frontend/src/custom-hooks/useContracts.ts create mode 100644 frontend/src/custom-hooks/useDummyWallet.ts create mode 100644 frontend/src/store/features/cosmwasm/cosmwasmService.ts create mode 100644 frontend/src/store/features/cosmwasm/cosmwasmSlice.ts create mode 100644 frontend/src/types/cosmwasm.d.ts diff --git a/frontend/package.json b/frontend/package.json index 9ee2ce368..609b712cd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@0xsquid/sdk": "^1.14.15", "@cosmjs/amino": "^0.31.3", + "@cosmjs/cosmwasm-stargate": "0.32.2", "@cosmjs/proto-signing": "^0.32.1", "@cosmjs/stargate": "^0.32.1", "@emotion/cache": "^11.11.0", @@ -25,6 +26,7 @@ "@reduxjs/toolkit": "^1.9.7", "@skip-router/core": "^1.3.11", "@types/node": "20.6.5", + "@types/node-gzip": "1.1.0", "@types/react": "18.2.37", "@types/react-dom": "18.2.15", "autoprefixer": "10.4.16", @@ -41,6 +43,7 @@ "mathjs": "^12.0.0", "moment": "^2.29.4", "next": "^14.0.1", + "node-gzip": "^1.1.2", "postcss": "8.4.30", "react": "^18.2.0", "react-chartjs-2": "^5.2.0", diff --git a/frontend/public/cosmwasm-icon-active.svg b/frontend/public/cosmwasm-icon-active.svg new file mode 100644 index 000000000..89a77c9b8 --- /dev/null +++ b/frontend/public/cosmwasm-icon-active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/cosmwasm-icon.svg b/frontend/public/cosmwasm-icon.svg new file mode 100644 index 000000000..c1f1b5d7f --- /dev/null +++ b/frontend/public/cosmwasm-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/app/(routes)/cosmwasm/Cosmwasm.tsx b/frontend/src/app/(routes)/cosmwasm/Cosmwasm.tsx new file mode 100644 index 000000000..d95c0c999 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/Cosmwasm.tsx @@ -0,0 +1,37 @@ +'use client'; +import TopNav from '@/components/TopNav'; +import Image from 'next/image'; +import React from 'react'; + +const Cosmwasm = () => { + const message = + 'All Networks page is not supported for Cosmwasm, Please select a network.'; + return ( +
+
+

Cosmwasm

+ +
+
+ {'No +

{message}

+ +
+
+ ); +}; + +export default Cosmwasm; diff --git a/frontend/src/app/(routes)/cosmwasm/[network]/ChainContracts.tsx b/frontend/src/app/(routes)/cosmwasm/[network]/ChainContracts.tsx new file mode 100644 index 000000000..a73503d36 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/[network]/ChainContracts.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import React from 'react'; +import PageContracts from './PageContracts'; + +const ChainContracts = ({ network }: { network: string }) => { + const nameToChainIDs = useAppSelector((state) => state.wallet.nameToChainIDs); + const chainName = network.toLowerCase(); + const validChain = chainName in nameToChainIDs; + return ( +
+ {validChain ? ( + + ) : ( + <> +
+ - The {chainName} is not supported - +
+ + )} +
+ ); +}; + +export default ChainContracts; diff --git a/frontend/src/app/(routes)/cosmwasm/[network]/PageContracts.tsx b/frontend/src/app/(routes)/cosmwasm/[network]/PageContracts.tsx new file mode 100644 index 000000000..94e058b08 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/[network]/PageContracts.tsx @@ -0,0 +1,68 @@ +'use client'; +import TopNav from '@/components/TopNav'; +import React, { useState } from 'react'; +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import Contracts from '../components/Contracts'; +import AllContracts from '../components/AllContracts'; +import DialogTxContractStatus from '../components/DialogTxUploadCodeStatus'; +import DialogTxExecuteStatus from '../components/DialogTxExecuteStatus'; +import DialogTxInstantiateStatus from '../components/DialogTxInstantiateStatus'; + +const PageContracts = ({ chainName }: { chainName: string }) => { + const nameToChainIDs: Record = useAppSelector( + (state) => state.wallet.nameToChainIDs + ); + const chainID = nameToChainIDs[chainName]; + const tabs = ['Contracts', 'All Contracts']; + const [selectedTab, setSelectedTab] = useState('Contracts'); + return ( +
+
+
+

+ CosmWasm Smart Contracts +

+ +
+
+ {tabs.map((tab) => ( +
+
{ + setSelectedTab(tab); + }} + > + {tab} +
+
+
+ ))} +
+
+ +
+ {selectedTab === 'Contracts' ? ( + + ) : ( + + )} +
+ + + +
+ ); +}; + +export default PageContracts; diff --git a/frontend/src/app/(routes)/cosmwasm/[network]/page.tsx b/frontend/src/app/(routes)/cosmwasm/[network]/page.tsx new file mode 100644 index 000000000..7dc44f417 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/[network]/page.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import '../cosmwasm.css'; +import ChainContracts from './ChainContracts'; + +const page = ({ params: { network } }: { params: { network: string } }) => { + return ; +}; + +export default page; diff --git a/frontend/src/app/(routes)/cosmwasm/components/AddAddresses.tsx b/frontend/src/app/(routes)/cosmwasm/components/AddAddresses.tsx new file mode 100644 index 000000000..a0fa9bd89 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/AddAddresses.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import AddressInputField from './AddressInputField'; + +interface AddAddressesI { + addresses: string[]; + setAddresses: React.Dispatch>; +} + +const AddAddresses = (props: AddAddressesI) => { + const { addresses, setAddresses } = props; + + const onAddAddress = (address: string) => { + setAddresses((prev) => [...prev, address]); + }; + + const onDelete = (index: number) => { + const newAddresses = addresses.filter((_, i) => i !== index); + setAddresses(newAddresses); + }; + + const handleAddressChange = ( + e: React.ChangeEvent, + index: number + ) => { + const input = e.target.value; + const newAddresses = addresses.map((value, key) => { + if (index === key) { + input.trim(); + } + return value; + }); + setAddresses(newAddresses); + }; + + return ( +
+ {addresses.map((value, index) => ( +
+ +
+ ))} +
+ +
+
+ ); +}; + +export default AddAddresses; diff --git a/frontend/src/app/(routes)/cosmwasm/components/AddressInputField.tsx b/frontend/src/app/(routes)/cosmwasm/components/AddressInputField.tsx new file mode 100644 index 000000000..ace050729 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/AddressInputField.tsx @@ -0,0 +1,57 @@ +import { InputAdornment, TextField } from '@mui/material'; +import React from 'react'; +import { customTextFieldStyles } from '../styles'; +import Image from 'next/image'; + +interface AddressInputFieldI { + address: string; + handleChange: ( + e: React.ChangeEvent, + index: number + ) => void; + onDelete: (index: number) => void; + index: number; +} + +const AddressInputField = (props: AddressInputFieldI) => { + const { address, handleChange, onDelete, index } = props; + return ( + + {index === 0 ? null : ( +
{ + onDelete(index); + }} + > + +
+ )} + + ), + }} + onChange={(e) => handleChange(e, index)} + /> + ); +}; + +export default AddressInputField; diff --git a/frontend/src/app/(routes)/cosmwasm/components/AllContracts.tsx b/frontend/src/app/(routes)/cosmwasm/components/AllContracts.tsx new file mode 100644 index 000000000..45a101a3b --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/AllContracts.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const AllContracts = () => { + return
Comming Soon...
; +}; + +export default AllContracts; diff --git a/frontend/src/app/(routes)/cosmwasm/components/AmountInputField.tsx b/frontend/src/app/(routes)/cosmwasm/components/AmountInputField.tsx new file mode 100644 index 000000000..042afe81d --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/AmountInputField.tsx @@ -0,0 +1,50 @@ +import { InputAdornment, TextField } from '@mui/material'; +import React from 'react'; +import { customTextFieldStyles } from '../styles'; +import Image from 'next/image'; + +interface AmountInputFieldI { + amount: string; + handleChange: ( + e: React.ChangeEvent, + index: number + ) => void; + onDelete: () => void; + index: number; +} + +const AmountInputField = (props: AmountInputFieldI) => { + const { amount, handleChange, onDelete, index } = props; + return ( + +
+ +
+ + ), + }} + onChange={(e) => handleChange(e, index)} + /> + ); +}; + +export default AmountInputField; diff --git a/frontend/src/app/(routes)/cosmwasm/components/AttachFunds.tsx b/frontend/src/app/(routes)/cosmwasm/components/AttachFunds.tsx new file mode 100644 index 000000000..8919690be --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/AttachFunds.tsx @@ -0,0 +1,90 @@ +import { + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, +} from '@mui/material'; +import React from 'react'; +import SelectFunds from './SelectFunds'; +import ProvideFundsJson from './ProvideFundsJson'; +import { assetsDropDownStyle } from '../styles'; +import useContracts from '@/custom-hooks/useContracts'; + +interface AttachFundsI { + handleAttachFundTypeChange: (event: SelectChangeEvent) => void; + attachFundType: string; + chainName: string; + setFunds: (value: React.SetStateAction) => void; + funds: FundInfo[]; + fundsInputJson: string; + setFundsInputJson: (value: string) => void; +} + +const AttachFunds = (props: AttachFundsI) => { + const { + handleAttachFundTypeChange, + attachFundType, + chainName, + funds, + setFunds, + fundsInputJson, + setFundsInputJson, + } = props; + const onAddFund = (fund: FundInfo) => { + setFunds((prev) => [...prev, fund]); + }; + const { getChainAssets } = useContracts(); + const { assetsList } = getChainAssets(chainName); + const onDelete = (index: number) => { + if (index === 0) return; + const newFunds = funds.filter((_, i) => i !== index); + setFunds(newFunds); + }; + return ( +
+ + + Select Transaction + + + + {attachFundType === 'select' ? ( + + ) : null} + {attachFundType === 'json' ? ( + + ) : null} +
+ ); +}; + +export default AttachFunds; diff --git a/frontend/src/app/(routes)/cosmwasm/components/ContractInfo.tsx b/frontend/src/app/(routes)/cosmwasm/components/ContractInfo.tsx new file mode 100644 index 000000000..a30449a51 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/ContractInfo.tsx @@ -0,0 +1,117 @@ +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import React, { useState } from 'react'; +import QueryContract from './QueryContract'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import ExecuteContract from './ExecuteContract'; + +const ContractInfo = ({ chainID }: { chainID: string }) => { + const selectedContractAddress = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.contractAddress + ); + const selectedContractInfo = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.contractInfo + ); + const tabs = ['Query Contract', 'Execute Contract']; + const [selectedTab, setSelectedTab] = useState('Query Contract'); + const { getChainInfo } = useGetChainInfo(); + const { + restURLs, + rpcURLs, + address: walletAddress, + chainName, + } = getChainInfo(chainID); + return ( +
+
+
+ {selectedContractInfo.label || selectedContractAddress} +
+
+ + + + + +
+
+
+
+ {tabs.map((tab) => ( +
+
{ + setSelectedTab(tab); + }} + > + {tab} +
+
+
+ ))} +
+
+ {selectedTab === 'Query Contract' ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default ContractInfo; + +const ContractInfoAttribute = ({ + name, + value, +}: { + name: string; + value: string; +}) => { + return ( + <> + {value ? ( +
+
{name}
+
{value}
+
+ ) : null} + + ); +}; diff --git a/frontend/src/app/(routes)/cosmwasm/components/ContractItem.tsx b/frontend/src/app/(routes)/cosmwasm/components/ContractItem.tsx new file mode 100644 index 000000000..a2ea73463 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/ContractItem.tsx @@ -0,0 +1,24 @@ +import CommonCopy from '@/components/CommonCopy'; +import { shortenName } from '@/utils/util'; +import React from 'react'; + +const ContractItem = ({ + name, + address, + onSelectContract, +}: { + name: string; + address: string; + onSelectContract: () => void; +}) => { + return ( +
onSelectContract()} + className="contract-item" + > +
{shortenName(name, 20)}
+ +
+ ); +}; +export default ContractItem; diff --git a/frontend/src/app/(routes)/cosmwasm/components/ContractNotSelected.tsx b/frontend/src/app/(routes)/cosmwasm/components/ContractNotSelected.tsx new file mode 100644 index 000000000..ffba9b58a --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/ContractNotSelected.tsx @@ -0,0 +1,18 @@ +import Image from 'next/image'; +import React from 'react'; + +const ContractNotSelected = () => { + return ( +
+ Search Contract +
Select or search contract
+
+ ); +}; + +export default ContractNotSelected; diff --git a/frontend/src/app/(routes)/cosmwasm/components/ContractSelected.tsx b/frontend/src/app/(routes)/cosmwasm/components/ContractSelected.tsx new file mode 100644 index 000000000..70694c278 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/ContractSelected.tsx @@ -0,0 +1,28 @@ +import CommonCopy from '@/components/CommonCopy'; +import React from 'react'; + +const ContractSelected = ({ + contract, + openSearchDialog, +}: { + contract: { address: string; name: string }; + openSearchDialog: () => void; +}) => { + const { address, name } = contract; + return ( +
+
+
{name}
+ +
+ +
+ ); +}; + +export default ContractSelected; diff --git a/frontend/src/app/(routes)/cosmwasm/components/Contracts.tsx b/frontend/src/app/(routes)/cosmwasm/components/Contracts.tsx new file mode 100644 index 000000000..87cd8443e --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/Contracts.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import SearchContracts from './SearchContracts'; +import DeployContract from './DeployContract'; +import ContractInfo from './ContractInfo'; + +const Contracts = ({ chainID }: { chainID: string }) => { + const [deployContractOpen, setDeployContractOpen] = useState(false); + const [selectedContract, setSelectedContract] = useState({ + address: '', + name: '', + }); + const handleSelectContract = (address: string, name: string) => { + setDeployContractOpen(false); + setSelectedContract({ address, name }); + }; + + return ( +
+
+
CosmWasm Smart Contracts
+
+ CosmWasm is a smart contracting platform built for the Cosmos + ecosystem. +
+
+
+
+ +
+ Don't have a contract? then deploy it{' '} + { + setDeployContractOpen(true); + setSelectedContract({ + address: '', + name: '', + }); + }} + className="font-bold underline underline-offset-[3px] cursor-pointer" + > + here + {' '} +
+
+
+ {deployContractOpen && !selectedContract.address ? ( + + ) : null} + {selectedContract.address ? : null} +
+ ); +}; + +export default Contracts; diff --git a/frontend/src/app/(routes)/cosmwasm/components/CustomTextField.tsx b/frontend/src/app/(routes)/cosmwasm/components/CustomTextField.tsx new file mode 100644 index 000000000..1a885dae6 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/CustomTextField.tsx @@ -0,0 +1,39 @@ +import { TextField } from '@mui/material'; +import React from 'react'; +import { Control, Controller } from 'react-hook-form'; +import { customTextFieldStyles } from '../styles'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const CustomTextField = ({ + control, + name, + placeHolder, + rules, + required, +}: { + control: Control; + rules?: any; + name: string; + placeHolder: string; + required: boolean; +}) => { + return ( + ( + + )} + /> + ); +}; + +export default CustomTextField; diff --git a/frontend/src/app/(routes)/cosmwasm/components/DeployContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/DeployContract.tsx new file mode 100644 index 000000000..7a5146360 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/DeployContract.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import SelectDeploymentType from './SelectDeploymentType'; +import UploadContract from './UploadContract'; +import InstantiateContract from './InstantiateContract'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; + +const DeployContract = ({ chainID }: { chainID: string }) => { + const { getChainInfo } = useGetChainInfo(); + const { address: walletAddress, restURLs } = getChainInfo(chainID); + const [isFileUpload, setIsFileUpload] = useState(false); + const onSelect = (value: boolean) => { + setIsFileUpload(value); + }; + return ( +
+
+
+ Deploy Contract +
+ +
+ {isFileUpload ? ( + + ) : ( + + )} +
+ ); +}; + +export default DeployContract; diff --git a/frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx new file mode 100644 index 000000000..ba4047fb9 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/DialogSearchContract.tsx @@ -0,0 +1,161 @@ +import { dialogBoxPaperPropStyles } from '@/utils/commonStyles'; +import { CLOSE_ICON_PATH } from '@/utils/constants'; +import { CircularProgress, Dialog, DialogContent } from '@mui/material'; +import Image from 'next/image'; +import React, { useState } from 'react'; +import SelectSearchType from './SelectSearchType'; +import { useAppDispatch } from '@/custom-hooks/StateHooks'; +import { setContract } from '@/store/features/cosmwasm/cosmwasmSlice'; +import useContracts from '@/custom-hooks/useContracts'; +import ContractItem from './ContractItem'; +import SearchInputField from './SearchInputField'; + +interface DialogSearchContractI { + open: boolean; + onClose: () => void; + chainID: string; + restURLs: string[]; + handleSelectContract: (address: string, name: string) => void; +} + +const DialogSearchContract = (props: DialogSearchContractI) => { + const { onClose, open, chainID, restURLs, handleSelectContract } = props; + const dispatch = useAppDispatch(); + const handleClose = () => { + onClose(); + }; + const [isEnterManually, setIsEnterManually] = useState(true); + const [searchResult, setSearchResult] = useState( + null + ); + const onSelect = (value: boolean) => { + setIsEnterManually(value); + }; + const [searchTerm, setSearchTerm] = useState(''); + const { getContractInfo, contractLoading, contractError } = useContracts(); + + const onSearchContract = async () => { + const { data } = await getContractInfo({ + address: searchTerm, + baseURLs: restURLs, + }); + setSearchResult(data); + }; + + const onSelectContract = () => { + if (searchResult) { + dispatch( + setContract({ + chainID, + contractAddress: searchResult?.address, + contractInfo: searchResult?.contract_info, + }) + ); + handleSelectContract( + searchResult?.address, + searchResult?.contract_info?.label + ); + onClose(); + } + }; + + return ( + + +
+
+
+ Close +
+
+
+
+
+

+ Select Contract +

+
+ Provide the contract address to search. Once found select the + contract to use it. +
+
+
+ +
+
+ {isEnterManually ? ( + <> +
+ setSearchTerm(value)} + /> + +
+
+ {contractLoading ? ( +
+ +
+ Searching for contract{' '} + +
+
+ ) : ( + <> + {searchResult ? ( +
+
Contract found:
+ +
+ ) : ( + <> + {contractError ? ( +
+ Error: {contractError} +
+ ) : null} + + )} + + )} +
+ + ) : ( +
Coming Soon...
+ )} +
+
+
+
+ ); +}; + +export default DialogSearchContract; diff --git a/frontend/src/app/(routes)/cosmwasm/components/DialogTxExecuteStatus.tsx b/frontend/src/app/(routes)/cosmwasm/components/DialogTxExecuteStatus.tsx new file mode 100644 index 000000000..f36dcf2ef --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/DialogTxExecuteStatus.tsx @@ -0,0 +1,154 @@ +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import { TxStatus } from '@/types/enums'; +import { dialogBoxPaperPropStyles } from '@/utils/commonStyles'; +import { TXN_FAILED_ICON, TXN_SUCCESS_ICON } from '@/utils/constants'; +import { parseBalance } from '@/utils/denom'; +import { Box, Dialog, DialogContent } from '@mui/material'; +import Image from 'next/image'; +import React, { useEffect, useMemo, useState } from 'react'; +import CustomCopyField from './txn-status-components/CustomCopyField'; +import ActionButtonsGroup from './txn-status-components/ActionButtonsGroup'; + +const DialogTxExecuteStatus = ({ chainID }: { chainID: string }) => { + const { getChainInfo, getDenomInfo } = useGetChainInfo(); + const { explorerTxHashEndpoint } = getChainInfo(chainID); + const { + decimals = 0, + displayDenom = '', + minimalDenom = '', + } = getDenomInfo(chainID); + const currency = useMemo( + () => ({ + coinMinimalDenom: minimalDenom, + coinDecimals: decimals, + coinDenom: displayDenom, + }), + [minimalDenom, decimals, displayDenom] + ); + + const [open, setOpen] = useState(false); + const txExecuteStatus = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txExecute?.status + ); + const txExecuteHash = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txExecute?.txHash + ); + const txResponse = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txExecute?.txResponse + ); + + const handleClose = () => { + setOpen(false); + }; + + useEffect(() => { + if (txExecuteHash && txExecuteStatus === TxStatus.IDLE) setOpen(true); + }, [txExecuteHash]); + + return ( + + + +
+
+
+ Transaction Successful +
+
+ {txResponse?.code === 0 ? ( + + Transaction Successful ! + + ) : ( + Transaction Failed ! + )} +
+
+
+ +
+ +
+
+
+ +
+
Fees
+
+ {txResponse?.fee?.[0] + ? parseBalance( + txResponse?.fee, + currency.coinDecimals, + currency.coinMinimalDenom + ) + : '-'}{' '} + {currency.coinDenom} +
+
+
+
Memo
+
+ {txResponse?.memo || '-'} +
+
+ {txResponse?.code === 0 ? null : ( +
+
Raw Log
+
+ {txResponse?.rawLog || '-'} +
+
+ )} +
+ +
+
+
+
+
+ ); +}; + +export default DialogTxExecuteStatus; diff --git a/frontend/src/app/(routes)/cosmwasm/components/DialogTxInstantiateStatus.tsx b/frontend/src/app/(routes)/cosmwasm/components/DialogTxInstantiateStatus.tsx new file mode 100644 index 000000000..6b57f1417 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/DialogTxInstantiateStatus.tsx @@ -0,0 +1,156 @@ +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import { TxStatus } from '@/types/enums'; +import { dialogBoxPaperPropStyles } from '@/utils/commonStyles'; +import { TXN_FAILED_ICON, TXN_SUCCESS_ICON } from '@/utils/constants'; +import { parseBalance } from '@/utils/denom'; +import { Box, Dialog, DialogContent } from '@mui/material'; +import Image from 'next/image'; +import React, { useEffect, useMemo, useState } from 'react'; +import CustomCopyField from './txn-status-components/CustomCopyField'; +import ActionButtonsGroup from './txn-status-components/ActionButtonsGroup'; + +const DialogTxInstantiateStatus = ({ chainID }: { chainID: string }) => { + const { getChainInfo, getDenomInfo } = useGetChainInfo(); + const { explorerTxHashEndpoint } = getChainInfo(chainID); + const { + decimals = 0, + displayDenom = '', + minimalDenom = '', + } = getDenomInfo(chainID); + const currency = useMemo( + () => ({ + coinMinimalDenom: minimalDenom, + coinDecimals: decimals, + coinDenom: displayDenom, + }), + [minimalDenom, decimals, displayDenom] + ); + + const [open, setOpen] = useState(false); + const txInstantiateStatus = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txInstantiate?.status + ); + const txHash = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txInstantiate?.txHash + ); + const txResponse = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txInstantiate?.txResponse + ); + + const handleClose = () => { + setOpen(false); + }; + + useEffect(() => { + if (txHash && txInstantiateStatus === TxStatus.IDLE) setOpen(true); + }, [txHash]); + + return ( + + + +
+
+
+ Transaction Successful +
+
+ {txResponse?.code === 0 ? ( + + Transaction Successful ! + + ) : ( + Transaction Failed ! + )} +
+
+
+ +
+ +
+
+
+ + + +
+
Fees
+
+ {txResponse?.fee?.[0] + ? parseBalance( + txResponse?.fee, + currency.coinDecimals, + currency.coinMinimalDenom + ) + : '-'}{' '} + {currency.coinDenom} +
+
+
+
Memo
+
+ {txResponse?.memo || '-'} +
+
+ {txResponse?.code === 0 ? null : ( +
+
Raw Log
+
+ {txResponse?.rawLog || '-'} +
+
+ )} +
+ +
+
+
+
+
+ ); +}; + +export default DialogTxInstantiateStatus; diff --git a/frontend/src/app/(routes)/cosmwasm/components/DialogTxUploadCodeStatus.tsx b/frontend/src/app/(routes)/cosmwasm/components/DialogTxUploadCodeStatus.tsx new file mode 100644 index 000000000..35e05cbee --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/DialogTxUploadCodeStatus.tsx @@ -0,0 +1,152 @@ +import { useAppSelector } from '@/custom-hooks/StateHooks'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import { TxStatus } from '@/types/enums'; +import { dialogBoxPaperPropStyles } from '@/utils/commonStyles'; +import { TXN_FAILED_ICON, TXN_SUCCESS_ICON } from '@/utils/constants'; +import { parseBalance } from '@/utils/denom'; +import { Box, Dialog, DialogContent } from '@mui/material'; +import Image from 'next/image'; +import React, { useEffect, useMemo, useState } from 'react'; +import CustomCopyField from './txn-status-components/CustomCopyField'; +import ActionButtonsGroup from './txn-status-components/ActionButtonsGroup'; + +const DialogTxUploadCodeStatus = ({ chainID }: { chainID: string }) => { + const { getChainInfo, getDenomInfo } = useGetChainInfo(); + const { explorerTxHashEndpoint } = getChainInfo(chainID); + const { + decimals = 0, + displayDenom = '', + minimalDenom = '', + } = getDenomInfo(chainID); + const currency = useMemo( + () => ({ + coinMinimalDenom: minimalDenom, + coinDecimals: decimals, + coinDenom: displayDenom, + }), + [minimalDenom, decimals, displayDenom] + ); + + const [open, setOpen] = useState(false); + const txUploadStatus = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txUpload?.status + ); + const txUploadHash = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txUpload?.txHash + ); + const txResponse = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txUpload?.txResponse + ); + + const handleClose = () => { + setOpen(false); + }; + + useEffect(() => { + if (txUploadHash && txUploadStatus === TxStatus.IDLE) setOpen(true); + }, [txUploadHash]); + + return ( + + + +
+
+
+ Transaction Successful +
+
+ {txResponse?.code === 0 ? ( + + Transaction Successful ! + + ) : ( + Transaction Failed ! + )} +
+
+
+ +
+ +
+
+
+ + +
+
Fees
+
+ {txResponse?.fee?.[0] + ? parseBalance( + txResponse?.fee, + currency.coinDecimals, + currency.coinMinimalDenom + ) + : '-'}{' '} + {currency.coinDenom} +
+
+
+
Memo
+
+ {txResponse?.memo || '-'} +
+
+ {txResponse?.code === 0 ? null : ( +
+
Raw Log
+
+ {txResponse?.rawLog || '-'} +
+
+ )} +
+ +
+
+
+
+
+ ); +}; + +export default DialogTxUploadCodeStatus; diff --git a/frontend/src/app/(routes)/cosmwasm/components/ExecuteContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/ExecuteContract.tsx new file mode 100644 index 000000000..d71e2b185 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/ExecuteContract.tsx @@ -0,0 +1,198 @@ +import useContracts from '@/custom-hooks/useContracts'; +import { CircularProgress, SelectChangeEvent, TextField } from '@mui/material'; +import React, { useState } from 'react'; +import AttachFunds from './AttachFunds'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; +import { TxStatus } from '@/types/enums'; +import { executeContract } from '@/store/features/cosmwasm/cosmwasmSlice'; +import { getFormattedFundsList } from '@/utils/util'; +import { queryInputStyles } from '../styles'; +import { setError } from '@/store/features/common/commonSlice'; + +interface ExecuteContractI { + address: string; + baseURLs: string[]; + chainID: string; + rpcURLs: string[]; + walletAddress: string; + chainName: string; +} + +const ExecuteContract = (props: ExecuteContractI) => { + const { address, baseURLs, chainID, rpcURLs, walletAddress, chainName } = + props; + + // ------------------------------------------// + // ---------------DEPENDENCIES---------------// + // ------------------------------------------// + const dispatch = useAppDispatch(); + const { getExecutionOutput } = useContracts(); + const { getDenomInfo } = useGetChainInfo(); + const { decimals, minimalDenom } = getDenomInfo(chainID); + + // ------------------------------------------// + // ------------------STATES------------------// + // ------------------------------------------// + const [executeInput, setExecuteInput] = useState(''); + const [attachFundType, setAttachFundType] = useState('no-funds'); + const [funds, setFunds] = useState([ + { + amount: '', + denom: minimalDenom, + decimals: decimals, + }, + ]); + const [fundsInput, setFundsInput] = useState(''); + + const txExecuteLoading = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID].txExecute.status + ); + + // ------------------------------------------------// + // -----------------CHANGE HANDLERS----------------// + // ------------------------------------------------// + const handleQueryChange = ( + e: React.ChangeEvent + ) => { + setExecuteInput(e.target.value); + }; + + const handleAttachFundTypeChange = (event: SelectChangeEvent) => { + setAttachFundType(event.target.value); + }; + + // ----------------------------------------------------// + // -----------------CUSTOM VALIDATIONS-----------------// + // ----------------------------------------------------// + const validateJSONInput = ( + input: string, + setInput: React.Dispatch>, + errorMessagePrefix: string + ): boolean => { + try { + if (!input?.length) { + dispatch( + setError({ + type: 'error', + message: `Please enter ${errorMessagePrefix}`, + }) + ); + return false; + } + const parsed = JSON.parse(input); + const formattedJSON = JSON.stringify(parsed, undefined, 4); + setInput(formattedJSON); + return true; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + dispatch( + setError({ + type: 'error', + message: `Invalid JSON input: (${errorMessagePrefix}) ${error?.message || ''}`, + }) + ); + return false; + } + }; + + const formatExecutionMessage = () => { + return validateJSONInput( + executeInput, + setExecuteInput, + 'Execution Message' + ); + }; + + const validateFunds = () => { + return validateJSONInput(fundsInput, setFundsInput, 'Attach Funds List'); + }; + + // ------------------------------------------// + // ---------------TRANSACTION----------------// + // ------------------------------------------// + const onExecute = async () => { + if (!formatExecutionMessage()) return; + if (attachFundType === 'json' && !validateFunds()) return; + + const attachedFunds = getFormattedFundsList( + funds, + fundsInput, + attachFundType + ); + + dispatch( + executeContract({ + chainID, + contractAddress: address, + msgs: executeInput, + rpcURLs, + walletAddress, + funds: attachedFunds, + baseURLs, + getExecutionOutput, + }) + ); + }; + + return ( +
+
+
+ + + +
+
+
+
+
Attach Funds
+
+ Provide the list of funds you would like to attach. +
+
+
+ +
+
+
+ ); +}; + +export default ExecuteContract; diff --git a/frontend/src/app/(routes)/cosmwasm/components/Fund.tsx b/frontend/src/app/(routes)/cosmwasm/components/Fund.tsx new file mode 100644 index 000000000..fc8f996b8 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/Fund.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import TokensList from './TokensList'; +import AmountInputField from './AmountInputField'; + +const Fund = ({ + assetsList, + fund, + onDelete, + index, + funds, + setFunds, +}: { + assetsList: AssetInfo[]; + fund: FundInfo; + onDelete: () => void; + index: number; + funds: FundInfo[]; + setFunds: (value: React.SetStateAction) => void; +}) => { + const handleAmountChange = ( + e: React.ChangeEvent, + index: number + ) => { + const input = e.target.value; + const newFunds = funds.map((value, key) => { + if (index === key) { + if (/^-?\d*\.?\d*$/.test(input)) { + if ((input.match(/\./g) || []).length <= 1) { + value.amount = input; + } + } + } + return value; + }); + setFunds(newFunds); + }; + + return ( +
+ + +
+ ); +}; + +export default Fund; diff --git a/frontend/src/app/(routes)/cosmwasm/components/InstantiateContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/InstantiateContract.tsx new file mode 100644 index 000000000..fc70ba32b --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/InstantiateContract.tsx @@ -0,0 +1,287 @@ +import { CircularProgress, SelectChangeEvent, TextField } from '@mui/material'; +import React, { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import useContracts from '@/custom-hooks/useContracts'; +import AttachFunds from './AttachFunds'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import { getFormattedFundsList } from '@/utils/util'; +import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; +import { txInstantiateContract } from '@/store/features/cosmwasm/cosmwasmSlice'; +import { TxStatus } from '@/types/enums'; +import CustomTextField from './CustomTextField'; +import { queryInputStyles } from '../styles'; +import { setError } from '@/store/features/common/commonSlice'; + +interface InstantiateContractI { + chainID: string; + walletAddress: string; + restURLs: string[]; +} +interface InstatiateContractInputs { + codeId: string; + label: string; + adminAddress: string; + message: string; +} + +const InstantiateContract = (props: InstantiateContractI) => { + const { chainID, walletAddress, restURLs } = props; + + // ------------------------------------------// + // ---------------DEPENDENCIES---------------// + // ------------------------------------------// + const dispatch = useAppDispatch(); + const { instantiateContract } = useContracts(); + const { getDenomInfo } = useGetChainInfo(); + const { decimals, minimalDenom, chainName } = getDenomInfo(chainID); + + // ------------------------------------------// + // ------------------STATES------------------// + // ------------------------------------------// + const [attachFundType, setAttachFundType] = useState('no-funds'); + const [funds, setFunds] = useState([ + { + amount: '', + denom: minimalDenom, + decimals: decimals, + }, + ]); + const [fundsInput, setFundsInput] = useState(''); + + const txInstantiateStatus = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txInstantiate?.status + ); + + // ------------------------------------------------// + // -----------------FORM HOOKS---------------------// + // ------------------------------------------------// + const { handleSubmit, control, setValue, getValues } = + useForm({ + defaultValues: { + codeId: '', + label: '', + adminAddress: '', + message: '', + }, + }); + + // ------------------------------------------------// + // -----------------CHANGE HANDLER-----------------// + // ------------------------------------------------// + const handleAttachFundTypeChange = (event: SelectChangeEvent) => { + setAttachFundType(event.target.value); + }; + + // ----------------------------------------------------// + // -----------------CUSTOM VALIDATIONS-----------------// + // ----------------------------------------------------// + const validateJSONInput = ( + input: string, + setInput: (value: string) => void, + errorMessagePrefix: string + ): boolean => { + try { + if (!input?.length) { + dispatch( + setError({ + type: 'error', + message: `Please enter ${errorMessagePrefix}`, + }) + ); + return false; + } + const parsed = JSON.parse(input); + const formattedJSON = JSON.stringify(parsed, undefined, 4); + setInput(formattedJSON); + return true; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + dispatch( + setError({ + type: 'error', + message: `Invalid JSON input: (${errorMessagePrefix}) ${error?.message || ''}`, + }) + ); + return false; + } + }; + + const formatInstantiationMessage = () => { + return validateJSONInput( + getValues('message'), + (value: string) => { + setValue('message', value); + }, + 'Instatiation Message' + ); + }; + + const validateFunds = () => { + return validateJSONInput( + fundsInput, + (value: string) => { + setFundsInput(value); + }, + 'Attach Funds List' + ); + }; + + // ------------------------------------------// + // ---------------TRANSACTION----------------// + // ------------------------------------------// + const onSubmit = (data: InstatiateContractInputs) => { + const parsedCodeId = Number(data.codeId); + if (isNaN(parsedCodeId)) { + dispatch( + setError({ + type: 'error', + message: 'Invalid Code ID', + }) + ); + return; + } + + if (!formatInstantiationMessage()) return; + if (attachFundType === 'json' && !validateFunds()) return; + + const attachedFunds = getFormattedFundsList( + funds, + fundsInput, + attachFundType + ); + + dispatch( + txInstantiateContract({ + chainID, + codeId: Number(data.codeId), + instantiateContract, + label: data.label, + msg: data.message, + baseURLs: restURLs, + admin: data.adminAddress ? data.adminAddress : undefined, + funds: attachedFunds, + }) + ); + }; + + return ( +
+
+
+
Code ID
+ +
+
+
+
Label
+ +
+
+
+ Admin Address + { + setValue('adminAddress', walletAddress); + }} + className="styled-underlined-text" + > + Assign Me + +
+ +
+
+
+
+
Attach Funds
+ +
+
+
+ Instantiate Message +
+
+
+ ( + + )} + /> + +
+
+
+
+ +
+
+ ); +}; + +export default InstantiateContract; + +const InstatiateButton = ({ loading }: { loading: boolean }) => { + return ( + + ); +}; diff --git a/frontend/src/app/(routes)/cosmwasm/components/ProvideFundsJson.tsx b/frontend/src/app/(routes)/cosmwasm/components/ProvideFundsJson.tsx new file mode 100644 index 000000000..829b04dcf --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/ProvideFundsJson.tsx @@ -0,0 +1,71 @@ +import { TextField } from '@mui/material'; +import React from 'react'; +import { queryInputStyles } from '../styles'; +import { useAppDispatch } from '@/custom-hooks/StateHooks'; +import { setError } from '@/store/features/common/commonSlice'; + +interface ProvideFundsJsonI { + fundsInput: string; + setFundsInput: (value: string) => void; +} + +const ProvideFundsJson = (props: ProvideFundsJsonI) => { + const { fundsInput, setFundsInput } = props; + const dispatch = useAppDispatch(); + const handleFundsChange = ( + e: React.ChangeEvent + ) => { + setFundsInput(e.target.value); + }; + const formatJSON = () => { + try { + const parsed = JSON.parse(fundsInput); + const formattedJSON = JSON.stringify(parsed, undefined, 4); + setFundsInput(formattedJSON); + return; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + dispatch( + setError({ + type: 'error', + message: + 'Invalid JSON input: (Attach Funds) ' + (error?.message || ''), + }) + ); + } + }; + + return ( +
+
+ + +
+
+ ); +}; + +export default ProvideFundsJson; diff --git a/frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx new file mode 100644 index 000000000..dbbbd4412 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/QueryContract.tsx @@ -0,0 +1,174 @@ +import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; +import useContracts from '@/custom-hooks/useContracts'; +import { queryContractInfo } from '@/store/features/cosmwasm/cosmwasmSlice'; +import { TxStatus } from '@/types/enums'; +import { CircularProgress, TextField } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { queryInputStyles } from '../styles'; +import { setError } from '@/store/features/common/commonSlice'; + +interface QueryContractI { + address: string; + baseURLs: string[]; + chainID: string; +} + +const QueryContract = (props: QueryContractI) => { + const { address, baseURLs, chainID } = props; + + // ------------------------------------------// + // ---------------DEPENDENCIES---------------// + // ------------------------------------------// + const dispatch = useAppDispatch(); + const { getContractMessages, getQueryContract } = useContracts(); + + // ------------------------------------------// + // ------------------STATES------------------// + // ------------------------------------------// + const [queryText, setQueryText] = useState(''); + const [contractMessages, setContractMessages] = useState([]); + + const queryOutput = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.query.queryOutput + ); + const queryLoading = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.query.status + ); + + // ------------------------------------------------// + // -----------------CHANGE HANDLERS----------------// + // ------------------------------------------------// + const handleQueryChange = ( + e: React.ChangeEvent + ) => { + setQueryText(e.target.value); + }; + + const handleSelectMessage = (msg: string) => { + setQueryText(`{\n\t"${msg}": {}\n}`); + }; + + const formatJSON = () => { + try { + const parsed = JSON.parse(queryText); + const formattedJSON = JSON.stringify(parsed, undefined, 4); + setQueryText(formattedJSON); + return true; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + dispatch( + setError({ + type: 'error', + message: 'Invalid JSON input: ' + (error?.message || ''), + }) + ); + } + return false; + }; + + // --------------------------------------// + // -----------------QUERY----------------// + // --------------------------------------// + const onQuery = () => { + if (!queryText?.length) { + dispatch( + setError({ + type: 'error', + message: 'Please enter query message', + }) + ); + return; + } + if (!formatJSON()) { + return; + } + + dispatch( + queryContractInfo({ + address, + baseURLs, + queryData: queryText, + chainID, + getQueryContract, + }) + ); + }; + + // ------------------------------------------// + // ---------------SIDE EFFECT----------------// + // ------------------------------------------// + useEffect(() => { + const fetchMessages = async () => { + const { messages } = await getContractMessages({ address, baseURLs }); + setContractMessages(messages); + }; + fetchMessages(); + }, [address]); + + return ( +
+
+
+
Suggested Messages:
+
+ {contractMessages?.map((msg) => ( +
handleSelectMessage(msg)} + key={msg} + className="query-shortcut-msg" + > + {msg} +
+ ))} +
+
+
+ + + +
+
+
+
+
{JSON.stringify(queryOutput, undefined, 2)}
+
+
+
+ ); +}; + +export default QueryContract; diff --git a/frontend/src/app/(routes)/cosmwasm/components/SearchContracts.tsx b/frontend/src/app/(routes)/cosmwasm/components/SearchContracts.tsx new file mode 100644 index 000000000..377441cf3 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/SearchContracts.tsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; +import DialogSearchContract from './DialogSearchContract'; +import useGetChainInfo from '@/custom-hooks/useGetChainInfo'; +import ContractSelected from './ContractSelected'; +import ContractNotSelected from './ContractNotSelected'; + +interface SearchContractsI { + chainID: string; + selectedContract: { address: string; name: string }; + handleSelectContract: (address: string, name: string) => void; +} + +const SearchContracts = (props: SearchContractsI) => { + const { chainID, handleSelectContract, selectedContract } = props; + + // DEPENDENCIES + const { getChainInfo } = useGetChainInfo(); + const { restURLs } = getChainInfo(chainID); + + // STATE + const [searchDialogOpen, setSearchDialogOpen] = useState(false); + + // CHANGE HANDLER + const handleClose = () => { + setSearchDialogOpen(false); + }; + + return ( + <> +
{ + if (!selectedContract.address) setSearchDialogOpen(true); + }} + > + {selectedContract.address ? ( + setSearchDialogOpen(true)} + /> + ) : ( + + )} +
+ + + ); +}; + +export default SearchContracts; diff --git a/frontend/src/app/(routes)/cosmwasm/components/SearchInputField.tsx b/frontend/src/app/(routes)/cosmwasm/components/SearchInputField.tsx new file mode 100644 index 000000000..d62cfd03d --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/SearchInputField.tsx @@ -0,0 +1,36 @@ +import Image from 'next/image'; +import React from 'react'; + +const SearchInputField = ({ + searchTerm, + setSearchTerm, +}: { + searchTerm: string; + setSearchTerm: (value: string) => void; +}) => { + return ( +
+
+ Search +
+
+ setSearchTerm(e.target.value)} + autoFocus={true} + /> +
+
+ ); +}; + +export default SearchInputField; diff --git a/frontend/src/app/(routes)/cosmwasm/components/SelectDeploymentType.tsx b/frontend/src/app/(routes)/cosmwasm/components/SelectDeploymentType.tsx new file mode 100644 index 000000000..0940e8825 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/SelectDeploymentType.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface SelectDeploymentTypeProps { + isFileUpload: boolean; + onSelect: (value: boolean) => void; +} + +const SelectDeploymentType: React.FC = (props) => { + const { isFileUpload, onSelect } = props; + return ( +
+
onSelect(false)} + > + +
Use existing Code ID
+
+
onSelect(true)}> + +
Upload new WASM file
+
+
+ ); +}; + +export default SelectDeploymentType; diff --git a/frontend/src/app/(routes)/cosmwasm/components/SelectFunds.tsx b/frontend/src/app/(routes)/cosmwasm/components/SelectFunds.tsx new file mode 100644 index 000000000..1d66c92a8 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/SelectFunds.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import Fund from './Fund'; + +interface SelectFundsI { + onAddFund: (fund: FundInfo) => void; + funds: FundInfo[]; + assetsList: AssetInfo[]; + onDelete: (index: number) => void; + setFunds: (value: React.SetStateAction) => void; +} + +const SelectFunds = (props: SelectFundsI) => { + const { onAddFund, funds, assetsList, onDelete, setFunds } = props; + const handleAddFund = () => { + onAddFund({ + amount: '', + denom: '', + decimals: 1, + }); + }; + return ( +
+ {assetsList?.length ? ( +
+ {funds.map((fund, index) => ( + onDelete(index)} + index={index} + funds={funds} + setFunds={setFunds} + /> + ))} +
+ +
+
+ ) : ( +
+ - Assets not found, Please select:{' '} + + Provide Assets List + {' '} + option to enter manually - +
+ )} +
+ ); +}; + +export default SelectFunds; diff --git a/frontend/src/app/(routes)/cosmwasm/components/SelectPermissionType.tsx b/frontend/src/app/(routes)/cosmwasm/components/SelectPermissionType.tsx new file mode 100644 index 000000000..1f444cd8d --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/SelectPermissionType.tsx @@ -0,0 +1,52 @@ +import { + FormControl, + MenuItem, + Select, + SelectChangeEvent, +} from '@mui/material'; +import React from 'react'; +import { selectTxnStyles } from '../styles'; +import { AccessType } from 'cosmjs-types/cosmwasm/wasm/v1/types'; + +interface SelectPermissionTypeI { + handleAccessTypeChange: (event: SelectChangeEvent) => void; + accessType: AccessType; +} + +const SelectPermissionType = (props: SelectPermissionTypeI) => { + const { handleAccessTypeChange, accessType } = props; + return ( +
+ + + +
+ ); +}; + +export default SelectPermissionType; diff --git a/frontend/src/app/(routes)/cosmwasm/components/SelectSearchType.tsx b/frontend/src/app/(routes)/cosmwasm/components/SelectSearchType.tsx new file mode 100644 index 000000000..2fc956423 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/SelectSearchType.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface SelectSearchTypeProps { + isEnterManually: boolean; + onSelect: (value: boolean) => void; +} + +const SelectSearchType: React.FC = (props) => { + const { isEnterManually, onSelect } = props; + return ( +
+
onSelect(true)} + > + +
Enter Address Manually
+
+
onSelect(false)}> + +
Select from List
+
+
+ ); +}; + +export default SelectSearchType; diff --git a/frontend/src/app/(routes)/cosmwasm/components/TokensList.tsx b/frontend/src/app/(routes)/cosmwasm/components/TokensList.tsx new file mode 100644 index 000000000..3f59d0069 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/TokensList.tsx @@ -0,0 +1,50 @@ +import { MenuItem, Select, SelectChangeEvent } from '@mui/material'; +import React from 'react'; +import { assetsDropDownStyle } from '../styles'; + +interface TokensListI { + assetsList: AssetInfo[]; + denom: string; + index: number; + funds: FundInfo[]; + setFunds: (value: React.SetStateAction) => void; +} + +const TokensList = (props: TokensListI) => { + const { assetsList, denom, index, funds, setFunds } = props; + const handleSelectAsset = (e: SelectChangeEvent) => { + const selectedValue = e.target.value; + const selected = assetsList.find( + (asset) => asset.coinMinimalDenom === selectedValue + ); + + const newFunds = funds.map((value, key) => { + if (index === key) { + value.denom = selectedValue; + value.decimals = selected?.decimals || 1; + } + return value; + }); + setFunds(newFunds); + }; + return ( +
+ +
+ ); +}; +export default TokensList; diff --git a/frontend/src/app/(routes)/cosmwasm/components/UploadContract.tsx b/frontend/src/app/(routes)/cosmwasm/components/UploadContract.tsx new file mode 100644 index 000000000..ad40d0786 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/UploadContract.tsx @@ -0,0 +1,226 @@ +import React, { useState } from 'react'; +import { + CircularProgress, + IconButton, + SelectChangeEvent, + Tooltip, +} from '@mui/material'; +import ClearIcon from '@mui/icons-material/Clear'; +import Image from 'next/image'; +import { useForm } from 'react-hook-form'; +import { AccessType } from 'cosmjs-types/cosmwasm/wasm/v1/types'; +import SelectPermissionType from './SelectPermissionType'; +import useContracts from '@/custom-hooks/useContracts'; +import { gzip } from 'node-gzip'; +import { useAppDispatch, useAppSelector } from '@/custom-hooks/StateHooks'; +import { uploadCode } from '@/store/features/cosmwasm/cosmwasmSlice'; +import { TxStatus } from '@/types/enums'; +import { setError } from '@/store/features/common/commonSlice'; +import AddAddresses from './AddAddresses'; + +interface UploadContractI { + chainID: string; + walletAddress: string; + restURLs: string[]; +} + +interface UploadContractInput { + wasmFile?: File; + permission: AccessType; + allowedAddresses: Record<'address', string>[]; +} + +const UploadContract = (props: UploadContractI) => { + const { chainID, walletAddress, restURLs } = props; + + // ------------------------------------------// + // ---------------DEPENDENCIES---------------// + // ------------------------------------------// + const dispatch = useAppDispatch(); + const { uploadContract } = useContracts(); + + // ------------------------------------------// + // -----------------STATES-------------------// + // ------------------------------------------// + const [uploadedFileName, setUploadedFileName] = useState(''); + const [accessType, setAccessType] = useState(3); + const [addresses, setAddresses] = useState(['']); + + const uploadContractStatus = useAppSelector( + (state) => state.cosmwasm.chains?.[chainID]?.txUpload.status + ); + const uploadContractLoading = uploadContractStatus === TxStatus.PENDING; + + // ------------------------------------------------// + // -----------------FORM HOOKS---------------------// + // ------------------------------------------------// + const { setValue, handleSubmit, getValues } = useForm({ + defaultValues: { + wasmFile: undefined, + permission: AccessType.ACCESS_TYPE_EVERYBODY, + allowedAddresses: [{ address: '' }], + }, + }); + + const validateAddresses = () => { + for (const addr of addresses) { + if (addr.length === 0) { + return false; + } + } + return true; + }; + + // ------------------------------------------------// + // -----------------CHANGE HANDLERS----------------// + // ------------------------------------------------// + const handleAccessTypeChange = (event: SelectChangeEvent) => { + setAccessType(event.target.value as AccessType); + setValue('permission', event.target.value as AccessType); + }; + + const resetUploadedFile = () => { + const fileInputElement = document.getElementById( + 'wasm-file-upload' + ) as HTMLInputElement; + fileInputElement.value = ''; + setValue('wasmFile', undefined); + }; + + // ------------------------------------------// + // ---------------TRANSACTION----------------// + // ------------------------------------------// + const onUpload = async (data: UploadContractInput) => { + const wasmcode = getValues('wasmFile')?.arrayBuffer(); + if (!wasmcode) { + dispatch( + setError({ type: 'error', message: 'Please upload the wasm file' }) + ); + return; + } + if ( + data.permission === AccessType.ACCESS_TYPE_ANY_OF_ADDRESSES && + !validateAddresses() + ) { + dispatch(setError({ type: 'error', message: 'Address cannot be empty' })); + return; + } + const msg: Msg = { + typeUrl: '/cosmwasm.wasm.v1.MsgStoreCode', + value: { + sender: walletAddress, + wasmByteCode: await gzip(new Uint8Array(await wasmcode)), + instantiatePermission: { + permission: data.permission, + addresses, + address: '', + }, + }, + }; + dispatch( + uploadCode({ + chainID, + address: walletAddress, + messages: [msg], + baseURLs: restURLs, + uploadContract, + }) + ); + }; + + return ( +
+
+
{ + document.getElementById('wasm-file-upload')!.click(); + }} + > +
+ {uploadedFileName ? ( + <> +
+ {uploadedFileName}{' '} + + { + setUploadedFileName(''); + resetUploadedFile(); + e.stopPropagation(); + }} + > + + + +
+ + ) : ( + <> + Upload file +
Upload (.wasm) file here
+ + )} +
+ { + const { files } = e.target; + const selectedFiles = files as FileList; + const selectedFile = selectedFiles?.[0]; + setValue('wasmFile', selectedFile); + setUploadedFileName(selectedFile?.name || ''); + }} + /> +
+
+
+
+
+ Select Instantiate Permission +
+
+ Provide the list of funds you would like to attach. +
+
+ +
+ {accessType === AccessType.ACCESS_TYPE_ANY_OF_ADDRESSES ? ( +
+ +
+ ) : null} +
+
+ +
+ ); +}; + +export default UploadContract; diff --git a/frontend/src/app/(routes)/cosmwasm/components/txn-status-components/ActionButtonsGroup.tsx b/frontend/src/app/(routes)/cosmwasm/components/txn-status-components/ActionButtonsGroup.tsx new file mode 100644 index 000000000..079809bec --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/txn-status-components/ActionButtonsGroup.tsx @@ -0,0 +1,38 @@ +import { useAppDispatch } from '@/custom-hooks/StateHooks'; +import { setError } from '@/store/features/common/commonSlice'; +import { copyToClipboard } from '@/utils/copyToClipboard'; +import { getTxnURL } from '@/utils/util'; +import Link from 'next/link'; +import React from 'react'; + +const ActionButtonsGroup = ({ + explorer, + txHash, +}: { + explorer: string; + txHash: string; +}) => { + const dispatch = useAppDispatch(); + return ( +
+ + + View + +
+ ); +}; + +export default ActionButtonsGroup; diff --git a/frontend/src/app/(routes)/cosmwasm/components/txn-status-components/CustomCopyField.tsx b/frontend/src/app/(routes)/cosmwasm/components/txn-status-components/CustomCopyField.tsx new file mode 100644 index 000000000..578e3644d --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/components/txn-status-components/CustomCopyField.tsx @@ -0,0 +1,38 @@ +import { useAppDispatch } from '@/custom-hooks/StateHooks'; +import { setError } from '@/store/features/common/commonSlice'; +import { copyToClipboard } from '@/utils/copyToClipboard'; +import Image from 'next/image'; +import React from 'react'; + +const CustomCopyField = ({ name, value }: { name: string; value: string }) => { + const dispatch = useAppDispatch(); + return ( +
+
{name}
+
+
+ {value || '-'} + { + copyToClipboard(value || '-'); + dispatch( + setError({ + type: 'success', + message: 'Copied', + }) + ); + e.stopPropagation(); + }} + src="/copy-icon-plain.svg" + width={24} + height={24} + alt="copy" + /> +
+
+
+ ); +}; + +export default CustomCopyField; diff --git a/frontend/src/app/(routes)/cosmwasm/cosmwasm.css b/frontend/src/app/(routes)/cosmwasm/cosmwasm.css new file mode 100644 index 000000000..c14f0df5d --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/cosmwasm.css @@ -0,0 +1,146 @@ +.tabs { + @apply flex gap-10 items-center justify-center border-b-[1px] border-[#ffffff1e] mt-6; +} + +.menu-item { + @apply cursor-pointer px-2 text-[18px] h-9 flex items-center pb-[14px] leading-[21.7px]; +} + +.selected-contract { + @apply px-4 py-6 border-[1px] border-[#FFFFFF80] rounded-2xl max-h-20 flex; +} + +.styled-underlined-text { + @apply font-bold underline underline-offset-[3px] cursor-pointer; +} + +.change-btn, +.deploy-btn { + @apply text-[12px] px-3 py-[6px] rounded-lg leading-[20px] font-medium tracking-[0.48px] h-10 flex justify-center items-center; +} + +.search-contract-field { + @apply h-12 flex bg-[#FFFFFF1A] items-center px-6 py-2 rounded-lg hover:bg-[#ffffff0d] flex-1; + border: 1px solid transparent; +} + +.search-contract-field:focus-within { + border: 1px solid #ffffff4a; +} + +.search-contract-input { + @apply w-full pl-2 border-none focus:outline-none bg-transparent placeholder:text-[16px] placeholder:text-[#FFFFFFBF] focus:border-[1px]; +} + +.styled-btn-wrapper { + @apply rounded-lg p-[1px] w-fit; + background: linear-gradient(to right, #4aa29c, #8b3da7); +} + +.styled-btn-wrapper:hover { + background: linear-gradient(to right, #8b3da7, #4aa29c); +} + +.styled-btn { + @apply bg-[#171429] text-[12px] font-semibold px-3 py-[10px] flex justify-center items-center rounded-lg; +} + +.query-input-wrapper, +.query-output-box { + @apply w-[50%] bg-[#FFFFFF0D] rounded-2xl min-h-[380px] p-4 flex flex-col gap-4; +} + +.execute-field-wrapper, +.execute-output-box { + @apply w-[50%] max-h-[440px] p-6; +} + +.execute-field-wrapper { + @apply bg-[#ffffff0d] rounded-2xl p-4 flex flex-col gap-4; +} + +.execute-input-field { + @apply relative flex-1 border-[1px] rounded-2xl border-[#ffffff1e] hover:border-[#ffffff50]; +} + +.execute-output-box { + @apply flex flex-col gap-6 pt-0; +} + +.search-btn { + @apply font-medium rounded-lg px-3 py-[6px]; +} + +.search-error { + @apply text-red-500 font-light text-center; +} + +.search-contract { + @apply mb-10 flex gap-6 px-10 items-center flex-col; +} + +.search-contract-header { + @apply flex flex-col gap-4 w-full pb-2 border-b-[1px] border-[#ffffff33]; +} + +.contract-item { + @apply flex gap-4 justify-between items-center px-3 py-3 pr-1 rounded-full cursor-pointer border-[1px] border-[#ffffff32] hover:bg-[#ffffff32]; +} + +.execute-btn { + @apply h-10 rounded-lg px-3 py-[6px] absolute bottom-6 right-6 min-w-[85px]; +} + +.format-json-btn { + @apply border-[1px] border-[#FFFFFF33] rounded-full p-2 text-[12px] font-extralight top-4 right-4 absolute hover:border-[#ffffff50]; +} + +.attach-funds-header { + @apply border-b-[1px] border-[#ffffff1e] pb-4 space-y-2; +} + +.attach-funds-description { + @apply leading-[18px] text-[12px] font-extralight; +} + +.instantiate-input-wrapper { + @apply bg-[#ffffff0d] p-4 rounded-2xl flex-1; +} + +.instantiate-input { + @apply relative h-full border-[1px] rounded-2xl border-[#ffffff1e] hover:border-[#ffffff50]; +} + +.provide-funds-input-wrapper { + @apply bg-[#FFFFFF0D] p-4 rounded-2xl; +} + +.provide-funds-input { + @apply border-[1px] border-[#ffffff1e] hover:border-[#ffffff50] rounded-2xl relative; +} + +.query-btn { + @apply h-10 rounded-lg px-3 py-[6px] absolute bottom-6 right-6 min-w-[85px]; +} + +.query-input { + @apply relative flex-1 border-[1px] rounded-2xl border-[#ffffff1e] hover:border-[#ffffff50]; +} + +.qeury-output { + @apply border-[1px] border-[#ffffff1e] h-full rounded-2xl p-4 overflow-x-scroll overflow-y-scroll; +} + +.query-shortcut-msg { + @apply px-4 py-2 rounded-2xl bg-[#FFFFFF14] cursor-pointer hover:bg-[#ffffff26]; +} + +.add-funds-btn, +.add-address-btn { + @apply rounded-lg px-3 py-[6px] font-medium leading-[20px] text-[12px]; +} + +.instantiate-btn, +.upload-btn { + @apply flex justify-center items-center text-[12px] font-medium px-3 py-[6px] rounded-lg h-10 w-full; +} diff --git a/frontend/src/app/(routes)/cosmwasm/page.tsx b/frontend/src/app/(routes)/cosmwasm/page.tsx new file mode 100644 index 000000000..1a47558a9 --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/page.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import './cosmwasm.css'; +import Cosmwasm from './Cosmwasm'; + +const page = () => { + return ; +}; + +export default page; diff --git a/frontend/src/app/(routes)/cosmwasm/styles.ts b/frontend/src/app/(routes)/cosmwasm/styles.ts new file mode 100644 index 000000000..fe0fc822d --- /dev/null +++ b/frontend/src/app/(routes)/cosmwasm/styles.ts @@ -0,0 +1,91 @@ +export const customTextFieldStyles = { + '& .MuiTypography-body1': { + color: 'white', + fontSize: '12px', + fontWeight: 200, + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', + }, + '& .MuiOutlinedInput-root': { + border: '1px solid transparent', + borderRadius: '12px', + color: 'white', + }, + '& .Mui-focused': { + border: '1px solid #ffffff4a', + borderRadius: '12px', + }, + '& .MuiInputAdornment-root': { + '& button': { + color: 'white', + }, + }, + '& .Mui-disabled': { + WebkitTextFillColor: '#ffffff !important', + }, +}; + +export const assetsDropDownStyle = { + '& .MuiOutlinedInput-input': { + color: 'white', + }, + '& .MuiOutlinedInput-root': { + padding: '0px !important', + border: 'none', + }, + '& .MuiSvgIcon-root': { + color: 'white', + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none !important', + }, + '& .MuiTypography-body1': { + color: 'white', + fontSize: '12px', + fontWeight: 200, + }, + borderRadius: '8px', +}; + +export const selectTxnStyles = { + '& .MuiOutlinedInput-input': { + color: 'white', + }, + '& .MuiOutlinedInput-root': { + padding: '0px !important', + border: 'none', + }, + '& .MuiSvgIcon-root': { + color: 'white', + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none !important', + }, + '& .MuiTypography-body1': { + color: 'white', + fontSize: '12px', + fontWeight: 200, + }, + borderRadius: '8px', +}; + +export const queryInputStyles = { + '& .MuiTypography-body1': { + color: 'white', + fontSize: '12px', + fontWeight: 200, + }, + '& .MuiOutlinedInput-notchedOutline': { + border: 'none', + }, + '& .MuiOutlinedInput-root': { + border: 'none', + borderRadius: '16px', + color: 'white', + }, + '& .Mui-focused': { + border: 'none', + borderRadius: '16px', + }, +}; diff --git a/frontend/src/app/(routes)/multiops/[network]/PageMultiops.tsx b/frontend/src/app/(routes)/multiops/[network]/PageMultiops.tsx index 02e958dff..1de3306ee 100644 --- a/frontend/src/app/(routes)/multiops/[network]/PageMultiops.tsx +++ b/frontend/src/app/(routes)/multiops/[network]/PageMultiops.tsx @@ -12,7 +12,7 @@ const PageMultiops = ({ chainName }: { chainName: string }) => { return (
-

Multiops

+

MultiOps

diff --git a/frontend/src/components/SideBar.tsx b/frontend/src/components/SideBar.tsx index 628ac5393..bf939a40a 100644 --- a/frontend/src/components/SideBar.tsx +++ b/frontend/src/components/SideBar.tsx @@ -117,7 +117,7 @@ const MenuItem = ({ authzSupport: boolean; metamaskSupport: boolean; }) => { - // Here when the url(pathName) includes validator, + // Here when the url(pathName) includes validator, // we are setting the module to staking (to highlight the staking module) const path = pathName === 'overview' @@ -167,8 +167,8 @@ const MenuItem = ({
{itemName}
diff --git a/frontend/src/custom-hooks/useContracts.ts b/frontend/src/custom-hooks/useContracts.ts new file mode 100644 index 000000000..2597b41e1 --- /dev/null +++ b/frontend/src/custom-hooks/useContracts.ts @@ -0,0 +1,330 @@ +import { + connectWithSigner, + getContract, + queryContract, +} from '@/store/features/cosmwasm/cosmwasmService'; +import { extractContractMessages } from '@/utils/util'; +import { useState } from 'react'; +import { useDummyWallet } from './useDummyWallet'; +import chainDenoms from '@/utils/chainDenoms.json'; +import useGetChainInfo from './useGetChainInfo'; +import { Event } from 'cosmjs-types/tendermint/abci/types'; +import { toUtf8 } from '@cosmjs/encoding'; + +declare let window: WalletWindow; + +const dummyQuery = { + '': '', +}; + +const assetsData = chainDenoms as AssetData; + +const GAS = '900000'; + +const getCodeIdFromEvents = (events: Event[]) => { + let codeId = ''; + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.type === 'store_code') { + for (let j = 0; j < event.attributes.length; j++) { + const attribute = event.attributes[j]; + if (attribute.key === 'code_id') { + codeId = attribute.value; + break; + } + } + } + } + return codeId; +}; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +const getCodeId = (txData: any) => { + return getCodeIdFromEvents(txData?.events || []); +}; + +const useContracts = () => { + const [contractLoading, setContractLoading] = useState(false); + const [contractError, setContractError] = useState(''); + + const [messagesLoading, setMessagesLoading] = useState(false); + + const { getDummyWallet } = useDummyWallet(); + const { getChainInfo } = useGetChainInfo(); + + const getContractInfo = async ({ + address, + baseURLs, + }: { + baseURLs: string[]; + address: string; + }) => { + try { + setContractLoading(true); + setContractError(''); + const res = await getContract(baseURLs, address); + setContractError(''); + return { + data: await res.json(), + }; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + setContractError(error.message); + } finally { + setContractLoading(false); + } + return { + data: null, + }; + }; + + const getContractMessages = async ({ + address, + baseURLs, + }: { + address: string; + baseURLs: string[]; + }) => { + let messages = []; + try { + setMessagesLoading(true); + setContractError(''); + await queryContract(baseURLs, address, btoa(JSON.stringify(dummyQuery))); + return { + messages: [], + }; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + messages = extractContractMessages(error.message); + } finally { + setMessagesLoading(false); + } + return { + messages, + }; + }; + + const getQueryContract = async ({ + address, + baseURLs, + queryData, + }: GetQueryContractFunctionInputs) => { + try { + const respose = await queryContract(baseURLs, address, btoa(queryData)); + return { + data: await respose.json(), + }; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + throw new Error(error.message); + } + }; + + const getExecuteMessages = async ({ + rpcURLs, + chainID, + contractAddress, + }: { + rpcURLs: string[]; + chainID: string; + contractAddress: string; + }) => { + const { dummyAddress, dummyWallet } = await getDummyWallet({ chainID }); + const client = await connectWithSigner(rpcURLs, dummyWallet); + try { + await client.simulate( + dummyAddress, + [ + { + typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract', + value: { + sender: dummyAddress, + contract: contractAddress, + msg: Buffer.from('{"": {}}'), + funds: [], + }, + }, + ], + undefined + ); + } catch (error: any) { + console.log(error); + } + }; + + const getExecutionOutput = async ({ + rpcURLs, + chainID, + contractAddress, + walletAddress, + msgs, + funds, + }: GetExecutionOutputFunctionInputs) => { + const offlineSigner = window.wallet.getOfflineSigner(chainID); + const client = await connectWithSigner(rpcURLs, offlineSigner); + const { feeAmount, feeCurrencies } = getChainInfo(chainID); + const { coinDecimals, coinDenom } = feeCurrencies[0]; + const fee = { + amount: [ + { + amount: (feeAmount * 10 ** coinDecimals).toString(), + denom: coinDenom, + }, + ], + gas: GAS, + }; + try { + const response = await client.signAndBroadcast( + walletAddress, + [ + { + typeUrl: '/cosmwasm.wasm.v1.MsgExecuteContract', + value: { + sender: walletAddress, + contract: contractAddress, + msg: toUtf8(msgs), + funds, + }, + }, + ], + fee, + '' + ); + return { txHash: response.transactionHash }; + } catch (error: any) { + throw new Error(error?.message || 'Failed to execute contract'); + } + }; + + const uploadContract = async ({ + chainID, + address, + messages, + }: UploadContractFunctionInputs) => { + const { feeAmount, feeCurrencies, rpcURLs } = getChainInfo(chainID); + const { coinDecimals, coinDenom } = feeCurrencies[0]; + const offlineSigner = window.wallet.getOfflineSigner(chainID); + const client = await connectWithSigner(rpcURLs, offlineSigner); + + const fee = { + amount: [ + { + amount: (feeAmount * 10 ** coinDecimals).toString(), + denom: coinDenom, + }, + ], + gas: '1100000', + }; + try { + const response = await client.signAndBroadcast( + address, + messages, + fee, + undefined, + undefined + ); + const codeId = getCodeId(response); + return { codeId, txHash: response?.transactionHash }; + } catch (error: any) { + throw new Error(error?.message || 'Failed to upload contract'); + } + }; + + const instantiateContract = async ({ + chainID, + codeId, + msg, + label, + admin, + funds, + }: InstantiateContractFunctionInputs) => { + const { + feeAmount, + feeCurrencies, + rpcURLs, + address: senderAddress, + } = getChainInfo(chainID); + const { coinDecimals, coinDenom } = feeCurrencies[0]; + const offlineSigner = window.wallet.getOfflineSigner(chainID); + const client = await connectWithSigner(rpcURLs, offlineSigner); + const fee = { + amount: [ + { + amount: (feeAmount * 10 ** coinDecimals).toString(), + denom: coinDenom, + }, + ], + gas: GAS, + }; + try { + const response = await client.signAndBroadcast( + senderAddress, + [ + { + typeUrl: '/cosmwasm.wasm.v1.MsgInstantiateContract', + value: { + sender: senderAddress, + codeId: codeId, + msg: toUtf8(msg), + label: label, + funds: funds || [], + admin: admin, + }, + }, + ], + fee, + '' + ); + const instantiateEvent = response.events.find( + (event) => event.type === 'instantiate' + ); + const contractAddress = + instantiateEvent?.attributes.find( + (attr) => attr.key === '_contract_address' + )?.value || ''; + const uploadedCodeId = + instantiateEvent?.attributes.find((attr) => attr.key === 'code_id') + ?.value || ''; + return { + codeId: uploadedCodeId, + contractAddress, + txHash: response?.transactionHash, + }; + } catch (error: any) { + throw new Error(error.message || 'Failed to instantiate'); + } + }; + + const getChainAssets = (chainName: string) => { + const chainAssets = assetsData?.[chainName]; + const assetsList: { + coinMinimalDenom: string; + decimals: number; + symbol: string; + }[] = []; + chainAssets?.forEach((asset) => { + assetsList.push({ + symbol: asset.symbol, + decimals: asset.decimals, + coinMinimalDenom: asset.origin_denom, + }); + }); + return { assetsList }; + }; + + return { + contractLoading, + getContractInfo, + contractError, + getContractMessages, + messagesLoading, + getQueryContract, + getExecuteMessages, + getExecutionOutput, + getChainAssets, + uploadContract, + instantiateContract, + }; +}; + +export default useContracts; diff --git a/frontend/src/custom-hooks/useDummyWallet.ts b/frontend/src/custom-hooks/useDummyWallet.ts new file mode 100644 index 000000000..5410aca53 --- /dev/null +++ b/frontend/src/custom-hooks/useDummyWallet.ts @@ -0,0 +1,29 @@ +import { DirectSecp256k1HdWallet } from '@cosmjs/proto-signing'; +import { useState } from 'react'; +import useGetChainInfo from './useGetChainInfo'; +import { DUMMY_WALLET_MNEMONIC } from '@/utils/constants'; + +export const useDummyWallet = () => { + const { getChainInfo } = useGetChainInfo(); + const [dummyWallet, setDummyWallet] = useState(); + const [dummyAddress, setDummyAddress] = useState(''); + const getDummyWallet = async ({ chainID }: { chainID: string }) => { + console.log(DUMMY_WALLET_MNEMONIC) + const { prefix } = getChainInfo(chainID); + if (DUMMY_WALLET_MNEMONIC) { + const wallet = await DirectSecp256k1HdWallet.fromMnemonic( + DUMMY_WALLET_MNEMONIC, + { + prefix, + } + ); + + setDummyWallet(wallet); + + const { address } = (await wallet.getAccounts())[0]; + setDummyAddress(address); + } + return { dummyWallet, dummyAddress }; + }; + return { getDummyWallet }; +}; diff --git a/frontend/src/custom-hooks/useGetChainInfo.ts b/frontend/src/custom-hooks/useGetChainInfo.ts index 295142b50..5c45cf0b1 100644 --- a/frontend/src/custom-hooks/useGetChainInfo.ts +++ b/frontend/src/custom-hooks/useGetChainInfo.ts @@ -74,6 +74,7 @@ const useGetChainInfo = () => { return { restURLs: config.restURIs, + rpcURLs: config.rpcURIs, baseURL: rest, chainID, aminoConfig: aminoCfg, diff --git a/frontend/src/store/features/cosmwasm/cosmwasmService.ts b/frontend/src/store/features/cosmwasm/cosmwasmService.ts new file mode 100644 index 000000000..98c1dd693 --- /dev/null +++ b/frontend/src/store/features/cosmwasm/cosmwasmService.ts @@ -0,0 +1,86 @@ +'use client'; + +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'; + +const getContractURL = (baseURL: string, address: string) => + `${baseURL}/cosmwasm/wasm/v1/contract/${address}`; + +const getContractQueryURL = ( + baseURL: string, + address: string, + queryData: string +) => `${baseURL}/cosmwasm/wasm/v1/contract/${address}/smart/${queryData}`; + +export const getContract = async ( + baseURLs: string[], + address: string +): Promise => { + for (const url of baseURLs) { + const uri = getContractURL(url, address); + try { + const response = await fetch(uri); + if (response.status === 500) { + const errorBody = await response.json(); + throw new Error(errorBody?.message || 'Failed to fetch contract', { + cause: 500, + }); + } else if (response.ok) { + return response; + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + if (error.cause === 500) throw new Error(error.message); + continue; + } + } + throw new Error('Failed to fetch contract'); +}; + +export const queryContract = async ( + baseURLs: string[], + address: string, + queryData: string +): Promise => { + for (const url of baseURLs) { + const uri = getContractQueryURL(url, address, queryData); + try { + const response = await fetch(uri); + if (response.status === 500) { + const errorBody = await response.json(); + throw new Error(errorBody?.message || 'Failed to query contract', { + cause: 500, + }); + } else if (response.ok) { + return response; + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + if (error.cause === 500) throw new Error(error.message); + continue; + } + } + throw new Error('Failed to query contract'); +}; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const connectWithSigner = async (urls: string[], offlineSigner: any) => { + for (const url of urls) { + try { + const signer = await SigningCosmWasmClient.connectWithSigner( + url, + offlineSigner + ); + return signer; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + console.error(`Error connecting to ${url}: ${error.message}`); + } + } + throw new Error('Unable to connect to any RPC URLs'); +}; + +const result = { + contract: getContract, +}; + +export default result; diff --git a/frontend/src/store/features/cosmwasm/cosmwasmSlice.ts b/frontend/src/store/features/cosmwasm/cosmwasmSlice.ts new file mode 100644 index 000000000..55f6f5072 --- /dev/null +++ b/frontend/src/store/features/cosmwasm/cosmwasmSlice.ts @@ -0,0 +1,364 @@ +'use client'; + +import { TxStatus } from '@/types/enums'; +import { ERR_UNKNOWN } from '@/utils/errors'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { cloneDeep } from 'lodash'; +import { setError } from '../common/commonSlice'; +import axios from 'axios'; +import { cleanURL } from '@/utils/util'; +import { parseTxResult } from '@/utils/signing'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const contractInfoEmptyState = { + admin: '', + label: '', + code_id: '', + creator: '', + created: { + block_height: '', + tx_index: '', + }, + ibc_port_id: '', + extension: null, +}; + +interface Chain { + contractAddress: string; + contractInfo: ContractInfo; + txUpload: { + status: TxStatus; + error: string; + txHash: string; + txResponse: ParsedUploadTxnResponse; + }; + txInstantiate: { + status: TxStatus; + error: string; + txHash: string; + txResponse: ParsedInstatiateTxnResponse; + }; + txExecute: { + status: TxStatus; + error: string; + txHash: string; + txResponse: ParsedExecuteTxnResponse; + }; + query: { + status: TxStatus; + error: string; + queryOutput: string; + }; +} + +interface Chains { + [key: string]: Chain; +} + +interface CosmwasmState { + chains: Chains; + defaultState: Chain; +} + +const initialState: CosmwasmState = { + chains: {}, + defaultState: { + contractAddress: '', + contractInfo: contractInfoEmptyState, + txUpload: { + status: TxStatus.INIT, + error: '', + txResponse: { + code: 0, + fee: [], + transactionHash: '', + rawLog: '', + memo: '', + codeId: '', + }, + txHash: '', + }, + txInstantiate: { + status: TxStatus.INIT, + error: '', + txHash: '', + txResponse: { + code: 0, + fee: [], + transactionHash: '', + rawLog: '', + memo: '', + codeId: '', + contractAddress: '', + }, + }, + txExecute: { + status: TxStatus.INIT, + error: '', + txHash: '', + txResponse: { + code: 0, + fee: [], + transactionHash: '', + rawLog: '', + memo: '', + }, + }, + query: { + queryOutput: '', + status: TxStatus.INIT, + error: '', + }, + }, +}; + +export const queryContractInfo = createAsyncThunk( + 'cosmwasm/query-contract', + async (data: QueryContractInfoInputs, { rejectWithValue, dispatch }) => { + try { + const response = await data.getQueryContract(data); + return { + data: response.data, + chainID: data.chainID, + }; + } catch (error: any) { + const errMsg = error?.message || 'Failed to query contract'; + dispatch( + setError({ + message: errMsg, + type: 'error', + }) + ); + return rejectWithValue(errMsg); + } + } +); + +export const executeContract = createAsyncThunk( + 'cosmwasm/execute-contract', + async (data: ExecuteContractInputs, { rejectWithValue, dispatch }) => { + try { + const response = await data.getExecutionOutput(data); + const txn = await axios.get( + cleanURL(data.baseURLs[0]) + '/cosmos/tx/v1beta1/txs/' + response.txHash + ); + const { + code, + transactionHash, + fee = [], + memo = '', + rawLog = '', + } = parseTxResult(txn?.data?.tx_response); + return { + data: { code, transactionHash, fee, memo, rawLog }, + chainID: data.chainID, + }; + } catch (error: any) { + const errMsg = error?.message || 'Failed to execute contract'; + dispatch( + setError({ + message: errMsg, + type: 'error', + }) + ); + return rejectWithValue(errMsg); + } + } +); + +export const uploadCode = createAsyncThunk( + 'cosmwasm/upload-code', + async (data: UploadCodeInputs, { rejectWithValue, dispatch }) => { + try { + const response = await data.uploadContract(data); + const txn = await axios.get( + cleanURL(data.baseURLs[0]) + '/cosmos/tx/v1beta1/txs/' + response.txHash + ); + const { + code, + transactionHash, + fee = [], + memo = '', + rawLog = '', + } = parseTxResult(txn?.data?.tx_response); + return { + data: { + code, + transactionHash, + fee, + memo, + rawLog, + codeId: response.codeId, + }, + chainID: data.chainID, + }; + } catch (error: any) { + const errMsg = error?.message || 'Failed to execute contract'; + dispatch( + setError({ + message: errMsg, + type: 'error', + }) + ); + return rejectWithValue(errMsg); + } + } +); + +export const txInstantiateContract = createAsyncThunk( + 'cosmwasm/instantiate-contract', + async (data: InstantiateContractInputs, { rejectWithValue, dispatch }) => { + try { + const response = await data.instantiateContract(data); + const txn = await axios.get( + cleanURL(data.baseURLs[0]) + '/cosmos/tx/v1beta1/txs/' + response.txHash + ); + const { + code, + transactionHash, + fee = [], + memo = '', + rawLog = '', + } = parseTxResult(txn?.data?.tx_response); + return { + data: { + code, + transactionHash, + fee, + memo, + rawLog, + codeId: response.codeId, + contractAddress: response.contractAddress, + }, + chainID: data.chainID, + }; + } catch (error: any) { + const errMsg = error?.message || 'Failed to execute contract'; + dispatch( + setError({ + message: errMsg, + type: 'error', + }) + ); + return rejectWithValue(errMsg); + } + } +); + +export const cosmwasmSlice = createSlice({ + name: 'cosmwasm', + initialState, + reducers: { + setContract: ( + state, + action: PayloadAction<{ + contractAddress: string; + contractInfo: ContractInfo; + chainID: string; + }> + ) => { + const chainID = action.payload.chainID; + if (!state.chains[chainID]) { + state.chains[chainID] = cloneDeep(initialState.defaultState); + } + state.chains[chainID].contractInfo = action.payload.contractInfo; + state.chains[chainID].contractAddress = action.payload.contractAddress; + }, + }, + extraReducers: (builder) => { + builder + .addCase(queryContractInfo.pending, (state, action) => { + const chainID = action.meta.arg.chainID; + if (!state.chains[chainID]) { + state.chains[chainID] = cloneDeep(initialState.defaultState); + } + state.chains[chainID].query.status = TxStatus.PENDING; + state.chains[chainID].query.error = ''; + }) + .addCase(queryContractInfo.fulfilled, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].query.status = TxStatus.IDLE; + state.chains[chainID].query.error = ''; + state.chains[chainID].query.queryOutput = action.payload.data; + }) + .addCase(queryContractInfo.rejected, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].query.status = TxStatus.REJECTED; + state.chains[chainID].query.error = action.error.message || ERR_UNKNOWN; + state.chains[chainID].query.queryOutput = '{}'; + }); + builder + .addCase(executeContract.pending, (state, action) => { + const chainID = action.meta.arg.chainID; + if (!state.chains[chainID]) { + state.chains[chainID] = cloneDeep(initialState.defaultState); + } + state.chains[chainID].txExecute.status = TxStatus.PENDING; + state.chains[chainID].txExecute.error = ''; + }) + .addCase(executeContract.fulfilled, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].txExecute.status = TxStatus.IDLE; + state.chains[chainID].txExecute.error = ''; + state.chains[chainID].txExecute.txResponse = action.payload.data; + state.chains[chainID].txExecute.txHash = + action.payload.data.transactionHash; + }) + .addCase(executeContract.rejected, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].txExecute.status = TxStatus.REJECTED; + state.chains[chainID].txExecute.error = + action.error.message || ERR_UNKNOWN; + }); + builder + .addCase(uploadCode.pending, (state, action) => { + const chainID = action.meta.arg.chainID; + if (!state.chains[chainID]) { + state.chains[chainID] = cloneDeep(initialState.defaultState); + } + state.chains[chainID].txUpload.status = TxStatus.PENDING; + state.chains[chainID].txUpload.error = ''; + }) + .addCase(uploadCode.fulfilled, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].txUpload.status = TxStatus.IDLE; + state.chains[chainID].txUpload.error = ''; + state.chains[chainID].txUpload.txResponse = action.payload.data; + state.chains[chainID].txUpload.txHash = + action.payload.data.transactionHash; + }) + .addCase(uploadCode.rejected, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].txUpload.status = TxStatus.REJECTED; + state.chains[chainID].txUpload.error = + action.error.message || ERR_UNKNOWN; + }); + builder + .addCase(txInstantiateContract.pending, (state, action) => { + const chainID = action.meta.arg.chainID; + if (!state.chains[chainID]) { + state.chains[chainID] = cloneDeep(initialState.defaultState); + } + state.chains[chainID].txInstantiate.status = TxStatus.PENDING; + state.chains[chainID].txInstantiate.error = ''; + }) + .addCase(txInstantiateContract.fulfilled, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].txInstantiate.status = TxStatus.IDLE; + state.chains[chainID].txInstantiate.error = ''; + state.chains[chainID].txInstantiate.txResponse = action.payload.data; + state.chains[chainID].txInstantiate.txHash = + action.payload.data.transactionHash; + }) + .addCase(txInstantiateContract.rejected, (state, action) => { + const chainID = action.meta.arg.chainID; + state.chains[chainID].txInstantiate.status = TxStatus.REJECTED; + state.chains[chainID].txInstantiate.error = + action.error.message || ERR_UNKNOWN; + }); + }, +}); + +export const { setContract } = cosmwasmSlice.actions; + +export default cosmwasmSlice.reducer; diff --git a/frontend/src/store/features/wallet/walletSlice.ts b/frontend/src/store/features/wallet/walletSlice.ts index 80d44e9d1..616c19aaf 100644 --- a/frontend/src/store/features/wallet/walletSlice.ts +++ b/frontend/src/store/features/wallet/walletSlice.ts @@ -63,7 +63,6 @@ export const establishWalletConnection = createAsyncThunk( const networks = data.networks; if (!isWalletInstalled(data.walletName)) { dispatch(setError({ type: 'error', message: 'Wallet is not installed' })); - return rejectWithValue('wallet is not installed'); } else { window.wallet.defaultOptions = { diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index e961cf3db..c3f5bd4c2 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -15,6 +15,7 @@ import feegrantSlice from './features/feegrant/feegrantSlice'; import recentTransactionsSlice from './features/recent-transactions/recentTransactionsSlice'; import multiopsSlice from './features/multiops/multiopsSlice'; import swapsSlice from './features/swaps/swapsSlice'; +import cosmwasmSlice from './features/cosmwasm/cosmwasmSlice'; export const store = configureStore({ reducer: { @@ -32,6 +33,7 @@ export const store = configureStore({ recentTransactions: recentTransactionsSlice, multiops: multiopsSlice, swaps: swapsSlice, + cosmwasm: cosmwasmSlice, }, }); diff --git a/frontend/src/types/cosmwasm.d.ts b/frontend/src/types/cosmwasm.d.ts new file mode 100644 index 000000000..68fe3a635 --- /dev/null +++ b/frontend/src/types/cosmwasm.d.ts @@ -0,0 +1,156 @@ +interface ContractInfo { + code_id: string; + creator: string; + admin: string; + label: string; + created: { + block_height: string; + tx_index: string; + }; + ibc_port_id: string; + extension: string | null; +} + +interface ContractInfoResponse { + address: string; + contract_info: ContractInfo; +} + +interface AssetInfo { + coinMinimalDenom: string; + decimals: number; + symbol: string; +} + +interface FundInfo { + amount: string; + denom: string; + decimals: number; +} + +interface ParsedExecuteTxnResponse { + code: number; + fee: Coin[]; + transactionHash: string; + rawLog: string; + memo: string; +} + +interface ParsedUploadTxnResponse extends ParsedExecuteTxnResponse { + codeId: string; +} + +interface ParsedInstatiateTxnResponse extends ParsedUploadTxnResponse { + contractAddress: string; +} + +interface GetQueryContractFunctionInputs { + address: string; + baseURLs: string[]; + queryData: string; +} + +interface QueryContractInfoInputs { + address: string; + baseURLs: string[]; + queryData: string; + chainID: string; + getQueryContract: ({ + address, + baseURLs, + queryData, + }: GetQueryContractFunctionInputs) => Promise<{ + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + data: any; + }>; +} + +interface GetExecutionOutputFunctionInputs { + rpcURLs: string[]; + chainID: string; + contractAddress: string; + walletAddress: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + msgs: any; + funds: + | { + amount: string; + denom: string; + }[] + | undefined; +} + +interface ExecuteContractInputs { + rpcURLs: string[]; + chainID: string; + contractAddress: string; + walletAddress: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + msgs: any; + baseURLs: string[]; + funds: { amount: string; denom: string }[] | undefined; + getExecutionOutput: ({ + rpcURLs, + chainID, + contractAddress, + walletAddress, + msgs, + funds, + }: GetExecutionOutputFunctionInputs) => Promise<{ + txHash: string; + }>; +} + +interface UploadContractFunctionInputs { + chainID: string; + address: string; + messages: Msg[]; +} + +interface UploadCodeInputs { + chainID: string; + address: string; + messages: Msg[]; + baseURLs: string[]; + uploadContract: ({ + chainID, + address, + messages, + }: UploadContractFunctionInputs) => Promise<{ + codeId: string; + txHash: string; + }>; +} + +interface InstantiateContractFunctionInputs { + chainID: string; + codeId: number; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + msg: any; + label: string; + admin?: string; + funds?: Coin[]; +} + +interface InstantiateContractInputs { + chainID: string; + codeId: number; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + msg: any; + label: string; + admin?: string; + funds?: Coin[]; + baseURLs: string[]; + instantiateContract: ({ + chainID, + codeId, + msg, + label, + admin, + funds, + }: InstantiateContractFunctionInputs) => Promise<{ + codeId: string; + contractAddress: string; + txHash: string; + }>; +} diff --git a/frontend/src/types/store.d.ts b/frontend/src/types/store.d.ts index 8bbc337af..9c96da62c 100644 --- a/frontend/src/types/store.d.ts +++ b/frontend/src/types/store.d.ts @@ -1,5 +1,6 @@ interface BasicChainInfo { restURLs: string[]; + rpcURLs: string[]; baseURL: string; chainID: string; aminoConfig: AminoConfig; diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 7f1586994..de04668c8 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -184,6 +184,14 @@ export const SIDENAV_MENU_ITEMS = [ authzSupported: false, isMetaMaskSupports: false, }, + { + name: 'CosmWasm Contracts', + icon: '/cosmwasm-icon.svg', + activeIcon: '/cosmwasm-icon-active.svg', + link: '/cosmwasm', + authzSupported: false, + isMetaMaskSupports: false, + }, ]; export const ALL_NETWORKS_ICON = '/all-networks-icon.png'; export const CHANGE_NETWORK_ICON = '/switch-icon.svg'; @@ -321,3 +329,5 @@ export const MULTIOPS_SAMPLE_FILES = { vote: 'https://raw.githubusercontent.com/vitwit/resolute/a6a02cc1b74ee34604e6df35cfce7a46c39980ea/frontend/src/example-files/vote.csv', }; export const SWAP_ROUTE_ERROR = 'Failed to fetch routes.'; +export const DUMMY_WALLET_MNEMONIC = + process.env.NEXT_PUBLIC_DUMMY_WALLET_MNEMONIC || ''; diff --git a/frontend/src/utils/util.ts b/frontend/src/utils/util.ts index 608a6a4d8..517e61b1f 100644 --- a/frontend/src/utils/util.ts +++ b/frontend/src/utils/util.ts @@ -77,6 +77,10 @@ export const getSelectedPartFromURL = (urlParts: string[]): string => { return 'History'; case 'validator': return 'Staking'; + case 'cosmwasm': + return 'Cosmwasm'; + case 'multiops': + return 'Multiops'; default: return 'Overview'; } @@ -467,3 +471,45 @@ export function formatValidatorStatsValue( ? '-' : Number(numValue.toFixed(precision)).toLocaleString(); } + +export function extractContractMessages(inputString: string): string[] { + const pattern: RegExp = /`(\w+)`/g; + + const matches: string[] = []; + let match: RegExpExecArray | null; + while ((match = pattern.exec(inputString)) !== null) { + matches.push(match[1]); + } + + return matches; +} + +export const getFormattedFundsList = ( + funds: FundInfo[], + fundsInput: string, + attachFundType: string +) => { + if (attachFundType === 'select') { + const result: { + denom: string; + amount: string; + }[] = []; + funds.forEach((fund) => { + if (fund.amount.length) { + result.push({ + denom: fund.denom, + amount: (Number(fund.amount) * 10 ** fund.decimals).toString(), + }); + } + }); + return result; + } else if (attachFundType === 'json') { + try { + const parsedFunds = JSON.parse(fundsInput); + return parsedFunds; + /* eslint-disable @typescript-eslint/no-explicit-any */ + } catch (error: any) { + console.log(error); + } + } +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 385484fdf..2f5b51f57 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -170,6 +170,22 @@ long "^4.0.0" pako "^2.0.2" +"@cosmjs/cosmwasm-stargate@0.32.2": + version "0.32.2" + resolved "https://registry.yarnpkg.com/@cosmjs/cosmwasm-stargate/-/cosmwasm-stargate-0.32.2.tgz#32aca8b4c2043cd1bc91cf4d0225b268c166e421" + integrity sha512-OwJHzIx2CoJS6AULxOpNR6m+CI0GXxy8z9svHA1ZawzNM3ZGlL0GvHdhmF0WkpX4E7UdrYlJSLpKcgg5Fo6i7Q== + dependencies: + "@cosmjs/amino" "^0.32.2" + "@cosmjs/crypto" "^0.32.2" + "@cosmjs/encoding" "^0.32.2" + "@cosmjs/math" "^0.32.2" + "@cosmjs/proto-signing" "^0.32.2" + "@cosmjs/stargate" "^0.32.2" + "@cosmjs/tendermint-rpc" "^0.32.2" + "@cosmjs/utils" "^0.32.2" + cosmjs-types "^0.9.0" + pako "^2.0.2" + "@cosmjs/cosmwasm-stargate@^0.32.2": version "0.32.3" resolved "https://registry.yarnpkg.com/@cosmjs/cosmwasm-stargate/-/cosmwasm-stargate-0.32.3.tgz#26a110a6bb0c15fdeef647e3433bd9553a1acd5f" @@ -1933,6 +1949,13 @@ dependencies: "@types/unist" "^2" +"@types/node-gzip@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/node-gzip/-/node-gzip-1.1.0.tgz#99a7dfab7c0eec545658f3d736e8d6939ed7161e" + integrity sha512-j7cGb6HIOZbDx3sqe9/9VAPeSvyt143yu5k35gzRXE3mxEgK6BOZ6BAiJ3ToXBcJqLzL9Cr53dav21jlp3f9gw== + dependencies: + "@types/node" "*" + "@types/node@*", "@types/node@>=13.7.0": version "20.11.5" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.5.tgz#be10c622ca7fcaa3cf226cf80166abc31389d86e" @@ -4668,6 +4691,11 @@ node-gyp-build@^4.2.0: resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.0.tgz#3fee9c1731df4581a3f9ead74664369ff00d26dd" integrity sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og== +node-gzip@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/node-gzip/-/node-gzip-1.1.2.tgz#245bd171b31ce7c7f50fc4cd0ca7195534359afb" + integrity sha512-ZB6zWpfZHGtxZnPMrJSKHVPrRjURoUzaDbLFj3VO70mpLTW5np96vXyHwft4Id0o+PYIzgDkBUjIzaNHhQ8srw== + node-releases@^2.0.14: version "2.0.14" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b"