diff --git a/CHANGELOG.md b/CHANGELOG.md index 925d22c72..e1c277e10 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 +- [#279](https://github.com/alleslabs/celatone-frontend/pull/279) Add instantiate permission to msg store code, change error display design, and upgrade cosmjs to version 0.30.1 - [#268](https://github.com/alleslabs/celatone-frontend/pull/268) Wireup create proposal to whitelisting - [#266](https://github.com/alleslabs/celatone-frontend/pull/250) Add proposal whitelisting page - [#286](https://github.com/alleslabs/celatone-frontend/pull/286) Add block proposer diff --git a/package.json b/package.json index 6c48a0c79..0b76c4393 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,10 @@ "@chakra-ui/icons": "^2.0.11", "@chakra-ui/react": "^2.3.6", "@chakra-ui/styled-system": "^2.3.5", - "@cosmjs/cosmwasm-stargate": "^0.29.3", - "@cosmjs/encoding": "^0.29.5", - "@cosmjs/proto-signing": "^0.29.5", - "@cosmjs/stargate": "^0.29.3", + "@cosmjs/cosmwasm-stargate": "^0.30.1", + "@cosmjs/encoding": "^0.30.1", + "@cosmjs/proto-signing": "^0.30.1", + "@cosmjs/stargate": "^0.30.1", "@cosmos-kit/core": "^0.20.0", "@cosmos-kit/keplr": "^0.20.0", "@cosmos-kit/react": "^0.19.0", diff --git a/src/lib/app-fns/tx/upload.tsx b/src/lib/app-fns/tx/upload.tsx index 899206637..ef6514458 100644 --- a/src/lib/app-fns/tx/upload.tsx +++ b/src/lib/app-fns/tx/upload.tsx @@ -1,8 +1,5 @@ -import type { - SigningCosmWasmClient, - UploadResult, -} from "@cosmjs/cosmwasm-stargate"; -import type { StdFee } from "@cosmjs/stargate"; +import type { SigningCosmWasmClient } from "@cosmjs/cosmwasm-stargate"; +import type { DeliverTxResponse, logs, StdFee } from "@cosmjs/stargate"; import { pipe } from "@rx-stream/pipe"; import type { Observable } from "rxjs"; @@ -10,7 +7,8 @@ import { ExplorerLink } from "lib/components/ExplorerLink"; import { CustomIcon } from "lib/components/icon"; import { AmpEvent, AmpTrack } from "lib/services/amplitude"; import { TxStreamPhase } from "lib/types"; -import type { HumanAddr, TxResultRendering } from "lib/types"; +import type { HumanAddr, TxResultRendering, ComposedMsg } from "lib/types"; +import { findAttr } from "lib/utils"; import { formatUFee } from "lib/utils/formatter/denom"; import { catchTxError } from "./common/catchTxError"; @@ -19,8 +17,8 @@ import { sendingTx } from "./common/sending"; interface UploadTxParams { address: HumanAddr; - codeDesc: string; - wasmCode: Uint8Array; + codeName: string; + messages: ComposedMsg[]; wasmFileName: string; fee: StdFee; memo?: string; @@ -31,8 +29,8 @@ interface UploadTxParams { export const uploadContractTx = ({ address, - codeDesc, - wasmCode, + codeName, + messages, wasmFileName, fee, memo, @@ -42,12 +40,20 @@ export const uploadContractTx = ({ }: UploadTxParams): Observable => { return pipe( sendingTx(fee), - postTx({ - postFn: () => client.upload(address, wasmCode, fee, memo), + postTx({ + postFn: () => client.signAndBroadcast(address, messages, fee, memo), }), ({ value: txInfo }) => { AmpTrack(AmpEvent.TX_SUCCEED); - onTxSucceed?.(txInfo.codeId); + const mimicLog: logs.Log = { + msg_index: 0, + log: "", + events: txInfo.events, + }; + + const codeId = findAttr(mimicLog, "store_code", "code_id") ?? "0"; + + onTxSucceed?.(parseInt(codeId, 10)); const txFee = txInfo.events.find((e) => e.type === "tx")?.attributes[0] .value; return { @@ -56,10 +62,10 @@ export const uploadContractTx = ({ receipts: [ { title: "Code ID", - value: txInfo.codeId, + value: codeId, html: (
- +
), }, @@ -80,7 +86,7 @@ export const uploadContractTx = ({ description: ( <> - ‘{codeDesc || `${wasmFileName}(${txInfo.codeId})`}’ + ‘{codeName || `${wasmFileName}(${codeId})`}’ {" "} is has been uploaded. Would you like to{" "} {isMigrate ? "migrate" : "instantiate"} your code now? diff --git a/src/lib/app-provider/queries/simulateFee.ts b/src/lib/app-provider/queries/simulateFee.ts index a7980b551..4fb1dbcb4 100644 --- a/src/lib/app-provider/queries/simulateFee.ts +++ b/src/lib/app-provider/queries/simulateFee.ts @@ -3,7 +3,15 @@ import { useWallet } from "@cosmos-kit/react"; import { useQuery } from "@tanstack/react-query"; import { useDummyWallet } from "../hooks"; -import type { ComposedMsg, Gas } from "lib/types"; +import type { + AccessType, + Addr, + ComposedMsg, + Gas, + HumanAddr, + Option, +} from "lib/types"; +import { composeStoreCodeMsg } from "lib/utils"; interface SimulateQueryParams { enabled: boolean; @@ -59,3 +67,58 @@ export const useSimulateFeeQuery = ({ onError, }); }; + +interface SimulateQueryParamsForStoreCode { + enabled: boolean; + wasmFile: Option; + permission: AccessType; + addresses: Addr[]; + onSuccess?: (gas: Gas | undefined) => void; + onError?: (err: Error) => void; +} + +export const useSimulateFeeForStoreCode = ({ + enabled, + wasmFile, + permission, + addresses, + onSuccess, + onError, +}: SimulateQueryParamsForStoreCode) => { + const { address, getCosmWasmClient, currentChainName } = useWallet(); + + const simulateFn = async () => { + if (!address) throw new Error("Please check your wallet connection."); + if (!wasmFile) throw new Error("Fail to get Wasm file"); + + const client = await getCosmWasmClient(); + if (!client) throw new Error("Fail to get client"); + + const submitStoreCodeProposalMsg = async () => { + return composeStoreCodeMsg({ + sender: address as HumanAddr, + wasmByteCode: new Uint8Array(await wasmFile.arrayBuffer()), + permission, + addresses, + }); + }; + const craftMsg = await submitStoreCodeProposalMsg(); + return (await client.simulate(address, [craftMsg], undefined)) as Gas; + }; + return useQuery({ + queryKey: [ + "simulate_fee_store_code", + currentChainName, + wasmFile, + permission, + addresses, + ], + queryFn: async () => simulateFn(), + enabled, + retry: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + onSuccess, + onError, + }); +}; diff --git a/src/lib/app-provider/tx/upload.ts b/src/lib/app-provider/tx/upload.ts index 99dc592a9..206b93bd1 100644 --- a/src/lib/app-provider/tx/upload.ts +++ b/src/lib/app-provider/tx/upload.ts @@ -3,12 +3,15 @@ import { useWallet } from "@cosmos-kit/react"; import { useCallback } from "react"; import { uploadContractTx } from "lib/app-fns/tx/upload"; -import type { HumanAddr, Option } from "lib/types"; +import type { AccessType, Addr, HumanAddr, Option } from "lib/types"; +import { composeStoreCodeMsg } from "lib/utils"; export interface UploadStreamParams { wasmFileName: Option; wasmCode: Option>; - codeDesc: string; + addresses: Addr[]; + permission: AccessType; + codeName: string; estimatedFee: Option; onTxSucceed?: (codeId: number) => void; } @@ -20,7 +23,9 @@ export const useUploadContractTx = (isMigrate: boolean) => { async ({ wasmFileName, wasmCode, - codeDesc, + addresses, + permission, + codeName, estimatedFee, onTxSucceed, }: UploadStreamParams) => { @@ -29,10 +34,17 @@ export const useUploadContractTx = (isMigrate: boolean) => { throw new Error("Please check your wallet connection."); if (!wasmFileName || !wasmCode || !estimatedFee) return null; + const message = composeStoreCodeMsg({ + sender: address as Addr, + wasmByteCode: new Uint8Array(await wasmCode), + permission, + addresses, + }); + return uploadContractTx({ address: address as HumanAddr, - wasmCode: new Uint8Array(await wasmCode), - codeDesc, + messages: [message], + codeName, wasmFileName, fee: estimatedFee, client, diff --git a/src/lib/components/upload/InstantiatePermissionRadio.tsx b/src/lib/components/upload/InstantiatePermissionRadio.tsx new file mode 100644 index 000000000..b7ddbd780 --- /dev/null +++ b/src/lib/components/upload/InstantiatePermissionRadio.tsx @@ -0,0 +1,147 @@ +import { Text, Box, Radio, RadioGroup, Button, Flex } from "@chakra-ui/react"; +import { useWallet } from "@cosmos-kit/react"; +import type { Control, UseFormSetValue, UseFormTrigger } from "react-hook-form"; +import { useController, useFieldArray, useWatch } from "react-hook-form"; + +import { AddressInput } from "../AddressInput"; +import { AssignMe } from "../AssignMe"; +import { CustomIcon } from "lib/components/icon"; +import { AmpEvent, AmpTrack } from "lib/services/amplitude"; +import type { Addr, UploadSectionState } from "lib/types"; +import { AccessType } from "lib/types"; + +interface InstantiatePermissionRadioProps { + control: Control; + setValue: UseFormSetValue; + trigger: UseFormTrigger; +} + +interface PermissionRadioProps { + isSelected: boolean; + value: AccessType; + text: string; +} + +const PermissionRadio = ({ isSelected, value, text }: PermissionRadioProps) => ( + + {text} + +); + +export const InstantiatePermissionRadio = ({ + control, + setValue, + trigger, +}: InstantiatePermissionRadioProps) => { + const { address: walletAddress } = useWallet(); + + const { fields, append, remove } = useFieldArray({ + control, + name: "addresses", + }); + + const [permission, addresses] = useWatch({ + control, + name: ["permission", "addresses"], + }); + + const { + formState: { errors }, + } = useController({ + control, + name: "addresses", + }); + + return ( + { + const value = parseInt(nextValue, 10); + setValue("permission", value); + }} + value={permission} + > + + + + + + {permission === AccessType.ACCESS_TYPE_ANY_OF_ADDRESSES && ( + + {fields.map((field, idx) => ( + + + i < idx && address === addresses[idx]?.address + ) && + "You already input this address") || + errors.addresses?.[idx]?.address?.message + } + helperAction={ + { + AmpTrack(AmpEvent.USE_ASSIGN_ME); + setValue( + `addresses.${idx}.address`, + walletAddress as Addr + ); + trigger(`addresses.${idx}.address`); + }} + isDisable={ + addresses.findIndex( + (x) => x.address === walletAddress + ) > -1 + } + /> + } + /> + + + ))} + + + )} + + + + ); +}; diff --git a/src/lib/components/upload/SimulateMessageRender.tsx b/src/lib/components/upload/SimulateMessageRender.tsx new file mode 100644 index 000000000..6e6ed4c1d --- /dev/null +++ b/src/lib/components/upload/SimulateMessageRender.tsx @@ -0,0 +1,53 @@ +import type { FlexProps } from "@chakra-ui/react"; +import { Spinner, Flex, Text } from "@chakra-ui/react"; + +import { CustomIcon } from "../icon"; +import type { Option } from "lib/types"; + +interface SimulateMessageRenderProps extends FlexProps { + value: Option; + isLoading: boolean; + isSuccess: boolean; +} + +const item = { + success: { + color: "success.main", + icon: ( + + ), + }, + fail: { + color: "error.main", + icon: ( + + ), + }, + loading: { + color: "pebble.500", + icon: , + }, +}; + +const getStatus = (isLoading: boolean, isSuccess: boolean) => { + if (isLoading) return "loading"; + if (isSuccess) return "success"; + return "fail"; +}; + +export const SimulateMessageRender = ({ + value, + isLoading, + isSuccess, + ...restProps +}: SimulateMessageRenderProps) => { + const status = getStatus(isLoading, isSuccess); + return ( + + {item[status].icon} + + {value} + + + ); +}; diff --git a/src/lib/components/upload/UploadCard.tsx b/src/lib/components/upload/UploadCard.tsx new file mode 100644 index 000000000..912a70dc0 --- /dev/null +++ b/src/lib/components/upload/UploadCard.tsx @@ -0,0 +1,37 @@ +import { Flex, Text } from "@chakra-ui/react"; +import big from "big.js"; + +import { CustomIcon, UploadIcon } from "lib/components/icon"; + +interface UploadCardProps { + file: File; + deleteFile: () => void; +} + +export const UploadCard = ({ file, deleteFile }: UploadCardProps) => ( + + + + + {file.name} + + {big(file.size).div(1000).toFixed(0)} KB + + + + + +); diff --git a/src/lib/components/upload/UploadSection.tsx b/src/lib/components/upload/UploadSection.tsx index bb82fbf35..e0dac5ce7 100644 --- a/src/lib/components/upload/UploadSection.tsx +++ b/src/lib/components/upload/UploadSection.tsx @@ -1,27 +1,34 @@ -import { Button, Flex } from "@chakra-ui/react"; +import { Box, Button, Flex } from "@chakra-ui/react"; +import type { StdFee } from "@cosmjs/stargate"; import { useWallet } from "@cosmos-kit/react"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; -import { CustomIcon } from "../icon"; +import { DropZone } from "../dropzone"; +import { ControllerInput } from "../forms"; import { useFabricateFee, - useSimulateFee, + useSimulateFeeForStoreCode, useUploadContractTx, + useValidateAddress, } from "lib/app-provider"; -import { DropZone } from "lib/components/dropzone"; import { EstimatedFeeRender } from "lib/components/EstimatedFeeRender"; -import { ControllerInput } from "lib/components/forms"; +import { CustomIcon } from "lib/components/icon"; import { getMaxCodeNameLengthError, MAX_CODE_NAME_LENGTH } from "lib/data"; import { useCodeStore } from "lib/providers/store"; import { useTxBroadcast } from "lib/providers/tx-broadcast"; import { AmpEvent, AmpTrack } from "lib/services/amplitude"; -import type { HumanAddr } from "lib/types"; -import { MsgType } from "lib/types"; -import { composeMsg } from "lib/utils"; +import type { + Addr, + HumanAddr, + SimulateStatus, + UploadSectionState, +} from "lib/types"; +import { AccessType } from "lib/types"; -import { UploadCard } from "./components/UploadCard"; -import type { UploadSectionState } from "./types"; +import { InstantiatePermissionRadio } from "./InstantiatePermissionRadio"; +import { SimulateMessageRender } from "./SimulateMessageRender"; +import { UploadCard } from "./UploadCard"; interface UploadSectionProps { handleBack: () => void; @@ -32,31 +39,92 @@ export const UploadSection = ({ handleBack, isMigrate = false, }: UploadSectionProps) => { - const { simulate, loading } = useSimulateFee(); const fabricateFee = useFabricateFee(); const { address } = useWallet(); const { broadcast } = useTxBroadcast(); const { updateCodeInfo } = useCodeStore(); + const postUploadTx = useUploadContractTx(isMigrate); + const { validateUserAddress, validateContractAddress } = useValidateAddress(); + + const [estimatedFee, setEstimatedFee] = useState(); + const [simulateStatus, setSimulateStatus] = useState({ + status: "default", + message: "", + }); const { control, - watch, setValue, + watch, formState: { errors }, + trigger, } = useForm({ defaultValues: { wasmFile: undefined, - codeDesc: "", - estimatedFee: undefined, - simulateStatus: "pending", - simulateError: "", + codeName: "", + permission: AccessType.ACCESS_TYPE_EVERYBODY, + addresses: [{ address: "" as Addr }], }, mode: "all", }); - const { wasmFile, codeDesc, estimatedFee, simulateStatus, simulateError } = - watch(); - const postUploadTx = useUploadContractTx(isMigrate); + const { wasmFile, codeName, addresses, permission } = watch(); + + const setDefaultBehavior = () => { + setSimulateStatus({ status: "default", message: "" }); + setEstimatedFee(undefined); + }; + + // Should not simulate when permission is any of addresses and address input is not filled, invalid, or empty + const shouldNotSimulate = useMemo( + () => + permission === AccessType.ACCESS_TYPE_ANY_OF_ADDRESSES && + (addresses.some((addr) => addr.address.trim().length === 0) || + addresses.some((addr) => + Boolean( + validateUserAddress(addr.address) && + validateContractAddress(addr.address) + ) + )), + + [ + addresses, + permission, + validateContractAddress, + validateUserAddress, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(addresses), + ] + ); + + const { isFetching: isSimulating } = useSimulateFeeForStoreCode({ + enabled: Boolean(wasmFile && address && !shouldNotSimulate), + wasmFile, + permission, + addresses: addresses.map((addr) => addr.address), + onSuccess: (fee) => { + if (wasmFile && address) { + if (shouldNotSimulate) { + setDefaultBehavior(); + } + if (fee) { + setSimulateStatus({ + status: "succeeded", + message: "Valid Wasm file and instantiate permission", + }); + setEstimatedFee(fabricateFee(fee)); + } + } + }, + onError: (e) => { + if (shouldNotSimulate) { + setDefaultBehavior(); + } else { + setSimulateStatus({ status: "failed", message: e.message }); + setEstimatedFee(undefined); + } + }, + }); const proceed = useCallback(async () => { if (address) { @@ -64,13 +132,15 @@ export const UploadSection = ({ const stream = await postUploadTx({ wasmFileName: wasmFile?.name, wasmCode: wasmFile?.arrayBuffer(), - codeDesc, + addresses: addresses.map((addr) => addr.address), + permission, + codeName, estimatedFee, onTxSucceed: (codeId: number) => { updateCodeInfo( codeId, address as HumanAddr, - codeDesc || `${wasmFile?.name}(${codeId})` + codeName || `${wasmFile?.name}(${codeId})` ); }, }); @@ -78,40 +148,30 @@ export const UploadSection = ({ if (stream) broadcast(stream); } }, [ + address, postUploadTx, wasmFile, - codeDesc, + addresses, + permission, + codeName, estimatedFee, broadcast, updateCodeInfo, - address, + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(addresses), ]); useEffect(() => { - (async () => { - if (wasmFile) { - setValue("simulateStatus", "pending"); - setValue("simulateError", ""); - const msg = composeMsg(MsgType.STORE_CODE, { - sender: address as HumanAddr, - wasmByteCode: new Uint8Array(await wasmFile.arrayBuffer()), - }); - try { - const estimatedGasUsed = await simulate([msg]); - if (estimatedGasUsed) { - setValue("estimatedFee", fabricateFee(estimatedGasUsed)); - setValue("simulateStatus", "completed"); - } - } catch (err) { - setValue("simulateStatus", "failed"); - setValue("simulateError", (err as Error).message); - } - } - })(); - }, [wasmFile, address, simulate, fabricateFee, setValue]); + if (!wasmFile) { + setDefaultBehavior(); + setValue("addresses", [{ address: "" as Addr }]); + } + }, [setValue, wasmFile]); + + useEffect(() => { + if (wasmFile && address && shouldNotSimulate) setDefaultBehavior(); + }, [wasmFile, address, shouldNotSimulate, permission, setValue]); - const isDisabled = - !estimatedFee || !wasmFile || !!errors.codeDesc || !address; return ( <> {wasmFile ? ( @@ -119,16 +179,14 @@ export const UploadSection = ({ file={wasmFile} deleteFile={() => { setValue("wasmFile", undefined); - setValue("estimatedFee", undefined); + setEstimatedFee(undefined); }} - simulateStatus={simulateStatus} - simulateError={simulateError} /> ) : ( setValue("wasmFile", file)} /> )} - -

Transaction Fee:

- -
+ + + + {(simulateStatus.status !== "default" || isSimulating) && ( + + )} + + + Transaction Fee:{" "} + + + +