diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb768b4a..b699481c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +- [#120](https://github.com/alleslabs/celatone-frontend/pull/120) Add simulate migrate fee and the final migration step - [#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 diff --git a/src/lib/app-fns/tx/migrate.tsx b/src/lib/app-fns/tx/migrate.tsx new file mode 100644 index 000000000..13e0ef017 --- /dev/null +++ b/src/lib/app-fns/tx/migrate.tsx @@ -0,0 +1,85 @@ +import { Icon } from "@chakra-ui/react"; +import type { + MigrateResult, + 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, TxResultRendering } from "lib/types"; +import { TxStreamPhase } from "lib/types"; +import { formatUFee } from "lib/utils"; + +import { catchTxError } from "./common/catchTxError"; +import { postTx } from "./common/post"; +import { sendingTx } from "./common/sending"; + +interface MigrateTxParams { + sender: string; + contractAddress: ContractAddr; + codeId: number; + migrateMsg: object; + fee: StdFee; + client: SigningCosmWasmClient; + onTxSucceed?: (txHash: string) => void; + onTxFailed?: () => void; +} + +export const migrateContractTx = ({ + sender, + contractAddress, + codeId, + migrateMsg, + fee, + client, + onTxSucceed, + onTxFailed, +}: MigrateTxParams): Observable => { + return pipe( + sendingTx(fee), + postTx({ + postFn: () => + client.migrate( + sender, + contractAddress, + codeId, + migrateMsg, + fee, + undefined + ), + }), + ({ value: txInfo }) => { + onTxSucceed?.(txInfo.transactionHash); + 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: "Migration Completed", + headerIcon: ( + + ), + }, + actionVariant: "migrate", + } as TxResultRendering; + } + )().pipe(catchTxError(onTxFailed)); +}; diff --git a/src/lib/app-provider/tx/migrate.ts b/src/lib/app-provider/tx/migrate.ts new file mode 100644 index 000000000..0ddcb3fec --- /dev/null +++ b/src/lib/app-provider/tx/migrate.ts @@ -0,0 +1,47 @@ +import type { StdFee } from "@cosmjs/stargate"; +import { useWallet } from "@cosmos-kit/react"; +import { useCallback } from "react"; + +import { migrateContractTx } from "lib/app-fns/tx/migrate"; +import type { ContractAddr, Option } from "lib/types"; + +export interface MigrateStreamParams { + contractAddress: ContractAddr; + codeId: number; + migrateMsg: object; + estimatedFee: Option; + onTxSucceed?: (txHash: string) => void; + onTxFailed?: () => void; +} + +export const useMigrateTx = () => { + const { address, getCosmWasmClient } = useWallet(); + + return useCallback( + async ({ + contractAddress, + codeId, + migrateMsg, + estimatedFee, + onTxSucceed, + onTxFailed, + }: MigrateStreamParams) => { + const client = await getCosmWasmClient(); + if (!address || !client) + throw new Error("Please check your wallet connection."); + if (!estimatedFee) return null; + + return migrateContractTx({ + sender: address, + contractAddress, + codeId, + migrateMsg, + fee: estimatedFee, + client, + onTxSucceed, + onTxFailed, + }); + }, + [address, getCosmWasmClient] + ); +}; diff --git a/src/lib/components/CodeSelectSection.tsx b/src/lib/components/CodeSelectSection.tsx new file mode 100644 index 000000000..f72a5a7bd --- /dev/null +++ b/src/lib/components/CodeSelectSection.tsx @@ -0,0 +1,76 @@ +import { Flex, Radio, RadioGroup } from "@chakra-ui/react"; +import { useState } from "react"; +import type { Control, FieldPath, FieldValues } from "react-hook-form"; + +import { CodeSelect } from "lib/pages/instantiate/component"; +import type { Option } from "lib/types"; + +import type { FormStatus } from "./forms"; +import { ControllerInput } from "./forms"; + +interface CodeSelectSectionProps { + codeId: string; + name: FieldPath; + control: Control; + error: Option; + onCodeSelect: (codeId: string) => void; + status: FormStatus; +} + +export const CodeSelectSection = ({ + codeId, + name, + control, + error, + onCodeSelect, + status, +}: CodeSelectSectionProps) => { + const [method, setMethod] = useState<"select-existing" | "fill-manually">( + "select-existing" + ); + + return ( + <> + + setMethod(nextVal) + } + value={method} + w="100%" + > + + + Select from your code + + + Fill Code ID manually + + + +
+ {method === "select-existing" ? ( + + ) : ( + + )} + + + ); +}; diff --git a/src/lib/components/forms/ControllerInput.tsx b/src/lib/components/forms/ControllerInput.tsx index 20727577b..f6197a778 100644 --- a/src/lib/components/forms/ControllerInput.tsx +++ b/src/lib/components/forms/ControllerInput.tsx @@ -4,6 +4,8 @@ import { FormHelperText, FormLabel, Input, + InputGroup, + InputRightElement, Text, } from "@chakra-ui/react"; import type { @@ -15,7 +17,7 @@ import type { import { useWatch, useController } from "react-hook-form"; import type { FormStatus } from "./FormStatus"; -import { getResponseMsg } from "./FormStatus"; +import { getStatusIcon, getResponseMsg } from "./FormStatus"; import type { TextInputProps } from "./TextInput"; interface ControllerInputProps @@ -58,7 +60,7 @@ export const ControllerInput = ({ return ( ({ {label} )} - - {/* TODO: add status */} + + + + {status && getStatusIcon(status.state)} + + {isError ? ( {error} ) : ( diff --git a/src/lib/components/modal/tx/ButtonSection.tsx b/src/lib/components/modal/tx/ButtonSection.tsx index 95fa8f0d5..5319d207c 100644 --- a/src/lib/components/modal/tx/ButtonSection.tsx +++ b/src/lib/components/modal/tx/ButtonSection.tsx @@ -87,6 +87,7 @@ export const ButtonSection = ({ Proceed to Migrate ); + case "migrate": case "update-admin": return ( <> diff --git a/src/lib/data/constant.ts b/src/lib/data/constant.ts index bded9f2fa..bbdb2c568 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.MIGRATE]: "/cosmwasm.wasm.v1.MsgMigrateContract", [MsgType.UPDATE_ADMIN]: "/cosmwasm.wasm.v1.MsgUpdateAdmin", }; diff --git a/src/lib/pages/instantiate/component/code-select/CodeSelect.tsx b/src/lib/pages/instantiate/component/code-select/CodeSelect.tsx index 7114012c9..73e7e2f7b 100644 --- a/src/lib/pages/instantiate/component/code-select/CodeSelect.tsx +++ b/src/lib/pages/instantiate/component/code-select/CodeSelect.tsx @@ -1,6 +1,7 @@ import type { FlexProps } from "@chakra-ui/react"; import { Flex, Text } from "@chakra-ui/react"; +import type { FormStatus } from "lib/components/forms"; import { UploadIcon } from "lib/components/icon/UploadIcon"; import { useCodeStore } from "lib/hooks/store"; import { useUserKey } from "lib/hooks/useUserKey"; @@ -10,53 +11,64 @@ import { CodeSelectModalButton } from "./CodeSelectModalButton"; interface CodeSelectProps extends Omit { onCodeSelect: (code: string) => void; codeId: string; + status: FormStatus; } export const CodeSelect = ({ onCodeSelect, codeId, + status, ...componentProps }: CodeSelectProps) => { const { codeInfo } = useCodeStore(); const userKey = useUserKey(); const description = codeInfo?.[userKey]?.[Number(codeId)]?.description; + + const isError = status.state === "error"; return ( - - - {codeId ? ( - - - {description ?? "No description"} - - - Code ID {codeId} + + + + {codeId ? ( + + + {description ?? "No description"} + + + Code ID {codeId} + + + ) : ( + + Please select code - - ) : ( - - Please select code + )} + + + {isError && ( + + {status.message} )} - - ); }; diff --git a/src/lib/pages/instantiate/instantiate.tsx b/src/lib/pages/instantiate/instantiate.tsx index bed800ddb..3fbc5e277 100644 --- a/src/lib/pages/instantiate/instantiate.tsx +++ b/src/lib/pages/instantiate/instantiate.tsx @@ -1,13 +1,7 @@ -import { - Button, - Flex, - Heading, - Radio, - RadioGroup, - Text, -} from "@chakra-ui/react"; +import { Button, Heading, Text } from "@chakra-ui/react"; import type { InstantiateResult } from "@cosmjs/cosmwasm-stargate"; import { useWallet } from "@cosmos-kit/react"; +import { useQuery } from "@tanstack/react-query"; import Long from "long"; import { useRouter } from "next/router"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -19,13 +13,17 @@ import { useSimulateFee, useInstantiateTx, } from "lib/app-provider"; +import { CodeSelectSection } from "lib/components/CodeSelectSection"; import { ConnectWalletAlert } from "lib/components/ConnectWalletAlert"; +import type { FormStatus } from "lib/components/forms"; import { ControllerInput } from "lib/components/forms"; import { AssetInput } from "lib/components/forms/AssetInput"; import JsonInput from "lib/components/json/JsonInput"; import { Stepper } from "lib/components/stepper"; import WasmPageContainer from "lib/components/WasmPageContainer"; +import { useLCDEndpoint } from "lib/hooks"; import { useTxBroadcast } from "lib/providers/tx-broadcast"; +import { getCodeIdInfo } from "lib/services/code"; import type { HumanAddr, Token, U } from "lib/types"; import { MsgType } from "lib/types"; import { @@ -36,7 +34,7 @@ import { microfy, } from "lib/utils"; -import { CodeSelect, FailedModal, Footer } from "./component"; +import { FailedModal, Footer } from "./component"; import type { InstantiateRedoMsg } from "./types"; interface InstantiatePageProps { @@ -51,6 +49,7 @@ const Instantiate = ({ onComplete }: InstantiatePageProps) => { const msgQuery = (router.query.msg as string) ?? ""; const codeIdQuery = (router.query["code-id"] as string) ?? ""; const { address = "" } = useWallet(); + const endpoint = useLCDEndpoint(); const postInstantiateTx = useInstantiateTx(); const { simulate } = useSimulateFee(); const fabricateFee = useFabricateFee(); @@ -60,9 +59,6 @@ const Instantiate = ({ onComplete }: InstantiatePageProps) => { // ------------------------------------------// // ------------------STATES------------------// // ------------------------------------------// - const [method, setMethod] = useState<"select-existing" | "fill-manually">( - "select-existing" - ); const [simulating, setSimulating] = useState(false); // ------------------------------------------// @@ -96,14 +92,19 @@ const Instantiate = ({ onComplete }: InstantiatePageProps) => { initMsg: watchInitMsg, simulateError, } = watch(); + const [status, setStatus] = useState({ state: "init" }); const selectedAssets = watchAssets.map((asset) => asset.denom); const disableInstantiate = useMemo(() => { return ( - !codeId || !address || !!jsonValidate(watchInitMsg) || !!formErrors.label + !codeId || + !address || + !!jsonValidate(watchInitMsg) || + !!formErrors.label || + status.state !== "success" ); - }, [codeId, address, watchInitMsg, formErrors.label]); + }, [codeId, address, watchInitMsg, formErrors.label, status.state]); const assetOptions = useMemo( () => @@ -115,6 +116,35 @@ const Instantiate = ({ onComplete }: InstantiatePageProps) => { [nativeTokensInfo, selectedAssets] ); + const { refetch } = useQuery( + ["query", endpoint, codeId], + async () => getCodeIdInfo(endpoint, Number(codeId)), + { + enabled: !!address && !!codeId.length, + retry: false, + cacheTime: 0, + onSuccess(data) { + const permission = data.code_info.instantiate_permission; + if ( + address && + (permission.permission === "Everybody" || + permission.addresses.includes(address) || + permission.address === address) + ) + setStatus({ state: "success" }); + else { + setStatus({ + state: "error", + message: "You can instantiate to this code through proposal only", + }); + } + }, + onError() { + setStatus({ state: "error", message: "This code ID does not exist" }); + }, + } + ); + // ------------------------------------------// // ----------------FUNCTIONS-----------------// // ------------------------------------------// @@ -169,6 +199,19 @@ const Instantiate = ({ onComplete }: InstantiatePageProps) => { // ------------------------------------------// // --------------SIDE EFFECTS----------------// // ------------------------------------------// + useEffect(() => { + if (codeId.length) { + setStatus({ state: "loading" }); + const timer = setTimeout(() => { + refetch(); + }, 500); + return () => clearTimeout(timer); + } + setStatus({ state: "init" }); + + return () => {}; + }, [address, codeId, refetch]); + useEffect(() => { if (codeIdQuery) setValue("codeId", codeIdQuery); if (msgQuery) { @@ -209,42 +252,15 @@ const Instantiate = ({ onComplete }: InstantiatePageProps) => { subtitle="You need to connect your wallet to perform this action" mb={6} /> - - setMethod(nextVal) - } - value={method} - w="100%" - > - - - Select from your code - - - Fill Code ID manually - - - + setValue("codeId", code)} + codeId={codeId} + />
- {method === "select-existing" ? ( - setValue("codeId", code)} - codeId={codeId} - /> - ) : ( - - )} ; + codeIdParam: string; handleBack: () => void; } export const MigrateContract = ({ - isAdmin, contractAddress, - codeId, + codeIdParam, handleBack, }: MigrateContractProps) => { + const { address } = useWallet(); + const { broadcast } = useTxBroadcast(); + const endpoint = useLCDEndpoint(); + const migrateTx = useMigrateTx(); + const fabricateFee = useFabricateFee(); + + const { + control, + watch, + setValue, + formState: { errors }, + } = useForm({ + defaultValues: { codeId: codeIdParam, migrateMsg: "{}" }, + mode: "all", + }); + const { codeId, migrateMsg } = watch(); + const [status, setStatus] = useState({ state: "init" }); + const [composedTxMsg, setComposedTxMsg] = useState([]); + const [estimatedFee, setEstimatedFee] = useState(); + const [simulateError, setSimulateError] = useState(""); + const [processing, setProcessing] = useState(false); + + const enableMigrate = !!( + address && + codeId.length && + migrateMsg.trim().length && + jsonValidate(migrateMsg) === null && + status.state === "success" + ); + + const { isFetching: isSimulating } = useSimulateFeeQuery({ + enabled: composedTxMsg.length > 0, + messages: composedTxMsg, + onSuccess: (gasRes) => { + if (gasRes) setEstimatedFee(fabricateFee(gasRes)); + else setEstimatedFee(undefined); + }, + onError: (e) => { + setSimulateError(e.message); + setEstimatedFee(undefined); + }, + }); + + const { refetch } = useQuery( + ["query", endpoint, codeId], + async () => getCodeIdInfo(endpoint, Number(codeId)), + { + enabled: !!address && !!codeId.length, + retry: false, + cacheTime: 0, + onSuccess(data) { + const permission = data.code_info.instantiate_permission; + if ( + address && + (permission.permission === "Everybody" || + permission.addresses.includes(address) || + permission.address === address) + ) + setStatus({ state: "success" }); + else { + setStatus({ + state: "error", + message: "You can migrate to this code through proposal only", + }); + setSimulateError(""); + } + }, + onError() { + setStatus({ state: "error", message: "This code ID does not exist" }); + setSimulateError(""); + }, + } + ); + + const proceed = useCallback(async () => { + const stream = await migrateTx({ + contractAddress, + codeId: Number(codeId), + migrateMsg: JSON.parse(migrateMsg), + estimatedFee, + onTxSucceed: () => setProcessing(false), + onTxFailed: () => setProcessing(false), + }); + + if (stream) { + setProcessing(true); + broadcast(stream); + } + }, [migrateTx, contractAddress, codeId, migrateMsg, estimatedFee, broadcast]); + + useEffect(() => { + if (codeId.length) { + setStatus({ state: "loading" }); + const timer = setTimeout(() => { + refetch(); + }, 500); + return () => clearTimeout(timer); + } + setStatus({ state: "init" }); + + return () => {}; + }, [address, codeId, refetch]); + + useEffect(() => { + if (enableMigrate) { + setSimulateError(""); + const composedMsg = composeMsg(MsgType.MIGRATE, { + sender: address as HumanAddr, + contract: contractAddress as ContractAddr, + codeId: Long.fromString(codeId), + msg: Buffer.from(migrateMsg), + }); + const timeoutId = setTimeout(() => { + setComposedTxMsg([composedMsg]); + }, 1000); + return () => clearTimeout(timeoutId); + } + return () => {}; + }, [address, codeId, contractAddress, enableMigrate, migrateMsg]); + return ( <> - {isAdmin} - {contractAddress} - {codeId} + + To Code ID + + setValue("codeId", code)} + codeId={codeId} + /> + + Migrate Message + + setValue("migrateMsg", msg)} + height="120px" + /> + {simulateError && ( + + + + {simulateError} + + + )} + + Transaction Fee:{" "} + + - {/* */} + Migrate + ); diff --git a/src/lib/pages/migrate/index.tsx b/src/lib/pages/migrate/index.tsx index 8a3328073..c73639674 100644 --- a/src/lib/pages/migrate/index.tsx +++ b/src/lib/pages/migrate/index.tsx @@ -24,7 +24,7 @@ const defaultValues: MigratePageState = { migrateStep: "migrate_options", contractAddress: "" as ContractAddr, admin: undefined, - codeId: undefined, + codeId: "", }; const Migrate = () => { @@ -45,7 +45,7 @@ const Migrate = () => { const contractAddressParam = getFirstQueryParam( router.query.contract ) as ContractAddr; - const codeIdParam = Number(getFirstQueryParam(router.query["code-id"])); + const codeIdParam = getFirstQueryParam(router.query["code-id"]); const onContractSelect = useCallback( (contract: ContractAddr) => { @@ -95,9 +95,8 @@ const Migrate = () => { case "migrate_contract": return ( ); diff --git a/src/lib/pages/migrate/types.ts b/src/lib/pages/migrate/types.ts index a8e9d1f9f..0da2018bd 100644 --- a/src/lib/pages/migrate/types.ts +++ b/src/lib/pages/migrate/types.ts @@ -6,5 +6,5 @@ export interface MigratePageState { migrateStep: MigrateStep; contractAddress: ContractAddr; admin: Option; - codeId: Option; + codeId: string; } diff --git a/src/lib/types/tx/model.ts b/src/lib/types/tx/model.ts index 7cba07e14..eee5266f7 100644 --- a/src/lib/types/tx/model.ts +++ b/src/lib/types/tx/model.ts @@ -27,6 +27,7 @@ export type ActionVariant = | "sending" | "upload" | "upload-migrate" + | "migrate" | "rejected" | "resend" | "update-admin"; diff --git a/src/lib/types/tx/msg.ts b/src/lib/types/tx/msg.ts index 4e260d03a..bcfe43902 100644 --- a/src/lib/types/tx/msg.ts +++ b/src/lib/types/tx/msg.ts @@ -6,6 +6,7 @@ export enum MsgType { STORE_CODE = "STORE_CODE", INSTANTIATE = "INSTANTIATE", EXECUTE = "EXECUTE", + MIGRATE = "MIGRATE", UPDATE_ADMIN = "UPDATE_ADMIN", } @@ -44,6 +45,12 @@ export interface MsgExecuteContract { funds: Coin[]; } +export interface MsgMigrateContract { + sender: HumanAddr; + contract: ContractAddr; + codeId: Long; + msg: Uint8Array; +} export interface MsgUpdateAdmin { sender: HumanAddr; newAdmin: HumanAddr | ContractAddr; @@ -54,6 +61,7 @@ export type TxMessage = | MsgStoreCode | MsgInstantiateContract | MsgExecuteContract + | MsgMigrateContract | MsgUpdateAdmin; export interface ComposedMsg {