diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a4a41f2..ddb768b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features - [#108](https://github.com/alleslabs/celatone-frontend/pull/108) Add migrate options on migrate page and upload new code for migration +- [#113](https://github.com/alleslabs/celatone-frontend/pull/113) Update admin page ui and wireup - [#98](https://github.com/alleslabs/celatone-frontend/pull/98) Add migrate, update admin, clear admin menu on contract list and detail - [#121](https://github.com/alleslabs/celatone-frontend/pull/121) Fix code snippet for query axios - [#102](https://github.com/alleslabs/celatone-frontend/pull/102) Add quick menu in overview and add highlighted in left sidebar @@ -109,6 +110,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Bug fixes +- [#124](https://github.com/alleslabs/celatone-frontend/pull/124) Fix public project query, display project image in contract details page - [#125](https://github.com/alleslabs/celatone-frontend/pull/125) Fix incorrect CosmJS execute snippet - [#117](https://github.com/alleslabs/celatone-frontend/pull/117) Fix native token label formatting - [#121](https://github.com/alleslabs/celatone-frontend/pull/121) Fix code snippet for query axios diff --git a/src/env.ts b/src/env.ts index f41c44393..9fda91504 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,4 +1,3 @@ -import { MsgType } from "lib/types"; import type { ContractAddr, ChainGasPrice, Token, U } from "lib/types"; import type { CelatoneConstants, CelatoneContractAddress } from "types"; @@ -49,16 +48,9 @@ export const FALLBACK_LCD_ENDPOINT: Record = { export const MAX_FILE_SIZE = 800_000; -export const MSG_TYPE_URL = { - [MsgType.STORE_CODE]: "/cosmwasm.wasm.v1.MsgStoreCode", - [MsgType.INSTANTIATE]: "/cosmwasm.wasm.v1.MsgInstantiateContract", - [MsgType.EXECUTE]: "/cosmwasm.wasm.v1.MsgExecuteContract", -}; - export const CELATONE_CONSTANTS: CelatoneConstants = { gasAdjustment: 1.6, maxFileSize: MAX_FILE_SIZE, - msgTypeUrl: MSG_TYPE_URL, }; export const DUMMY_MNEMONIC = process.env.NEXT_PUBLIC_DUMMY_MNEMONIC; @@ -83,7 +75,7 @@ export const getChainApiPath = (chainName: string) => { export const getMainnetApiPath = (chainId: string) => { switch (chainId) { case "osmo-test-4": - case "osmosis": + case "osmosis-1": return "osmosis-1"; default: return undefined; diff --git a/src/lib/app-fns/tx/updateAdmin.tsx b/src/lib/app-fns/tx/updateAdmin.tsx new file mode 100644 index 000000000..103c8342e --- /dev/null +++ b/src/lib/app-fns/tx/updateAdmin.tsx @@ -0,0 +1,74 @@ +import { Icon } from "@chakra-ui/react"; +import type { + ExecuteResult, + SigningCosmWasmClient, +} from "@cosmjs/cosmwasm-stargate"; +import type { StdFee } from "@cosmjs/stargate"; +import { pipe } from "@rx-stream/pipe"; +import { MdCheckCircle } from "react-icons/md"; +import type { Observable } from "rxjs"; + +import { ExplorerLink } from "lib/components/ExplorerLink"; +import type { ContractAddr, HumanAddr, TxResultRendering } from "lib/types"; +import { TxStreamPhase } from "lib/types"; +import { formatUFee } from "lib/utils"; + +import { catchTxError, postTx, sendingTx } from "./common"; + +interface UpdateAdminTxParams { + address: HumanAddr; + contractAddress: ContractAddr; + newAdmin: HumanAddr | ContractAddr; + fee: StdFee; + client: SigningCosmWasmClient; + onTxSucceed?: () => void; + onTxFailed?: () => void; +} + +export const updateAdminTx = ({ + address, + contractAddress, + newAdmin, + fee, + client, + onTxSucceed, + onTxFailed, +}: UpdateAdminTxParams): Observable => { + return pipe( + sendingTx(fee), + postTx({ + postFn: () => + client.updateAdmin(address, contractAddress, newAdmin, fee, undefined), + }), + ({ value: txInfo }) => { + onTxSucceed?.(); + return { + value: null, + phase: TxStreamPhase.SUCCEED, + receipts: [ + { + title: "Tx Hash", + value: txInfo.transactionHash, + html: ( + + ), + }, + { + title: "Tx Fee", + value: `${formatUFee( + txInfo.events.find((e) => e.type === "tx")?.attributes[0].value ?? + "0u" + )}`, + }, + ], + receiptInfo: { + header: "Update Admin Complete", + headerIcon: ( + + ), + }, + actionVariant: "update-admin", + } as TxResultRendering; + } + )().pipe(catchTxError(onTxFailed)); +}; diff --git a/src/lib/app-provider/tx/index.ts b/src/lib/app-provider/tx/index.ts index de35b0c2e..7a9604131 100644 --- a/src/lib/app-provider/tx/index.ts +++ b/src/lib/app-provider/tx/index.ts @@ -1,5 +1,6 @@ export * from "./upload"; -export * from "./resend"; -export * from "./clearAdmin"; export * from "./execute"; export * from "./instantiate"; +export * from "./resend"; +export * from "./updateAdmin"; +export * from "./clearAdmin"; diff --git a/src/lib/app-provider/tx/updateAdmin.ts b/src/lib/app-provider/tx/updateAdmin.ts new file mode 100644 index 000000000..bb9ee397e --- /dev/null +++ b/src/lib/app-provider/tx/updateAdmin.ts @@ -0,0 +1,44 @@ +import type { StdFee } from "@cosmjs/stargate"; +import { useWallet } from "@cosmos-kit/react"; +import { useCallback } from "react"; + +import { updateAdminTx } from "lib/app-fns/tx/updateAdmin"; +import type { ContractAddr, HumanAddr, Option } from "lib/types"; + +export interface UpdateAdminStreamParams { + contractAddress: ContractAddr; + newAdmin: HumanAddr | ContractAddr; + estimatedFee: Option; + onTxSucceed?: () => void; + onTxFailed?: () => void; +} + +export const useUpdateAdminTx = () => { + const { address, getCosmWasmClient } = useWallet(); + + return useCallback( + async ({ + contractAddress, + newAdmin, + estimatedFee, + onTxSucceed, + onTxFailed, + }: UpdateAdminStreamParams) => { + const client = await getCosmWasmClient(); + if (!address || !client) + throw new Error("Please check your wallet connection."); + if (!estimatedFee) return null; + + return updateAdminTx({ + address: address as HumanAddr, + contractAddress, + newAdmin, + fee: estimatedFee, + client, + onTxSucceed, + onTxFailed, + }); + }, + [address, getCosmWasmClient] + ); +}; diff --git a/src/lib/components/ErrorMessageRender.tsx b/src/lib/components/ErrorMessageRender.tsx new file mode 100644 index 000000000..595a28f22 --- /dev/null +++ b/src/lib/components/ErrorMessageRender.tsx @@ -0,0 +1,21 @@ +import type { FlexProps } from "@chakra-ui/react"; +import { Flex, Icon, Text } from "@chakra-ui/react"; +import { IoIosWarning } from "react-icons/io"; + +interface ErrorMessageRenderProps extends FlexProps { + error: string; +} + +export const ErrorMessageRender = ({ + error, + ...restProps +}: ErrorMessageRenderProps) => { + return ( + + + + {error} + + + ); +}; diff --git a/src/lib/components/forms/TextInput.tsx b/src/lib/components/forms/TextInput.tsx index 6c06d6b13..1afd9c261 100644 --- a/src/lib/components/forms/TextInput.tsx +++ b/src/lib/components/forms/TextInput.tsx @@ -64,7 +64,7 @@ export const TextInput = ({ maxLength={maxLength} /> - {status && getStatusIcon(status.state)} + {status && getStatusIcon(status.state, "20px")} diff --git a/src/lib/components/modal/tx/ButtonSection.tsx b/src/lib/components/modal/tx/ButtonSection.tsx index 909a4d1c6..95fa8f0d5 100644 --- a/src/lib/components/modal/tx/ButtonSection.tsx +++ b/src/lib/components/modal/tx/ButtonSection.tsx @@ -87,6 +87,25 @@ export const ButtonSection = ({ Proceed to Migrate ); + case "update-admin": + return ( + <> + + + + ); case "rejected": case "resend": return ( diff --git a/src/lib/data/constant.ts b/src/lib/data/constant.ts index bbdc666c3..bded9f2fa 100644 --- a/src/lib/data/constant.ts +++ b/src/lib/data/constant.ts @@ -64,6 +64,7 @@ export const typeUrlDict = { [MsgType.STORE_CODE]: "/cosmwasm.wasm.v1.MsgStoreCode", [MsgType.INSTANTIATE]: "/cosmwasm.wasm.v1.MsgInstantiateContract", [MsgType.EXECUTE]: "/cosmwasm.wasm.v1.MsgExecuteContract", + [MsgType.UPDATE_ADMIN]: "/cosmwasm.wasm.v1.MsgUpdateAdmin", }; export const DEFAULT_RPC_ERROR = "Invalid format, or Something went wrong"; diff --git a/src/lib/model/contract.ts b/src/lib/model/contract.ts index b7256361d..773eaf1a2 100644 --- a/src/lib/model/contract.ts +++ b/src/lib/model/contract.ts @@ -5,9 +5,8 @@ import { useCelatoneApp } from "lib/app-provider"; import { INSTANTIATED_LIST_NAME } from "lib/data"; import { useCodeStore, useContractStore, useLCDEndpoint } from "lib/hooks"; import { useAssetInfos } from "lib/services/assetService"; -import type { InstantiateInfo, PublicInfo } from "lib/services/contract"; +import type { InstantiateInfo } from "lib/services/contract"; import { - queryPublicInfo, queryContractBalances, queryInstantiateInfo, } from "lib/services/contract"; @@ -20,13 +19,19 @@ import { useTxsCountByContractAddress, useRelatedProposalsCountByContractAddress, } from "lib/services/contractService"; +import { + usePublicProjectByContractAddress, + usePublicProjectBySlug, +} from "lib/services/publicProjectService"; import type { CodeLocalInfo } from "lib/stores/code"; import type { ContractLocalInfo, ContractListInfo } from "lib/stores/contract"; import type { BalanceWithAssetInfo, ContractAddr, + Detail, HumanAddr, Option, + PublicInfo, } from "lib/types"; import { formatSlugName } from "lib/utils"; @@ -35,7 +40,10 @@ export interface ContractData { codeInfo: Option; contractLocalInfo: Option; instantiateInfo: Option; - publicInfo: Option; + publicProject: { + publicInfo: Option; + publicDetail: Option; + }; balances: Option; initMsg: string; initTxHash: Option; @@ -87,13 +95,16 @@ export const useInstantiatedMockInfoByMe = (): ContractListInfo => { export const useContractData = ( contractAddress: ContractAddr -): ContractData | undefined => { +): Option => { const { indexerGraphClient } = useCelatoneApp(); const { currentChainRecord } = useWallet(); const { getCodeLocalInfo } = useCodeStore(); const { getContractLocalInfo } = useContractStore(); const endpoint = useLCDEndpoint(); const assetInfos = useAssetInfos(); + const { data: publicInfo } = + usePublicProjectByContractAddress(contractAddress); + const { data: publicInfoBySlug } = usePublicProjectBySlug(publicInfo?.slug); const { data: instantiateInfo } = useQuery( ["query", "instantiateInfo", endpoint, contractAddress], @@ -126,17 +137,6 @@ export const useContractData = ( return -1; }); - const { data: publicInfo } = useQuery( - ["query", "publicInfo", contractAddress], - async () => - queryPublicInfo( - currentChainRecord?.name, - currentChainRecord?.chain.chain_id, - contractAddress - ), - { enabled: !!currentChainRecord } - ); - const codeInfo = instantiateInfo ? getCodeLocalInfo(Number(instantiateInfo.codeId)) : undefined; @@ -155,7 +155,10 @@ export const useContractData = ( codeInfo, contractLocalInfo, instantiateInfo, - publicInfo, + publicProject: { + publicInfo, + publicDetail: publicInfoBySlug?.details, + }, balances: contractBalancesWithAssetInfos, initMsg: instantiateDetail.initMsg, initTxHash: instantiateDetail.initTxHash, diff --git a/src/lib/pages/admin/index.tsx b/src/lib/pages/admin/index.tsx new file mode 100644 index 000000000..d50f17ba6 --- /dev/null +++ b/src/lib/pages/admin/index.tsx @@ -0,0 +1,215 @@ +import { Button, Flex, Heading } from "@chakra-ui/react"; +import type { StdFee } from "@cosmjs/stargate"; +import { useWallet } from "@cosmos-kit/react"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useState } from "react"; + +import { + useCelatoneApp, + useFabricateFee, + useInternalNavigate, + useSimulateFeeQuery, + useUpdateAdminTx, +} from "lib/app-provider"; +import { ConnectWalletAlert } from "lib/components/ConnectWalletAlert"; +import { ContractSelectSection } from "lib/components/ContractSelectSection"; +import { ErrorMessageRender } from "lib/components/ErrorMessageRender"; +import { EstimatedFeeRender } from "lib/components/EstimatedFeeRender"; +import type { FormStatus } from "lib/components/forms"; +import { TextInput } from "lib/components/forms"; +import WasmPageContainer from "lib/components/WasmPageContainer"; +import { + useLCDEndpoint, + useGetAddressType, + useValidateAddress, +} from "lib/hooks"; +import { useTxBroadcast } from "lib/providers/tx-broadcast"; +import { queryInstantiateInfo } from "lib/services/contract"; +import type { ContractAddr, HumanAddr } from "lib/types"; +import { MsgType } from "lib/types"; +import { composeMsg, getFirstQueryParam } from "lib/utils"; + +const UpdateAdmin = () => { + const router = useRouter(); + const { address } = useWallet(); + const { validateContractAddress, validateUserAddress } = useValidateAddress(); + const getAddressType = useGetAddressType(); + const navigate = useInternalNavigate(); + const fabricateFee = useFabricateFee(); + const updateAdminTx = useUpdateAdminTx(); + const { broadcast } = useTxBroadcast(); + const endpoint = useLCDEndpoint(); + const { indexerGraphClient } = useCelatoneApp(); + + const [adminAddress, setAdminAddress] = useState(""); + const [adminFormStatus, setAdminFormStatus] = useState({ + state: "init", + message: "", + }); + const [estimatedFee, setEstimatedFee] = useState(); + const [simulateError, setSimulateError] = useState(); + + const contractAddressParam = getFirstQueryParam( + router.query.contract + ) as ContractAddr; + + const onContractPathChange = useCallback( + (contract?: ContractAddr) => { + navigate({ + pathname: "/admin", + query: { ...(contract && { contract }) }, + options: { shallow: true }, + }); + }, + [navigate] + ); + + const { isFetching } = useSimulateFeeQuery({ + enabled: + !!address && + !!contractAddressParam && + adminFormStatus.state === "success", + messages: [ + composeMsg(MsgType.UPDATE_ADMIN, { + sender: address as HumanAddr, + newAdmin: adminAddress as HumanAddr | ContractAddr, + contract: contractAddressParam, + }), + ], + onSuccess: (fee) => { + if (fee) { + setSimulateError(undefined); + setEstimatedFee(fabricateFee(fee)); + } else setEstimatedFee(undefined); + }, + onError: (e) => { + setSimulateError(e.message); + setEstimatedFee(undefined); + }, + }); + + const proceed = useCallback(async () => { + const stream = await updateAdminTx({ + contractAddress: contractAddressParam, + newAdmin: adminAddress as HumanAddr | ContractAddr, + estimatedFee, + }); + + if (stream) broadcast(stream); + }, [ + adminAddress, + contractAddressParam, + updateAdminTx, + broadcast, + estimatedFee, + ]); + + /** + * @remarks Contract admin validation + */ + useQuery( + ["query", "instantiateInfo", endpoint, contractAddressParam], + async () => + queryInstantiateInfo(endpoint, indexerGraphClient, contractAddressParam), + { + enabled: !!contractAddressParam, + refetchOnWindowFocus: false, + retry: 0, + onSuccess: (contractInfo) => { + if (contractInfo.admin !== address) onContractPathChange(); + }, + onError: onContractPathChange, + } + ); + + useEffect(() => { + if (contractAddressParam && validateContractAddress(contractAddressParam)) { + onContractPathChange(); + } + }, [contractAddressParam, onContractPathChange, validateContractAddress]); + + /** + * @remarks Admin address input validation + */ + useEffect(() => { + if (!adminAddress) setAdminFormStatus({ state: "init" }); + else { + const addressType = getAddressType(adminAddress); + if (addressType === "invalid_address") { + setAdminFormStatus({ + state: "error", + message: "Invalid address length", + }); + } else { + const validateResult = + addressType === "user_address" + ? validateUserAddress(adminAddress) + : validateContractAddress(adminAddress); + if (validateResult) { + setAdminFormStatus({ state: "error", message: validateResult }); + } else { + setAdminFormStatus({ state: "success" }); + } + } + } + }, [ + adminAddress, + getAddressType, + validateContractAddress, + validateUserAddress, + ]); + + return ( + + + Update Admin + + + onContractPathChange(contract)} + /> + + +

Transaction Fee:

+ +
+ {simulateError && ( + + )} + +
+ ); +}; + +export default UpdateAdmin; diff --git a/src/lib/pages/contract-details/components/ContractTop.tsx b/src/lib/pages/contract-details/components/ContractTop.tsx index dab7a4dc7..0e564858f 100644 --- a/src/lib/pages/contract-details/components/ContractTop.tsx +++ b/src/lib/pages/contract-details/components/ContractTop.tsx @@ -6,7 +6,9 @@ import { Button, Icon, IconButton, + Image, } from "@chakra-ui/react"; +import router from "next/router"; import { MdBookmark, MdBookmarkBorder, MdInput } from "react-icons/md"; import { RiPencilFill } from "react-icons/ri"; @@ -20,18 +22,20 @@ import { } from "lib/components/modal"; import type { ContractData } from "lib/model/contract"; import type { ContractAddr } from "lib/types"; +import { getFirstQueryParam } from "lib/utils"; interface ContractTopProps { contractData: ContractData; } export const ContractTop = ({ contractData }: ContractTopProps) => { const navigate = useInternalNavigate(); + const { contractLocalInfo, instantiateInfo, publicProject } = contractData; + const contractAddress = getFirstQueryParam(router.query.contractAddress); - const { contractLocalInfo, instantiateInfo, publicInfo } = contractData; - - const contractAddress = instantiateInfo?.contractAddress as ContractAddr; const displayName = - contractLocalInfo?.name || publicInfo?.name || instantiateInfo?.label; + contractLocalInfo?.name || + publicProject.publicInfo?.name || + instantiateInfo?.label; const goToQuery = () => { navigate({ @@ -70,7 +74,7 @@ export const ContractTop = ({ contractData }: ContractTopProps) => { return ( { return ( - - {displayName} - + + {publicProject.publicDetail?.logo && ( + {publicProject.publicDetail.name} + )} + + {displayName} + + { {contractData.instantiateInfo?.label} - {publicInfo?.name && ( + {publicProject.publicInfo?.name && ( Public Contract Name: - {publicInfo?.name} + {publicProject.publicInfo?.name} )}