diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb8adfd8..ecea46e24 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 +- [#515](https://github.com/alleslabs/celatone-frontend/pull/515) Initia select module drawer wireup - [#494](https://github.com/alleslabs/celatone-frontend/pull/494) Initia select module drawer UI - [#490](https://github.com/alleslabs/celatone-frontend/pull/490) Add initia module interaction page - [#488](https://github.com/alleslabs/celatone-frontend/pull/488) Add initia navigation and sidebar diff --git a/src/config/chain/initia.ts b/src/config/chain/initia.ts index 4baba6c71..6a352aaf2 100644 --- a/src/config/chain/initia.ts +++ b/src/config/chain/initia.ts @@ -5,12 +5,11 @@ import type { ChainConfigs } from "./types"; export const INITIA_CHAIN_CONFIGS: ChainConfigs = { "stone-9": { chain: "initia", - registryChainName: "osmosis", + registryChainName: "initiatestnet", prettyName: "Initia Testnet", - // TODO change to initia - lcd: "https://lcd.osmosis.zone", - rpc: "https://rpc.osmosis.zone:443", - indexer: "https://osmosis-mainnet-graphql.alleslabs.dev/v1/graphql", + lcd: "https://stone-rest.initia.tech", + rpc: "https://stone-rpc.initia.tech:443", + indexer: "https://initia-tesnet-graphql.alleslabs.dev/v1/graphql", api: "https://celatone-api.alleslabs.dev", wallets: [...keplrWallets], features: { @@ -39,7 +38,7 @@ export const INITIA_CHAIN_CONFIGS: ChainConfigs = { gas: { gasPrice: { tokenPerGas: 0.025, - denom: "init", + denom: "uinit", }, gasAdjustment: 1.5, maxGasLimit: 25_000_000, diff --git a/src/lib/app-provider/env.ts b/src/lib/app-provider/env.ts index fb00400ca..74f85ad89 100644 --- a/src/lib/app-provider/env.ts +++ b/src/lib/app-provider/env.ts @@ -92,4 +92,7 @@ export enum CELATONE_QUERY_KEYS { POOL_INFO_BY_IDS = "CELATONE_QUERY_POOL_INFO_BY_IDS", POOL_TRANSACTION_BY_ID = "CELATONE_QUERY_POOL_TRANSACTION_BY_ID", POOL_TRANSACTION_BY_ID_COUNT = "CELATONE_QUERY_POOL_TRANSACTION_BY_ID_COUNT", + // MODULES + ACCOUNT_MODULES = "CELATONE_QUERY_ACCOUNT_MODULES", + MODULE_VERIFICATION = "CELATONE_QUERY_MODULE_VERIFICATION", } diff --git a/src/lib/app-provider/hooks/index.ts b/src/lib/app-provider/hooks/index.ts index 76a5b2de0..98b3d19b0 100644 --- a/src/lib/app-provider/hooks/index.ts +++ b/src/lib/app-provider/hooks/index.ts @@ -13,3 +13,4 @@ export * from "./useBaseApiRoute"; export * from "./useRPCEndpoint"; export * from "./useConfig"; export * from "./useCurrentChain"; +export * from "./useConvertHexAddress"; diff --git a/src/lib/app-provider/hooks/useAddress.ts b/src/lib/app-provider/hooks/useAddress.ts index 0762dae0d..b69e26a93 100644 --- a/src/lib/app-provider/hooks/useAddress.ts +++ b/src/lib/app-provider/hooks/useAddress.ts @@ -2,6 +2,7 @@ import { fromBech32 } from "@cosmjs/encoding"; import { useCallback, useMemo } from "react"; import type { Option } from "lib/types"; +import { isHexAddress } from "lib/utils"; import { useCurrentChain } from "./useCurrentChain"; import { useExampleAddresses } from "./useExampleAddresses"; @@ -134,5 +135,10 @@ export const useValidateAddress = () => { ), [bech32Prefix, getAddressTypeByLength] ), + validateHexAddress: useCallback( + (address: string) => + !address.startsWith(bech32Prefix) && isHexAddress(address), + [bech32Prefix] + ), }; }; diff --git a/src/lib/app-provider/hooks/useConvertHexAddress.ts b/src/lib/app-provider/hooks/useConvertHexAddress.ts new file mode 100644 index 000000000..749d4ec8b --- /dev/null +++ b/src/lib/app-provider/hooks/useConvertHexAddress.ts @@ -0,0 +1,17 @@ +import { useCallback } from "react"; + +import type { HexAddr } from "lib/types"; +import { hexToBech32Address } from "lib/utils"; + +import { useCurrentChain } from "./useCurrentChain"; + +export const useConvertHexAddress = () => { + const { + chain: { bech32_prefix: bech32Prefix }, + } = useCurrentChain(); + + return useCallback( + (hexAddr: HexAddr) => hexToBech32Address(bech32Prefix, hexAddr), + [bech32Prefix] + ); +}; diff --git a/src/lib/chain-registry/initiatestnet.ts b/src/lib/chain-registry/initiatestnet.ts new file mode 100644 index 000000000..debbde25f --- /dev/null +++ b/src/lib/chain-registry/initiatestnet.ts @@ -0,0 +1,72 @@ +import type { Chain, AssetList } from "@chain-registry/types"; + +export const initiatestnet: Chain = { + $schema: "../chain.schema.json", + chain_name: "initiatestnet", + status: "live", + network_type: "testnet", + pretty_name: "Initia Testnet", + chain_id: "stone-9", + bech32_prefix: "init", + daemon_name: "initd", + node_home: "$HOME/.init", + key_algos: ["secp256k1"], + slip44: 118, + fees: { + fee_tokens: [ + { + denom: "uinit", + fixed_min_gas_price: 0, + low_gas_price: 0, + average_gas_price: 0.025, + high_gas_price: 0.04, + }, + ], + }, + staking: { + staking_tokens: [ + { + denom: "uinit", + }, + ], + }, + logo_URIs: { + png: "", + svg: "", + }, + apis: { + rpc: [ + { + address: "https://stone-rpc.initia.tech:443", + }, + ], + rest: [ + { + address: "https://stone-rest.initia.tech", + }, + ], + }, +}; +export const initiatestnetAssets: AssetList = { + $schema: "../assetlist.schema.json", + chain_name: "initiatestnet", + assets: [ + { + description: "The native staking token of Initia.", + denom_units: [ + { + denom: "uinit", + exponent: 0, + }, + { + denom: "init", + exponent: 6, + }, + ], + base: "uinit", + name: "Init", + display: "init", + symbol: "INIT", + }, + ], +}; diff --git a/src/lib/components/CopyLink.tsx b/src/lib/components/CopyLink.tsx index 16530a7c7..1190e867e 100644 --- a/src/lib/components/CopyLink.tsx +++ b/src/lib/components/CopyLink.tsx @@ -1,6 +1,6 @@ import type { FlexProps, IconProps } from "@chakra-ui/react"; import { Flex, Text, useClipboard } from "@chakra-ui/react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useCurrentChain } from "lib/app-provider"; import { AmpTrackCopier } from "lib/services/amplitude"; @@ -25,7 +25,7 @@ export const CopyLink = ({ ...flexProps }: CopyLinkProps) => { const { address } = useCurrentChain(); - const { onCopy, hasCopied } = useClipboard(value); + const { onCopy, hasCopied, setValue } = useClipboard(value); const [isHover, setIsHover] = useState(false); // TODO - Refactor @@ -39,6 +39,10 @@ export const CopyLink = ({ return undefined; }, [showCopyOnHover, isHover]); + useEffect(() => { + setValue(value); + }, [value, setValue]); + return ( ) => void; size?: InputProps["size"]; + my?: InputProps["my"]; autoFocus?: boolean; action?: string; + iconPosition?: "start" | "end"; + onChange: (e: ChangeEvent) => void; } +const SearchIcon = () => ; + const InputWithIcon = ({ placeholder, value, size, + my, action, + iconPosition = "end", autoFocus = false, onChange, }: InputWithIconProps) => ( - + AmpTrack(AmpEvent.USE_SEARCH_INPUT) : undefined} /> - - - + {iconPosition === "end" ? ( + + + + ) : ( + + + + )} ); diff --git a/src/lib/components/forms/TextInput.tsx b/src/lib/components/forms/TextInput.tsx index 0fe07ec97..e9cf3ed57 100644 --- a/src/lib/components/forms/TextInput.tsx +++ b/src/lib/components/forms/TextInput.tsx @@ -64,6 +64,7 @@ export const TextInput = ({ setInputState(e.target.value)} maxLength={maxLength} - autoFocus={autoFocus} /> {status && getStatusIcon(status.state, "16px")} @@ -81,11 +81,13 @@ export const TextInput = ({ {error ? ( {error} ) : ( - + {status?.message ? ( getResponseMsg(status, helperText) ) : ( - {helperText} + + {helperText} + )} )} diff --git a/src/lib/components/module/FunctionCard.tsx b/src/lib/components/module/FunctionCard.tsx index d670203d6..d05998b59 100644 --- a/src/lib/components/module/FunctionCard.tsx +++ b/src/lib/components/module/FunctionCard.tsx @@ -4,22 +4,22 @@ import { Flex, Text } from "@chakra-ui/react"; import { DotSeparator } from "../DotSeparator"; import { CustomIcon } from "../icon"; import { Tooltip } from "lib/components/Tooltip"; +import type { ExposedFunction, Visibility } from "lib/types"; interface FunctionCardProps { - isView?: boolean; - disabled?: boolean; - visibility?: "public" | "friends" | "script"; + exposedFn: ExposedFunction; + onFunctionSelect: (fn: ExposedFunction) => void; } -const getIcon = (visibility: FunctionCardProps["visibility"]) => { +const getIcon = (visibility: Visibility) => { switch (visibility) { - case "friends": - return ; + case "friend": + return ; case "script": - return ; + return ; case "public": default: - return ; + return ; } }; @@ -28,6 +28,7 @@ const disabledStyle: { [key in `${boolean}`]: FlexProps } = { bgColor: "gray.900", _hover: { bg: "gray.900" }, cursor: "not-allowed", + pointerEvents: "none", borderColor: "gray.700", }, false: { @@ -39,10 +40,12 @@ const disabledStyle: { [key in `${boolean}`]: FlexProps } = { }; export const FunctionCard = ({ - isView = true, - disabled = false, - visibility = "public", + exposedFn, + onFunctionSelect, }: FunctionCardProps) => { + const { is_entry: isEntry, is_view: isView, visibility, name } = exposedFn; + const disabled = !isEntry || visibility !== "public"; + return ( onFunctionSelect(exposedFn)} {...disabledStyle[String(disabled) as `${boolean}`]} > @@ -66,28 +70,30 @@ export const FunctionCard = ({ - - - - - - + {!disabled && ( + <> + + + + + + + + )} {getIcon(visibility)} - + {visibility} - Function Name + + {name} + ); }; diff --git a/src/lib/components/module/ModuleCard.tsx b/src/lib/components/module/ModuleCard.tsx index ea005f3bc..6091ab827 100644 --- a/src/lib/components/module/ModuleCard.tsx +++ b/src/lib/components/module/ModuleCard.tsx @@ -1,31 +1,67 @@ -import { Flex } from "@chakra-ui/react"; +import { Flex, Grid, Text } from "@chakra-ui/react"; +import type { Dispatch, SetStateAction } from "react"; import { CustomIcon } from "../icon"; +import type { IndexedModule } from "lib/services/moduleService"; +import { useVerifyModule } from "lib/services/moduleService"; +import type { HumanAddr, Option } from "lib/types"; import { CountBadge } from "./CountBadge"; -export const ModuleCard = () => { +interface ModuleCardProps { + selectedAddress: HumanAddr; + module: IndexedModule; + selectedModule: Option; + setSelectedModule: Dispatch>>; +} + +export const ModuleCard = ({ + selectedAddress, + module, + selectedModule, + setSelectedModule, +}: ModuleCardProps) => { + const { data: isVerified } = useVerifyModule({ + address: selectedAddress, + moduleName: module.moduleName, + }); + return ( - setSelectedModule(module)} + gap={1} + templateColumns="20px 1fr auto" + _hover={{ + bg: "gray.700", + }} + transition=".25s all ease-out" > - - - Module name - + + + + {module.moduleName} + + {isVerified && ( + + )} - - - + + + - + ); }; diff --git a/src/lib/pages/account-details/components/modules/ModuleLists.tsx b/src/lib/pages/account-details/components/modules/ModuleLists.tsx index b055465f9..5c1fc5d7d 100644 --- a/src/lib/pages/account-details/components/modules/ModuleLists.tsx +++ b/src/lib/pages/account-details/components/modules/ModuleLists.tsx @@ -2,7 +2,7 @@ import { Flex, SimpleGrid } from "@chakra-ui/react"; import { useMobile } from "lib/app-provider"; import { CustomIcon } from "lib/components/icon"; -import { ModuleCard } from "lib/components/module/ModuleCard"; +// import { ModuleCard } from "lib/components/module/ModuleCard"; import { TableTitle, ViewMore } from "lib/components/table"; interface ModuleListsProps { @@ -52,6 +52,7 @@ export const ModuleLists = ({ onViewMore, totalAsset }: ModuleListsProps) => { {!(isMobile && onViewMore) && ( + {/* @@ -59,8 +60,7 @@ export const ModuleLists = ({ onViewMore, totalAsset }: ModuleListsProps) => { - - + */} )} {!isMobile && onViewMore && } diff --git a/src/lib/pages/interaction/component/ModuleSelectDrawerButton.tsx b/src/lib/pages/interaction/component/ModuleSelectDrawerButton.tsx deleted file mode 100644 index ca11aa024..000000000 --- a/src/lib/pages/interaction/component/ModuleSelectDrawerButton.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { - Button, - Heading, - useDisclosure, - Drawer, - DrawerOverlay, - DrawerContent, - DrawerHeader, - DrawerCloseButton, - DrawerBody, -} from "@chakra-ui/react"; - -import { CustomIcon } from "lib/components/icon"; - -import { ModuleSelectBody } from "./select-module/ModuleSelectBody"; -import { - ModuleSelectorDisplay, - ModuleSelectorInput, -} from "./select-module/ModuleSelector"; -// import { ModuleEmptyState } from "./select-module/ModuleEmptyState"; - -interface ModuleSelectDrawerButtonProps { - buttonText?: string; -} - -export const ModuleSelectDrawerButton = ({ - buttonText = "Select Module", -}: ModuleSelectDrawerButtonProps) => { - const { isOpen, onClose, onOpen } = useDisclosure(); - return ( - <> - - - - - - - - Select Module - - - - - {/* Input */} - - {/* Selected Address */} - - - {/* */} - - - - - ); -}; diff --git a/src/lib/pages/interaction/component/drawer/body/ModuleEmptyState.tsx b/src/lib/pages/interaction/component/drawer/body/ModuleEmptyState.tsx new file mode 100644 index 000000000..d401dcbd9 --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/body/ModuleEmptyState.tsx @@ -0,0 +1,39 @@ +import type { FlexProps } from "@chakra-ui/react"; +import { Text, Flex } from "@chakra-ui/react"; + +import { StateImage } from "lib/components/state"; + +interface ModuleEmptyStateProps { + description?: string; + imageWidth?: string; + hasImage?: boolean; + noBorder?: boolean; + h?: FlexProps["h"]; + p?: FlexProps["p"]; +} +export const ModuleEmptyState = ({ + description = "Available functions for selected modules will display here", + imageWidth = "160px", + hasImage = true, + noBorder = false, + h = "full", + p, +}: ModuleEmptyStateProps) => ( + + {hasImage && } + + {description} + + +); diff --git a/src/lib/pages/interaction/component/drawer/body/ModuleFunctionBody.tsx b/src/lib/pages/interaction/component/drawer/body/ModuleFunctionBody.tsx new file mode 100644 index 000000000..5e3065c5e --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/body/ModuleFunctionBody.tsx @@ -0,0 +1,156 @@ +import type { FlexProps, GridItemProps } from "@chakra-ui/react"; +import { GridItem, Heading, Flex, Text } from "@chakra-ui/react"; +import { useCallback, useMemo, useState } from "react"; + +import type { ModuleSelectFunction } from "../types"; +import InputWithIcon from "lib/components/InputWithIcon"; +import { CountBadge } from "lib/components/module/CountBadge"; +import { FunctionCard } from "lib/components/module/FunctionCard"; +import type { IndexedModule } from "lib/services/moduleService"; +import type { ExposedFunction, Option } from "lib/types"; + +import { ModuleEmptyState } from "./ModuleEmptyState"; + +const functionGridBaseStyle: FlexProps = { + border: "1px solid", + borderRadius: 8, + borderColor: "gray.700", + p: 4, + direction: "column", +}; + +const EmptyFunctionState = ({ + desc = "No exposed_functions available.", +}: { + desc?: string; +}) => ( + +); + +interface RenderFunctionsProps { + exposedFnsLength: number; + filtered: Option; + onFunctionSelect: (fn: ExposedFunction) => void; +} +const RenderFunctions = ({ + exposedFnsLength, + filtered, + onFunctionSelect, +}: RenderFunctionsProps) => { + if (!exposedFnsLength) return ; + return filtered?.length ? ( + <> + {filtered.map((fn) => ( + + ))} + + ) : ( + + ); +}; + +interface ModuleFunctionBodyProps extends GridItemProps { + module: Option; + handleModuleSelect: ModuleSelectFunction; + closeModal: () => void; +} + +export const ModuleFunctionBody = ({ + module, + handleModuleSelect, + closeModal, + ...gridItemProps +}: ModuleFunctionBodyProps) => { + const [keyword, setKeyword] = useState(""); + const [filteredView, filteredExecute] = useMemo( + () => [ + module?.viewFunctions.filter((el) => el.name.includes(keyword)), + module?.executeFunctions.filter((el) => el.name.includes(keyword)), + ], + [module?.viewFunctions, module?.executeFunctions, keyword] + ); + + const onFunctionSelect = useCallback( + (fn: ExposedFunction) => { + if (module) { + handleModuleSelect(module, fn); + closeModal(); + } + }, + [module, handleModuleSelect, closeModal] + ); + + // TODO: 100% - element and margin hack, find better way to setup the height + const maxHeight = "calc(100% - 22px - 32px - 40px)"; + return ( + + {module ? ( + <> + + {module.moduleName} + + setKeyword(e.target.value)} + placeholder="Search functions ..." + my={4} + /> + + + + + View Functions + + + + + + + + + + + Execute Functions + + + + + + + + + + ) : ( + + )} + + ); +}; diff --git a/src/lib/pages/interaction/component/drawer/body/ModuleSelectMainBody.tsx b/src/lib/pages/interaction/component/drawer/body/ModuleSelectMainBody.tsx new file mode 100644 index 000000000..99f4de9e8 --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/body/ModuleSelectMainBody.tsx @@ -0,0 +1,161 @@ +import { Grid, GridItem, Text, Flex, Box, Button } from "@chakra-ui/react"; +import type { Dispatch, SetStateAction } from "react"; +import { useMemo, useState } from "react"; + +import type { + DisplayMode, + ModuleSelectFunction, + SelectedAddress, +} from "../types"; +import InputWithIcon from "lib/components/InputWithIcon"; +import { CountBadge } from "lib/components/module/CountBadge"; +import { ModuleCard } from "lib/components/module/ModuleCard"; +import type { IndexedModule } from "lib/services/moduleService"; +import type { Option } from "lib/types"; + +import { ModuleEmptyState } from "./ModuleEmptyState"; +import { ModuleFunctionBody } from "./ModuleFunctionBody"; + +interface ModuleSelectBodyProps { + selectedAddress: SelectedAddress; + modules: IndexedModule[]; + mode: DisplayMode; + handleModuleSelect: ModuleSelectFunction; + closeModal: () => void; +} + +const RenderModules = ({ + selectedAddress, + modulesLength, + filtered, + selectedModule, + setSelectedModule, +}: { + selectedAddress: SelectedAddress; + modulesLength: number; + filtered: IndexedModule[]; + selectedModule: Option; + setSelectedModule: Dispatch>>; +}) => { + if (!modulesLength) + return ( + + ); + return filtered?.length ? ( + <> + {filtered.map((module) => ( + + ))} + + ) : ( + + ); +}; + +export const ModuleSelectMainBody = ({ + selectedAddress, + mode, + modules, + handleModuleSelect, + closeModal, +}: ModuleSelectBodyProps) => { + const [keyword, setKeyword] = useState(""); + const [selectedModule, setSelectedModule] = useState(); + + const filteredModules = useMemo( + () => modules.filter((each) => each.moduleName.includes(keyword)), + [modules, keyword] + ); + + return ( + + {mode === "input" && ( + + )} + + setKeyword(e.target.value)} + placeholder="Search module ..." + /> + + + Modules + + + + + + + + {/* Left */} + + + + + + ); +}; diff --git a/src/lib/pages/interaction/component/drawer/body/index.ts b/src/lib/pages/interaction/component/drawer/body/index.ts new file mode 100644 index 000000000..06b4852b5 --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/body/index.ts @@ -0,0 +1,2 @@ +export * from "./ModuleEmptyState"; +export * from "./ModuleSelectMainBody"; diff --git a/src/lib/pages/interaction/component/drawer/index.tsx b/src/lib/pages/interaction/component/drawer/index.tsx new file mode 100644 index 000000000..4e31721c4 --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/index.tsx @@ -0,0 +1,87 @@ +import { + Button, + Heading, + useDisclosure, + Drawer, + DrawerOverlay, + DrawerContent, + DrawerHeader, + DrawerCloseButton, + DrawerBody, + Flex, +} from "@chakra-ui/react"; +import { useState } from "react"; + +import { CustomIcon } from "lib/components/icon"; +import type { IndexedModule } from "lib/services/moduleService"; +import type { HexAddr, HumanAddr } from "lib/types"; + +import { ModuleEmptyState, ModuleSelectMainBody } from "./body"; +import { ModuleSelector } from "./selector"; +import type { + DisplayMode, + SelectedAddress, + ModuleSelectFunction, +} from "./types"; + +interface ModuleSelectDrawerTriggerProps { + buttonText?: string; + handleModuleSelect: ModuleSelectFunction; +} + +export const ModuleSelectDrawerTrigger = ({ + buttonText = "Select Module", + handleModuleSelect, +}: ModuleSelectDrawerTriggerProps) => { + const { isOpen, onClose, onOpen } = useDisclosure(); + const [modules, setModules] = useState(); + const [mode, setMode] = useState("input"); + const [selectedAddress, setSelectedAddress] = useState({ + address: "" as HumanAddr, + hex: "" as HexAddr, + }); + + return ( + <> + + + + + + + + Select Module + + + + + + + {modules ? ( + + ) : ( + + )} + + + + + + ); +}; diff --git a/src/lib/pages/interaction/component/drawer/selector/SelectorDisplay.tsx b/src/lib/pages/interaction/component/drawer/selector/SelectorDisplay.tsx new file mode 100644 index 000000000..a3127dd2c --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/selector/SelectorDisplay.tsx @@ -0,0 +1,54 @@ +import { Flex, Button } from "@chakra-ui/react"; +import type { Dispatch, SetStateAction } from "react"; + +import type { DisplayMode, SelectedAddress } from "../types"; +import { CopyLink } from "lib/components/CopyLink"; +import { CustomIcon } from "lib/components/icon"; +import { LabelText } from "lib/components/LabelText"; + +interface ModuleSelectorDisplayProps { + selectedAddress: SelectedAddress; + setMode: Dispatch>; +} + +export const ModuleSelectorDisplay = ({ + selectedAddress, + setMode, +}: ModuleSelectorDisplayProps) => ( + + + + + + + + + +); diff --git a/src/lib/pages/interaction/component/drawer/selector/SelectorInput.tsx b/src/lib/pages/interaction/component/drawer/selector/SelectorInput.tsx new file mode 100644 index 000000000..c2f5d89d5 --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/selector/SelectorInput.tsx @@ -0,0 +1,136 @@ +import { Flex, Button } from "@chakra-ui/react"; +import type { Dispatch, SetStateAction, KeyboardEvent } from "react"; +import { useState, useMemo, useCallback } from "react"; + +import type { + SelectedAddress, + DisplayMode, + ModuleSelectFunction, +} from "../types"; +import { + useConvertHexAddress, + useValidateAddress, + useExampleAddresses, +} from "lib/app-provider"; +import { TextInput } from "lib/components/forms"; +import { useValidateModuleInput } from "lib/pages/interaction/hooks/useValidateModuleInput"; +import type { IndexedModule } from "lib/services/moduleService"; +import { useAccountModules } from "lib/services/moduleService"; +import type { MoveAccountAddr, HexAddr, HumanAddr, Option } from "lib/types"; +import { bech32AddressToHex } from "lib/utils"; + +export interface ModuleSelectorInputProps { + selectedAddress: SelectedAddress; + setSelectedAddress: Dispatch>; + handleModuleSelect: ModuleSelectFunction; + setModules: Dispatch>>; + setMode: Dispatch>; + closeModal: () => void; +} + +export const ModuleSelectorInput = ({ + selectedAddress, + setSelectedAddress, + handleModuleSelect, + setModules, + setMode, + closeModal, +}: ModuleSelectorInputProps) => { + const [keyword, setKeyword] = useState(selectedAddress.address as string); + const [error, setError] = useState(""); + const [addr, moduleName] = useMemo(() => keyword.split("::"), [keyword]); + + const convertHexAddr = useConvertHexAddress(); + const { validateHexAddress } = useValidateAddress(); + const validateModuleInput = useValidateModuleInput(); + const { user } = useExampleAddresses(); + const { refetch, isFetching } = useAccountModules({ + address: addr as MoveAccountAddr, + moduleName, + options: { + refetchOnWindowFocus: false, + enabled: false, + retry: false, + onSuccess: (data) => { + setError(""); + setSelectedAddress(() => { + const isHex = validateHexAddress(addr); + return isHex + ? { + address: convertHexAddr(addr as HexAddr), + hex: addr as HexAddr, + } + : { + address: addr as HumanAddr, + hex: bech32AddressToHex(addr as HumanAddr), + }; + }); + if (Array.isArray(data)) { + setModules(data); + setMode("display"); + } else { + handleModuleSelect(data); + closeModal(); + } + }, + onError: (err) => setError((err as Error).message), + }, + }); + + const handleSubmit = useCallback(() => { + const err = validateModuleInput(keyword); + return err ? setError(err) : refetch(); + }, [keyword, refetch, validateModuleInput]); + + const handleKeydown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Enter") { + handleSubmit(); + } + }, + [handleSubmit] + ); + + return ( + + + + + + + + ); +}; diff --git a/src/lib/pages/interaction/component/drawer/selector/index.tsx b/src/lib/pages/interaction/component/drawer/selector/index.tsx new file mode 100644 index 000000000..031897f8d --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/selector/index.tsx @@ -0,0 +1,45 @@ +import { Box } from "@chakra-ui/react"; + +import type { DisplayMode, SelectedAddress } from "../types"; + +import { ModuleSelectorDisplay } from "./SelectorDisplay"; +import type { ModuleSelectorInputProps } from "./SelectorInput"; +import { ModuleSelectorInput } from "./SelectorInput"; + +interface ModuleSelectorProps extends ModuleSelectorInputProps { + mode: DisplayMode; + selectedAddress: SelectedAddress; +} + +export const ModuleSelector = ({ + mode, + selectedAddress, + setSelectedAddress, + setMode, + setModules, + handleModuleSelect, + closeModal, +}: ModuleSelectorProps) => { + const showDisplay = mode === "display" && Boolean(selectedAddress.address); + return ( + + + + + ); +}; diff --git a/src/lib/pages/interaction/component/drawer/types.ts b/src/lib/pages/interaction/component/drawer/types.ts new file mode 100644 index 000000000..80d96c08b --- /dev/null +++ b/src/lib/pages/interaction/component/drawer/types.ts @@ -0,0 +1,14 @@ +import type { IndexedModule } from "lib/services/moduleService"; +import type { HumanAddr, HexAddr, ExposedFunction } from "lib/types"; + +export interface SelectedAddress { + address: HumanAddr; + hex: HexAddr; +} + +export type DisplayMode = "input" | "display"; + +export type ModuleSelectFunction = ( + selectedModule: IndexedModule, + fn?: ExposedFunction +) => void; diff --git a/src/lib/pages/interaction/component/select-module/ModuleEmptyState.tsx b/src/lib/pages/interaction/component/select-module/ModuleEmptyState.tsx deleted file mode 100644 index a408d333b..000000000 --- a/src/lib/pages/interaction/component/select-module/ModuleEmptyState.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Text, Flex } from "@chakra-ui/react"; - -import { StateImage } from "lib/components/state"; - -interface ModuleEmptyStateProps { - description?: string; - imageWidth?: string; -} -export const ModuleEmptyState = ({ - description = "Available functions for selected modules will display here", - imageWidth = "160px", -}: ModuleEmptyStateProps) => { - return ( - - - - - {description} - - - - ); -}; diff --git a/src/lib/pages/interaction/component/select-module/ModuleSelectBody.tsx b/src/lib/pages/interaction/component/select-module/ModuleSelectBody.tsx deleted file mode 100644 index 5d305e46c..000000000 --- a/src/lib/pages/interaction/component/select-module/ModuleSelectBody.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { FlexProps } from "@chakra-ui/react"; -import { Text, Flex, Badge, Skeleton, Button, Heading } from "@chakra-ui/react"; - -import { CountBadge } from "lib/components/module/CountBadge"; -import { FunctionCard } from "lib/components/module/FunctionCard"; -import { ModuleCard } from "lib/components/module/ModuleCard"; -// import { ModuleEmptyState } from "./ModuleEmptyState"; - -const functionSelectBaseStyle: FlexProps = { - border: "1px solid", - borderRadius: 8, - borderColor: "gray.700", - p: 4, - direction: "column", -}; - -export const ModuleSelectBody = () => { - return ( - <> - - {/* Left */} - - - input here - - - - - Modules - - - 0 - - - - - - - {/* Right */} - {/* - - */} - - - Module name goes here - - input here - - - - - View Functions - - - - - - - - - - - Execute Functions - - - - - - - - - - - - - ); -}; diff --git a/src/lib/pages/interaction/component/select-module/ModuleSelector.tsx b/src/lib/pages/interaction/component/select-module/ModuleSelector.tsx deleted file mode 100644 index 66d9110b2..000000000 --- a/src/lib/pages/interaction/component/select-module/ModuleSelector.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Button, Flex } from "@chakra-ui/react"; - -import { CopyLink } from "lib/components/CopyLink"; -import { CustomIcon } from "lib/components/icon"; -import { LabelText } from "lib/components/LabelText"; - -export const ModuleSelectorInput = () => { - return ( - - - input here - - - - - - - ); -}; - -export const ModuleSelectorDisplay = () => { - return ( - - - - - - - - - - ); -}; diff --git a/src/lib/pages/interaction/hooks/useValidateModuleInput.ts b/src/lib/pages/interaction/hooks/useValidateModuleInput.ts new file mode 100644 index 000000000..f9c3671e6 --- /dev/null +++ b/src/lib/pages/interaction/hooks/useValidateModuleInput.ts @@ -0,0 +1,37 @@ +import { useCallback } from "react"; + +import { + useCurrentChain, + useExampleAddresses, + useValidateAddress, +} from "lib/app-provider"; +import { isHexAddress, splitModule, truncate } from "lib/utils"; + +export const useValidateModuleInput = () => { + const { validateUserAddress } = useValidateAddress(); + const { + chain: { bech32_prefix: prefix }, + } = useCurrentChain(); + + const { user } = useExampleAddresses(); + const truncateExampleAddr = truncate(user); + const errText = `Input must be address (${truncateExampleAddr} or “0x123...456“) or module path (“${truncateExampleAddr}::module_name” or “0x123...456::module_name“)`; + + return useCallback( + (input: string): string | null => { + const inputArr = splitModule(input); + // Allow only module path for now + if (inputArr.length > 2) return errText; + const [address, module] = inputArr; + const addrErr = validateUserAddress(address); + const invalidAddress = address.startsWith(prefix) + ? addrErr + : !isHexAddress(address); + + if (invalidAddress || module === "") return errText; + + return null; + }, + [errText, prefix, validateUserAddress] + ); +}; diff --git a/src/lib/pages/interaction/index.tsx b/src/lib/pages/interaction/index.tsx index 1a8f4da28..29c23e530 100644 --- a/src/lib/pages/interaction/index.tsx +++ b/src/lib/pages/interaction/index.tsx @@ -1,17 +1,19 @@ import type { FlexProps } from "@chakra-ui/react"; import { Button, Flex, Heading, Text } from "@chakra-ui/react"; import { useRouter } from "next/router"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { CountBadge } from "lib/components/module/CountBadge"; import PageContainer from "lib/components/PageContainer"; import { EmptyState } from "lib/components/state"; +import type { IndexedModule } from "lib/services/moduleService"; +import type { ExposedFunction } from "lib/types"; +import { ModuleSelectDrawerTrigger } from "./component/drawer"; import { InteractionTypeSwitch, InteractionTabs, } from "./component/InteractionTypeSwitch"; -import { ModuleSelectDrawerButton } from "./component/ModuleSelectDrawerButton"; const containerBaseStyle: FlexProps = { direction: "column", @@ -25,6 +27,19 @@ const containerBaseStyle: FlexProps = { export const Interaction = () => { const { query, isReady } = useRouter(); const [tab, setTab] = useState(); + // TODO: Remove when wiring up this page + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [module, setModule] = useState(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [selectedFn, setSelectedFn] = useState(); + + const handleModuleSelect = useCallback( + (selectedModule: IndexedModule, fn?: ExposedFunction) => { + setModule(selectedModule); + setSelectedFn(fn); + }, + [] + ); useEffect(() => { if (isReady) { @@ -50,7 +65,7 @@ export const Interaction = () => { my={8} >

Select module to interact with ...

- +
{/* Left side */} diff --git a/src/lib/providers/cosmos-kit.tsx b/src/lib/providers/cosmos-kit.tsx index fe53fc3f0..c57f25bf8 100644 --- a/src/lib/providers/cosmos-kit.tsx +++ b/src/lib/providers/cosmos-kit.tsx @@ -4,6 +4,10 @@ import { assets, chains } from "chain-registry"; import { CHAIN_CONFIGS } from "config/chain"; import { useCelatoneApp } from "lib/app-provider"; +import { + initiatestnet, + initiatestnetAssets, +} from "lib/chain-registry/initiatestnet"; import { localosmosis, localosmosisAsset, @@ -35,12 +39,13 @@ export const ChainProvider = ({ children }: { children: React.ReactNode }) => { return ( => { + const result: ResponseModule[] = []; + + const fetchFn = async (paginationKey: string | null) => { + const { data } = await axios.get( + `${baseEndpoint}/initia/move/v1/accounts/${address}/modules${ + paginationKey ? `?pagination.key=${paginationKey}` : "" + }` + ); + result.push(...data.modules); + if (data.pagination.next_key) await fetchFn(data.pagination.next_key); + }; + + await fetchFn(null); + + return snakeToCamel(result); +}; + +export const getAccountModule = async ( + baseEndpoint: string, + address: MoveAccountAddr, + moduleName: string +): Promise => { + const { data } = await axios.get( + `${baseEndpoint}/initia/move/v1/accounts/${address}/modules/${moduleName}` + ); + return snakeToCamel(data.module); +}; + +interface ModuleVerificationReturn { + id: number; + module_address: HexAddr; + module_name: string; + verified_at: string; + digest: string; + source: string; + base64: string; + chain_id: string; +} + +export const getModuleVerificationStatus = async ( + address: MoveAccountAddr, + moduleName: string +): Promise => + // TODO: move url to base api route? wait for celatone api implementation? + axios + .get( + `https://stone-compiler.initia.tech/contracts/${address}/${moduleName}` + ) + .then(() => true) + .catch(() => false); diff --git a/src/lib/services/moduleService.ts b/src/lib/services/moduleService.ts new file mode 100644 index 000000000..71be131e7 --- /dev/null +++ b/src/lib/services/moduleService.ts @@ -0,0 +1,84 @@ +import type { + QueryFunction, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; + +import { CELATONE_QUERY_KEYS, useBaseApiRoute } from "lib/app-provider"; +import type { + MoveAccountAddr, + ExposedFunction, + InternalModule, + ResponseABI, +} from "lib/types"; +import { parseJsonABI, splitViewExecuteFunctions } from "lib/utils"; + +import { + getAccountModule, + getAccountModules, + getModuleVerificationStatus, +} from "./module"; + +export interface IndexedModule extends InternalModule { + parsedAbi: ResponseABI; + viewFunctions: ExposedFunction[]; + executeFunctions: ExposedFunction[]; +} + +const indexModuleResponse = (module: InternalModule): IndexedModule => { + const parsedAbi = parseJsonABI(module.abi); + const { view, execute } = splitViewExecuteFunctions( + parsedAbi.exposed_functions + ); + return { + ...module, + parsedAbi, + viewFunctions: view, + executeFunctions: execute, + }; +}; + +export const useAccountModules = ({ + address, + moduleName, + options = {}, +}: { + address: MoveAccountAddr; + moduleName: string; + options?: Omit, "queryKey">; +}): UseQueryResult => { + const baseEndpoint = useBaseApiRoute("rest"); + const queryFn: QueryFunction = () => + moduleName + ? getAccountModule(baseEndpoint, address, moduleName).then((module) => + indexModuleResponse(module) + ) + : getAccountModules(baseEndpoint, address).then((modules) => + modules.map((module) => indexModuleResponse(module)) + ); + + return useQuery( + [CELATONE_QUERY_KEYS.ACCOUNT_MODULES, baseEndpoint, address, moduleName], + queryFn, + options + ); +}; + +export const useVerifyModule = ({ + address, + moduleName, +}: { + address: MoveAccountAddr; + moduleName: string; +}) => + useQuery( + [CELATONE_QUERY_KEYS.MODULE_VERIFICATION, address, moduleName], + () => getModuleVerificationStatus(address, moduleName), + { + enabled: Boolean(address && moduleName), + retry: 0, + refetchOnWindowFocus: false, + keepPreviousData: true, + } + ); diff --git a/src/lib/types/abi.ts b/src/lib/types/abi.ts new file mode 100644 index 000000000..a2dc9ca22 --- /dev/null +++ b/src/lib/types/abi.ts @@ -0,0 +1,83 @@ +import type { HexAddr } from "./addrs"; +import type { SnakeToCamelCaseNested } from "./converter"; + +export enum UpgradePolicy { + ARBITRARY = "ARBITRARY", + COMPATIBLE = "COMPATIBLE", + IMMUTABLE = "IMMUTABLE", +} + +export type Visibility = "public" | "friend" | "private" | "script"; + +// TODO: revisit address type later +export interface ABIModule { + address: string; + name: string; + functions: ABIFunction[]; +} + +// TODO: revisit moduleAddress type later +interface ABIFunction { + method: "query" | "tx"; + moduleAddress: string; + moduleName: string; + functionName: string; + typeArgsLength: number; + argsTypes: string[]; +} + +/* response */ +export interface ResponseModule { + address: HexAddr; + module_name: string; + abi: string; + raw_bytes: string; + upgrade_policy: UpgradePolicy; +} + +export interface ResponseModules { + modules: ResponseModule[]; + pagination: ModulePagination; +} + +export interface ModulePagination { + next_key: null; + total: string; +} + +export interface ResponseABI { + address: HexAddr; + name: string; + friends: string[]; + exposed_functions: ExposedFunction[]; + structs: Struct[]; +} + +export type InternalModule = SnakeToCamelCaseNested; + +export interface ExposedFunction { + name: string; + visibility: Visibility; + is_view: boolean; + is_entry: boolean; + generic_type_params: GenericTypeParam[]; + params: string[]; + return: string[]; +} + +interface Struct { + name: string; + is_native: boolean; + abilities: string[]; + generic_type_params: GenericTypeParam[]; + fields: Field[]; +} + +interface GenericTypeParam { + constraints: string[]; +} + +interface Field { + name: string; + type: string; +} diff --git a/src/lib/types/addrs.ts b/src/lib/types/addrs.ts index 3d1deb162..bd8b8d0fe 100644 --- a/src/lib/types/addrs.ts +++ b/src/lib/types/addrs.ts @@ -1,7 +1,9 @@ import type { NominalType } from "./currency/common"; export type HumanAddr = string & NominalType<"HumanAddr">; +export type HexAddr = string & NominalType<"HexAddr">; export type ContractAddr = string & NominalType<"ContractAddr">; export type ValidatorAddr = string & NominalType<"ValidatorAddr">; -export type Addr = HumanAddr | ContractAddr; +export type MoveAccountAddr = HumanAddr | HexAddr; +export type Addr = MoveAccountAddr | ContractAddr; diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index c9f53dc81..4fd34b3b3 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -15,4 +15,5 @@ export * from "./upload"; export * from "./block"; export * from "./asset"; export * from "./converter"; +export * from "./abi"; export * from "./json"; diff --git a/src/lib/utils/abi.ts b/src/lib/utils/abi.ts new file mode 100644 index 000000000..88ffc9d34 --- /dev/null +++ b/src/lib/utils/abi.ts @@ -0,0 +1,39 @@ +import type { ExposedFunction, ResponseABI } from "lib/types"; + +export const parseJsonABI = (jsonString: string): ResponseABI => { + try { + return JSON.parse(jsonString); + } catch { + throw new Error(`Failed to parse ABI from JSON string: ${jsonString}`); + } +}; + +const sortByIsEntry = (a: ExposedFunction, b: ExposedFunction) => { + if (a.is_entry === b.is_entry) return 0; + return a.is_entry ? -1 : 1; +}; + +export const splitViewExecuteFunctions = (functions: ExposedFunction[]) => { + const functionMap = functions.reduce<{ + view: ExposedFunction[]; + execute: ExposedFunction[]; + }>( + (acc, fn) => { + if (fn.is_view) { + acc.view.push(fn); + } else { + acc.execute.push(fn); + } + return acc; + }, + { + view: [], + execute: [], + } + ); + + functionMap.view.sort(sortByIsEntry); + functionMap.execute.sort(sortByIsEntry); + + return functionMap; +}; diff --git a/src/lib/utils/address.ts b/src/lib/utils/address.ts index 3388b2e25..c3f258b9d 100644 --- a/src/lib/utils/address.ts +++ b/src/lib/utils/address.ts @@ -1,4 +1,7 @@ +import { fromBech32, fromHex, toBech32, toHex } from "@cosmjs/encoding"; + import type { AddressReturnType } from "lib/app-provider"; +import type { HexAddr, HumanAddr } from "lib/types"; export const getAddressTypeText = (addressType: AddressReturnType) => { switch (addressType) { @@ -13,3 +16,15 @@ export const getAddressTypeText = (addressType: AddressReturnType) => { return "(Invalid Address)"; } }; + +export const bech32AddressToHex = (addr: HumanAddr): HexAddr => + "0x".concat(toHex(fromBech32(addr).data)) as HexAddr; + +export const hexToBech32Address = ( + prefix: string, + hexAddr: HexAddr +): HumanAddr => { + let strip = hexAddr.replace("0x", ""); + if (strip.length < 40) strip = strip.padStart(40, "0"); + return toBech32(prefix, fromHex(strip)) as HumanAddr; +}; diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 256152617..0f0063e21 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -30,3 +30,5 @@ export * from "./icon"; export * from "./window"; export * from "./number"; export * from "./math"; +export * from "./abi"; +export * from "./module"; diff --git a/src/lib/utils/module.ts b/src/lib/utils/module.ts new file mode 100644 index 000000000..611cc60fb --- /dev/null +++ b/src/lib/utils/module.ts @@ -0,0 +1,15 @@ +import type { MoveAccountAddr } from "lib/types"; + +/** + * @input init1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpqr5e3d::any::pack + * @returns [init1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpqr5e3d,any,pack] + */ + +type SplitReturn = + | [MoveAccountAddr, undefined, undefined] + | [MoveAccountAddr, string, undefined] + | [MoveAccountAddr, string, string]; + +export const splitModule = (path: string): SplitReturn => { + return path.split("::") as SplitReturn; +}; diff --git a/src/lib/utils/validate.ts b/src/lib/utils/validate.ts index b9e9ff2e7..64aa39eb2 100644 --- a/src/lib/utils/validate.ts +++ b/src/lib/utils/validate.ts @@ -18,3 +18,18 @@ export const isBlock = (input: string): boolean => { const numberValue = Number(input); return Number.isInteger(numberValue) && numberValue > 0; }; + +export const isHexAddress = (address: string): boolean => { + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + return false; + } + + const strip = address.slice(2); + + try { + fromHex(strip); + } catch { + return false; + } + return true; +};