diff --git a/packages/adena-extension/src/common/constants/tx.constant.ts b/packages/adena-extension/src/common/constants/tx.constant.ts new file mode 100644 index 000000000..15821da05 --- /dev/null +++ b/packages/adena-extension/src/common/constants/tx.constant.ts @@ -0,0 +1,3 @@ +export const DEFAULT_GAS_WANTED = 10_000_000; + +export const TRANSACTION_MESSAGE_SEND_OF_REGISTER = '200000000ugnot'; diff --git a/packages/adena-extension/src/common/errors/validation/index.ts b/packages/adena-extension/src/common/errors/validation/index.ts index eee6d5cbf..a3a577ac5 100644 --- a/packages/adena-extension/src/common/errors/validation/index.ts +++ b/packages/adena-extension/src/common/errors/validation/index.ts @@ -1 +1,3 @@ +export * from './address-book-validation-error'; export * from './password-validation-error'; +export * from './token-validation-error'; diff --git a/packages/adena-extension/src/common/errors/validation/token-validation-error.ts b/packages/adena-extension/src/common/errors/validation/token-validation-error.ts new file mode 100644 index 000000000..96b109bbb --- /dev/null +++ b/packages/adena-extension/src/common/errors/validation/token-validation-error.ts @@ -0,0 +1,23 @@ +import { BaseError } from '../base'; + +const ERROR_VALUE = { + INVALID_REALM_PATH: { + status: 4000, + type: 'INVALID_REALM_PATH', + message: 'Invalid realm path', + }, + ALREADY_ADDED: { + status: 4000, + type: 'ALREADY_ADDED', + message: 'Already added', + }, +}; + +type ErrorType = keyof typeof ERROR_VALUE; + +export class TokenValidationError extends BaseError { + constructor(errorType: ErrorType) { + super(ERROR_VALUE[errorType]); + Object.setPrototypeOf(this, TokenValidationError.prototype); + } +} diff --git a/packages/adena-extension/src/common/provider/adena/adena-provider.tsx b/packages/adena-extension/src/common/provider/adena/adena-provider.tsx index da19a3a6e..07bcfbaa8 100644 --- a/packages/adena-extension/src/common/provider/adena/adena-provider.tsx +++ b/packages/adena-extension/src/common/provider/adena/adena-provider.tsx @@ -1,12 +1,18 @@ -import React, { createContext, useMemo } from 'react'; -import axios from 'axios'; import { AdenaStorage } from '@common/storage'; +import { useWindowSize } from '@hooks/use-window-size'; +import { ChainRepository } from '@repositories/common'; +import { TokenRepository } from '@repositories/common/token'; +import { FaucetRepository } from '@repositories/faucet/faucet'; +import { TransactionHistoryRepository } from '@repositories/transaction'; import { WalletAccountRepository, WalletAddressRepository, WalletEstablishRepository, WalletRepository, } from '@repositories/wallet'; +import { FaucetService } from '@services/faucet'; +import { ChainService, TokenService } from '@services/resource'; +import { TransactionHistoryService, TransactionService } from '@services/transaction'; import { WalletAccountService, WalletAddressBookService, @@ -14,16 +20,10 @@ import { WalletEstablishService, WalletService, } from '@services/wallet'; -import { ChainService, TokenService } from '@services/resource'; -import { ChainRepository } from '@repositories/common'; -import { TransactionHistoryService, TransactionService } from '@services/transaction'; -import { TokenRepository } from '@repositories/common/token'; -import { TransactionHistoryRepository } from '@repositories/transaction'; -import { FaucetService } from '@services/faucet'; -import { FaucetRepository } from '@repositories/faucet/faucet'; -import { useWindowSize } from '@hooks/use-window-size'; -import { useRecoilValue } from 'recoil'; import { NetworkState } from '@states'; +import axios from 'axios'; +import React, { createContext, useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; import { GnoProvider } from '../gno/gno-provider'; export interface AdenaContextProps { @@ -83,7 +83,7 @@ export const AdenaProvider: React.FC> = ({ chil ); const tokenRepository = useMemo( - () => new TokenRepository(localStorage, axiosInstance, currentNetwork), + () => new TokenRepository(localStorage, axiosInstance, currentNetwork, gnoProvider), [localStorage, axiosInstance, currentNetwork], ); diff --git a/packages/adena-extension/src/common/provider/gno/gno-provider.ts b/packages/adena-extension/src/common/provider/gno/gno-provider.ts index 4cacd0d89..9112ab104 100644 --- a/packages/adena-extension/src/common/provider/gno/gno-provider.ts +++ b/packages/adena-extension/src/common/provider/gno/gno-provider.ts @@ -1,14 +1,14 @@ import { GnoJSONRPCProvider } from '@gnolang/gno-js-client'; import { - BlockInfo, - base64ToUint8Array, - newRequest, ABCIEndpoint, ABCIResponse, - RPCResponse, - parseABCI, + base64ToUint8Array, + BlockInfo, BroadcastTxCommitResult, BroadcastTxSyncResult, + newRequest, + parseABCI, + RPCResponse, TransactionEndpoint, } from '@gnolang/tm2-js-client'; import fetchAdapter from '@vespaiach/axios-fetch-adapter'; diff --git a/packages/adena-extension/src/common/utils/parse-utils.ts b/packages/adena-extension/src/common/utils/parse-utils.ts new file mode 100644 index 000000000..1d5ae7b4b --- /dev/null +++ b/packages/adena-extension/src/common/utils/parse-utils.ts @@ -0,0 +1,97 @@ +export const parseGRC20ByABCIRender = ( + response: string, +): { + tokenName: string; + tokenSymbol: string; + tokenDecimals: number; + totalSupply: bigint; + knownAccounts: bigint; +} => { + if (!response) { + throw new Error('failed parse grc20 token render'); + } + + const regex = + /#\s(?.+)\s\(\$(?.+)\)\s*\* \*\*Decimals\*\*: (?\d+)\s*\* \*\*Total supply\*\*: (?\d+)\s*\* \*\*Known accounts\*\*: (?\d+)/; + + const match = response.match(regex); + + if (!match || !match?.groups) { + throw new Error('failed parse grc20 token render'); + } + + return { + tokenName: match.groups.tokenName, + tokenSymbol: match.groups.tokenSymbol, + tokenDecimals: parseInt(match.groups.tokenDecimals, 10), + totalSupply: BigInt(match.groups.totalSupply), + knownAccounts: BigInt(match.groups.knownAccounts), + }; +}; + +/** + * realm's path format: {Domain}/{Type}/{Namespace}/{Remain Path...} + */ +export const parseReamPathItemsByPath = ( + realmPath: string, +): { + domain: string; + type: string; + namespace: string; + remainPath: string; +} => { + const pathItems = realmPath.split('/'); + if (pathItems.length < 4) { + throw new Error('not available realm path, path size less than 4'); + } + + const [domain, type, namespace, ...remainPathItems] = pathItems; + + const availableDomains = ['gno.land']; + if (!availableDomains.includes(domain)) { + throw new Error('not available realm path, domain is ' + domain); + } + + const availableTypes = ['p', 'r']; + if (!availableTypes.includes(type)) { + throw new Error('not available realm path, type is ' + type); + } + + return { + domain, + type, + namespace, + remainPath: remainPathItems.join('/'), + }; +}; + +export const parseGRC20ByFileContents = ( + contents: string, +): { + tokenName: string; + tokenSymbol: string; + tokenDecimals: number; +} | null => { + const newBankerRegex = /grc20\.NewBanker\(([^)]+)\)/; + const match = contents.match(newBankerRegex); + const matchLine = match?.[1] || null; + + if (!matchLine) { + return null; + } + + const args = matchLine.split(',').map((arg) => arg.trim()); + if (args.length < 3) { + return null; + } + + const tokenName = args[0].startsWith('"') ? args[0].slice(1, -1) : args[0]; + const tokenSymbol = args[1].startsWith('"') ? args[1].slice(1, -1) : args[1]; + const tokenDecimals = isNaN(Number(args[2])) ? 0 : Number(args[2]); + + return { + tokenName, + tokenSymbol, + tokenDecimals, + }; +}; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.spec.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.spec.tsx index 3138393a6..ee934f364 100644 --- a/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.spec.tsx +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.spec.tsx @@ -1,14 +1,16 @@ import React from 'react'; + +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { ThemeProvider } from 'styled-components'; -import { render } from '@testing-library/react'; -import theme from '@styles/theme'; -import { GlobalPopupStyle } from '@styles/global-style'; import AdditionalTokenInfo, { AdditionalTokenInfoProps } from './additional-token-info'; describe('AdditionalTokenInfo Component', () => { it('AdditionalTokenInfo render', () => { const args: AdditionalTokenInfoProps = { + isLoading: false, symbol: 'GNOT', path: 'gno.land/gnot', decimals: '6', diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.styles.ts b/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.styles.ts index a9f992c31..59c663709 100644 --- a/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.styles.ts +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.styles.ts @@ -1,3 +1,4 @@ +import { SkeletonBoxStyle } from '@components/atoms'; import mixins from '@styles/mixins'; import { fonts, getTheme } from '@styles/theme'; import styled from 'styled-components'; @@ -37,3 +38,8 @@ export const AdditionalTokenInfoItemWrapper = styled.div` white-space: nowrap; } `; + +export const TokenInfoValueLoadingBox = styled(SkeletonBoxStyle)` + width: 40px; + height: 10px; +`; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.tsx index 76b7f14e2..60a739fb6 100644 --- a/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.tsx +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-info/additional-token-info.tsx @@ -1,13 +1,19 @@ import React from 'react'; -import { AdditionalTokenInfoWrapper, AdditionalTokenInfoItemWrapper } from './additional-token-info.styles'; +import { + AdditionalTokenInfoItemWrapper, + AdditionalTokenInfoWrapper, + TokenInfoValueLoadingBox, +} from './additional-token-info.styles'; export interface AdditionalTokenInfoProps { + isLoading: boolean; symbol: string; path: string; decimals: string; } export interface AdditionalTokenInfoBlockProps { + isLoading: boolean; title: string; value: string; } @@ -15,36 +21,30 @@ export interface AdditionalTokenInfoBlockProps { const AdditionalTokenInfoBlock: React.FC = ({ title, value, + isLoading, }) => { return ( {title}: - {value} + + {isLoading ? : {value}} ); }; const AdditionalTokenInfo: React.FC = ({ + isLoading, symbol, path, decimals, }) => { return ( - - - + + + ); }; -export default AdditionalTokenInfo; \ No newline at end of file +export default AdditionalTokenInfo; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.spec.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.spec.tsx new file mode 100644 index 000000000..0457d510a --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.spec.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; + +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; + +import AdditionalTokenPathInput, { + AdditionalTokenPathInputProps, +} from './additional-token-path-input'; + +describe('AdditionalTokenPathInput Component', () => { + it('AdditionalTokenPathInput render', () => { + const args: AdditionalTokenPathInputProps = { + keyword: '', + onChangeKeyword: () => { + return; + }, + errorMessage: 'error', + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.stories.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.stories.tsx new file mode 100644 index 000000000..cb7324345 --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.stories.tsx @@ -0,0 +1,13 @@ +import { Meta, StoryObj } from '@storybook/react'; +import AdditionalTokenPathInput, { + type AdditionalTokenPathInputProps, +} from './additional-token-path-input'; + +export default { + title: 'components/additional-token/AdditionalTokenPathInput', + component: AdditionalTokenPathInput, +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.styles.ts b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.styles.ts new file mode 100644 index 000000000..b6be7816a --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.styles.ts @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +import { View } from '@components/atoms'; +import { fonts, getTheme } from '@styles/theme'; + +export const AdditionalTokenPathInputWrapper = styled(View)` + width: 100%; + + .search-input { + height: 48px; + padding: 13px 16px; + background-color: ${getTheme('neutral', '_9')}; + border: 1px solid ${getTheme('neutral', '_7')}; + color: ${getTheme('neutral', '_1')}; + border-radius: 30px; + ${fonts.body2Reg}; + + &.error { + border-color: ${getTheme('red', '_5')}; + } + } + + .error-message { + padding: 0 8px; + height: 18px; + } +`; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.tsx new file mode 100644 index 000000000..5fbb25fbf --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/additional-token-path-input.tsx @@ -0,0 +1,39 @@ +import { Text } from '@components/atoms'; +import theme from '@styles/theme'; +import React, { useMemo } from 'react'; +import { AdditionalTokenPathInputWrapper } from './additional-token-path-input.styles'; + +export interface AdditionalTokenPathInputProps { + keyword: string; + onChangeKeyword: (keyword: string) => void; + errorMessage: string | null; +} + +const AdditionalTokenPathInput: React.FC = ({ + keyword, + onChangeKeyword, + errorMessage, +}) => { + const hasError = useMemo(() => { + return !!errorMessage; + }, [errorMessage]); + + return ( + + onChangeKeyword(event.target.value)} + placeholder='Search' + /> + + {hasError && ( + + {errorMessage} + + )} + + ); +}; + +export default AdditionalTokenPathInput; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/index.ts b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/index.ts new file mode 100644 index 000000000..cb4e045ad --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-path-input/index.ts @@ -0,0 +1 @@ +export * from './additional-token-path-input'; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.spec.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.spec.tsx new file mode 100644 index 000000000..431a87c0a --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { ThemeProvider } from 'styled-components'; +import AdditionalTokenTypeSelector, { + AddingType, + AdditionalTokenTypeSelectorProps, +} from './additional-token-type-selector'; + +describe('AdditionalTokenTypeSelector Component', () => { + it('AdditionalTokenTypeSelector render', () => { + const args: AdditionalTokenTypeSelectorProps = { + setType: () => { + return; + }, + type: AddingType.MANUAL, + }; + + render( + + + + + + , + ); + }); +}); diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.stories.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.stories.tsx new file mode 100644 index 000000000..cc7dd8a6f --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.stories.tsx @@ -0,0 +1,13 @@ +import { Meta, StoryObj } from '@storybook/react'; +import AdditionalTokenTypeSelector, { + type AdditionalTokenTypeSelectorProps, +} from './additional-token-type-selector'; + +export default { + title: 'components/manage-token/AdditionalTokenTypeSelector', + component: AdditionalTokenTypeSelector, +} as Meta; + +export const Default: StoryObj = { + args: {}, +}; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.styles.ts b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.styles.ts new file mode 100644 index 000000000..138da6e36 --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.styles.ts @@ -0,0 +1,32 @@ +import { Row, View } from '@components/atoms'; +import { fonts, getTheme } from '@styles/theme'; +import styled from 'styled-components'; + +export const StyledAdditionalTokenTypeSelectorWrapper = styled(Row)` + display: flex; + width: 100%; + height: 48px; + padding: 5px; + gap: 10px; + justify-content: center; + align-items: center; + border-radius: 30px; + background-color: ${getTheme('neutral', '_9')}; +`; + +export const StyledAdditionalTokenTypeSelector = styled(View)` + display: flex; + width: 100%; + height: 100%; + justify-content: center; + align-items: center; + border-radius: 30px; + color: ${getTheme('neutral', 'a')}; + ${fonts.body2Reg} + cursor: pointer; + + &.selected { + color: ${getTheme('neutral', '_1')}; + background-color: ${getTheme('neutral', '_7')}; + } +`; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.tsx new file mode 100644 index 000000000..84f8ec381 --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/additional-token-type-selector.tsx @@ -0,0 +1,53 @@ +import React, { useCallback } from 'react'; +import { + StyledAdditionalTokenTypeSelector, + StyledAdditionalTokenTypeSelectorWrapper, +} from './additional-token-type-selector.styles'; + +export enum AddingType { + 'SEARCH', + 'MANUAL', +} + +const displayTypeNames = { + [AddingType.SEARCH]: 'Search', + [AddingType.MANUAL]: 'Manual', +}; + +export interface AdditionalTokenTypeSelectorProps { + type: AddingType; + setType: (type: AddingType) => void; +} + +const AdditionalTokenTypeSelector: React.FC = ({ + type, + setType, +}) => { + const types: AddingType[] = [AddingType.SEARCH, AddingType.MANUAL]; + + const onClickSelector = useCallback( + (selected: AddingType) => { + if (selected === type) { + return; + } + setType(selected); + }, + [type], + ); + + return ( + + {types.map((item, index) => ( + onClickSelector(item)} + > + {displayTypeNames[item]} + + ))} + + ); +}; + +export default AdditionalTokenTypeSelector; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/index.ts b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/index.ts new file mode 100644 index 000000000..22a8311ea --- /dev/null +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token-type-selector/index.ts @@ -0,0 +1 @@ +export * from './additional-token-type-selector'; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.spec.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.spec.tsx index 06e16012b..6ec910a56 100644 --- a/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.spec.tsx +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.spec.tsx @@ -1,22 +1,35 @@ import React from 'react'; + +import { GlobalPopupStyle } from '@styles/global-style'; +import theme from '@styles/theme'; +import { render } from '@testing-library/react'; +import { AdditionalTokenProps } from '@types'; import { RecoilRoot } from 'recoil'; import { ThemeProvider } from 'styled-components'; -import { render } from '@testing-library/react'; -import theme from '@styles/theme'; -import { GlobalPopupStyle } from '@styles/global-style'; import AdditionalToken from '.'; -import { AdditionalTokenProps } from '@types'; +import { AddingType } from '../additional-token-type-selector'; describe('AdditionalToken Component', () => { it('AdditionalToken render', () => { const args: AdditionalTokenProps = { opened: false, + addingType: AddingType.MANUAL, selected: true, keyword: '', + manualTokenPath: '', + selectedTokenInfo: null, tokenInfos: [], + isLoadingManualGRC20Token: false, + errorManualGRC20Token: null, + selectAddingType: () => { + return; + }, onChangeKeyword: () => { return; }, + onChangeManualTokenPath: () => { + return; + }, onClickOpenButton: () => { return; }, diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.styles.ts b/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.styles.ts index 2332f6462..c2f10070c 100644 --- a/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.styles.ts +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token/additional-token.styles.ts @@ -14,6 +14,12 @@ export const AdditionalTokenWrapper = styled.div` margin-bottom: 24px; } + .type-selector-wrapper { + display: flex; + width: 100%; + margin-bottom: 12px; + } + .select-box-wrapper { display: flex; margin-bottom: 12px; diff --git a/packages/adena-extension/src/components/pages/additional-token/additional-token/index.tsx b/packages/adena-extension/src/components/pages/additional-token/additional-token/index.tsx index c84f891a2..f958b94f6 100644 --- a/packages/adena-extension/src/components/pages/additional-token/additional-token/index.tsx +++ b/packages/adena-extension/src/components/pages/additional-token/additional-token/index.tsx @@ -1,25 +1,59 @@ -import React from 'react'; -import { AdditionalTokenWrapper } from './additional-token.styles'; -import AdditionalTokenSelectBox from '@components/pages/additional-token/additional-token-select-box/additional-token-select-box'; -import AdditionalTokenInfo from '@components/pages/additional-token/additional-token-info/additional-token-info'; -import { SubHeader } from '@components/atoms'; import LeftArrowIcon from '@assets/arrowL-left.svg'; -import { AdditionalTokenProps } from '@types'; import { makeDisplayPackagePath } from '@common/utils/string-utils'; +import { SubHeader } from '@components/atoms'; +import AdditionalTokenInfo from '@components/pages/additional-token/additional-token-info/additional-token-info'; +import AdditionalTokenSelectBox from '@components/pages/additional-token/additional-token-select-box/additional-token-select-box'; +import { AdditionalTokenProps } from '@types'; +import React, { useMemo } from 'react'; +import AdditionalTokenPathInput from '../additional-token-path-input/additional-token-path-input'; +import AdditionalTokenTypeSelector, { + AddingType, +} from '../additional-token-type-selector/additional-token-type-selector'; +import { AdditionalTokenWrapper } from './additional-token.styles'; const AdditionalToken: React.FC = ({ opened, selected, + addingType, keyword, + manualTokenPath, tokenInfos, selectedTokenInfo, + isLoadingManualGRC20Token, + errorManualGRC20Token, + selectAddingType, onChangeKeyword, + onChangeManualTokenPath, onClickOpenButton, onClickListItem, onClickBack, onClickCancel, onClickAdd, }) => { + const isSearchType = useMemo(() => { + return addingType === AddingType.SEARCH; + }, [addingType]); + + const isLoadingTokenInfo = useMemo(() => { + if (addingType === AddingType.SEARCH) { + return false; + } + + return isLoadingManualGRC20Token; + }, [addingType, isLoadingManualGRC20Token]); + + const tokenPathInputErrorMessage = useMemo(() => { + if (!errorManualGRC20Token) { + return null; + } + + return errorManualGRC20Token.message; + }, [errorManualGRC20Token]); + + const enabledAddButton = useMemo(() => { + return selectedTokenInfo && !errorManualGRC20Token; + }, [selectedTokenInfo, errorManualGRC20Token]); + return (
@@ -32,30 +66,48 @@ const AdditionalToken: React.FC = ({ />
+
+ +
+
- + {isSearchType ? ( + + ) : ( + + )}
+
+
-
diff --git a/packages/adena-extension/src/hooks/use-debounce.ts b/packages/adena-extension/src/hooks/use-debounce.ts new file mode 100644 index 000000000..9fc232f04 --- /dev/null +++ b/packages/adena-extension/src/hooks/use-debounce.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +interface UseDebounceReturn { + debouncedValue: T; + setDebouncedValue: React.Dispatch>; + isLoading: boolean; +} + +export const useDebounce = (value: T, delay: number): UseDebounceReturn => { + const [debouncedValue, setDebouncedValue] = useState(value); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + const handler = setTimeout(() => { + setIsLoading(false); + setDebouncedValue(value); + }, delay); + + // Clean up the timeout if value or delay changes + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return { debouncedValue, setDebouncedValue, isLoading }; +}; diff --git a/packages/adena-extension/src/hooks/use-grc20-token.ts b/packages/adena-extension/src/hooks/use-grc20-token.ts new file mode 100644 index 000000000..c725801ed --- /dev/null +++ b/packages/adena-extension/src/hooks/use-grc20-token.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { GRC20TokenModel } from '@types'; +import { useAdenaContext } from './use-context'; +import { useNetwork } from './use-network'; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useGRC20Token = (tokenPath: string) => { + const { tokenService } = useAdenaContext(); + const { currentNetwork } = useNetwork(); + + return useQuery({ + queryKey: ['grc20-token', currentNetwork.networkId, tokenPath], + queryFn: () => tokenService.fetchGRC20Token(tokenPath), + }); +}; diff --git a/packages/adena-extension/src/inject/message/methods/core.ts b/packages/adena-extension/src/inject/message/methods/core.ts index bf4f0d8aa..d3aa279af 100644 --- a/packages/adena-extension/src/inject/message/methods/core.ts +++ b/packages/adena-extension/src/inject/message/methods/core.ts @@ -1,5 +1,5 @@ -import axios from 'axios'; import { Account } from 'adena-module'; +import axios from 'axios'; import { GnoProvider } from '@common/provider/gno/gno-provider'; import { AdenaStorage } from '@common/storage'; @@ -40,7 +40,12 @@ export class InjectCore { private chainRepository = new ChainRepository(this.localStorage, this.axiosInstance); - private tokenRepository = new TokenRepository(this.localStorage, this.axiosInstance, null); + private tokenRepository = new TokenRepository( + this.localStorage, + this.axiosInstance, + null, + this.gnoProvider, + ); public chainService = new ChainService(this.chainRepository); diff --git a/packages/adena-extension/src/pages/popup/wallet/manage-token-added/index.tsx b/packages/adena-extension/src/pages/popup/wallet/manage-token-added/index.tsx index d6f7251cc..32d62f06c 100644 --- a/packages/adena-extension/src/pages/popup/wallet/manage-token-added/index.tsx +++ b/packages/adena-extension/src/pages/popup/wallet/manage-token-added/index.tsx @@ -1,13 +1,18 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { TokenValidationError } from '@common/errors'; +import { parseReamPathItemsByPath } from '@common/utils/parse-utils'; +import { isGRC20TokenModel } from '@common/validation'; import AdditionalToken from '@components/pages/additional-token/additional-token'; -import { useTokenMetainfo } from '@hooks/use-token-metainfo'; -import { RoutePath } from '@types'; +import { AddingType } from '@components/pages/additional-token/additional-token-type-selector'; import { ManageTokenLayout } from '@components/pages/manage-token-layout'; -import { TokenInfo } from '@types'; import useAppNavigate from '@hooks/use-app-navigate'; +import { useDebounce } from '@hooks/use-debounce'; +import { useGRC20Token } from '@hooks/use-grc20-token'; import { useGRC20Tokens } from '@hooks/use-grc20-tokens'; import { useNetwork } from '@hooks/use-network'; +import { useTokenMetainfo } from '@hooks/use-token-metainfo'; +import { RoutePath, TokenInfo } from '@types'; const ManageTokenAddedContainer: React.FC = () => { const { navigate, goBack } = useAppNavigate(); @@ -16,21 +21,77 @@ const ManageTokenAddedContainer: React.FC = () => { const [opened, setOpened] = useState(false); const [selected, setSelected] = useState(false); const [keyword, setKeyword] = useState(''); - const [selectedTokenInfo, setSelectedTokenInfo] = useState(); + const [selectedTokenInfo, setSelectedTokenInfo] = useState(null); const [finished, setFinished] = useState(false); + const [addingType, setAddingType] = useState(AddingType.SEARCH); + const [manualTokenPath, setManualTokenPath] = useState(''); - useEffect(() => { - document.body.addEventListener('click', closeSelectBox); - return () => document.body.removeEventListener('click', closeSelectBox); - }, [document.body]); + const { data: grc20Tokens } = useGRC20Tokens(); - useEffect(() => { - if (finished) { - goBack(); + const { + debouncedValue: debouncedManualTokenPath, + setDebouncedValue: setDebouncedManualTokenPath, + isLoading: isLoadingDebounce, + } = useDebounce(manualTokenPath, 500); + const { data: manualGRC20Token, isFetching: isFetchingManualGRC20Token } = + useGRC20Token(debouncedManualTokenPath); + + const isValidManualGRC20Token = useMemo(() => { + if (manualTokenPath === '') { + return true; } - }, [finished]); - const { data: grc20Tokens } = useGRC20Tokens(); + try { + parseReamPathItemsByPath(manualTokenPath); + return true; + } catch { + return false; + } + }, [manualTokenPath]); + + const isLoadingManualGRC20Token = useMemo(() => { + if (!isValidManualGRC20Token) { + return false; + } + + return isLoadingDebounce || isFetchingManualGRC20Token; + }, [isValidManualGRC20Token, isLoadingDebounce, isFetchingManualGRC20Token]); + + const errorManualGRC20Token = useMemo(() => { + if (manualTokenPath === '') { + return null; + } + + if (!isValidManualGRC20Token) { + return new TokenValidationError('INVALID_REALM_PATH'); + } + + if (isLoadingManualGRC20Token) { + return null; + } + + if (manualGRC20Token === null) { + return new TokenValidationError('INVALID_REALM_PATH'); + } + + const isRegistered = tokenMetainfos.some((tokenMetaInfo) => { + if (tokenMetaInfo.tokenId === manualTokenPath) { + return true; + } + + if (isGRC20TokenModel(tokenMetaInfo)) { + return tokenMetaInfo.pkgPath === manualTokenPath; + } + + return false; + }); + + if (isRegistered) { + return new TokenValidationError('ALREADY_ADDED'); + } + + return null; + }, [tokenMetainfos, isLoadingManualGRC20Token, manualGRC20Token, manualTokenPath]); const tokenInfos: TokenInfo[] = useMemo(() => { if (!grc20Tokens) { @@ -76,9 +137,27 @@ const ManageTokenAddedContainer: React.FC = () => { setKeyword(keyword); }, []); + const onChangeManualTokenPath = useCallback((tokenPath: string) => { + setManualTokenPath(tokenPath); + }, []); + + const selectAddingType = useCallback((addingType: AddingType) => { + setAddingType(addingType); + setKeyword(''); + setManualTokenPath(''); + setDebouncedManualTokenPath(''); + setSelectedTokenInfo(null); + setOpened(false); + setSelected(false); + }, []); + const onClickListItem = useCallback( (tokenId: string) => { const tokenInfo = tokenInfos?.find((tokenInfo) => tokenInfo.tokenId === tokenId); + if (!tokenInfo) { + return; + } + setSelected(true); setSelectedTokenInfo(tokenInfo); setOpened(false); @@ -91,6 +170,10 @@ const ManageTokenAddedContainer: React.FC = () => { }, []); const onClickAdd = useCallback(async () => { + if (errorManualGRC20Token) { + return; + } + if (!selected || !selectedTokenInfo || finished) { return; } @@ -99,15 +182,59 @@ const ManageTokenAddedContainer: React.FC = () => { setFinished(true); }, [selected, selectedTokenInfo, finished]); + useEffect(() => { + document.body.addEventListener('click', closeSelectBox); + return () => document.body.removeEventListener('click', closeSelectBox); + }, [document.body]); + + useEffect(() => { + if (finished) { + goBack(); + } + }, [finished]); + + useEffect(() => { + if (addingType === AddingType.SEARCH) { + return; + } + + if (isLoadingManualGRC20Token) { + setSelectedTokenInfo(null); + return; + } + + if (!manualGRC20Token) { + setSelectedTokenInfo(null); + return; + } + + setSelected(true); + setSelectedTokenInfo({ + tokenId: manualGRC20Token.tokenId, + name: manualGRC20Token.name, + symbol: manualGRC20Token.symbol, + path: manualGRC20Token.pkgPath, + decimals: manualGRC20Token.decimals, + chainId: manualGRC20Token.networkId, + pathInfo: manualGRC20Token.pkgPath.replace('gno.land/', ''), + }); + }, [addingType, manualGRC20Token, isLoadingManualGRC20Token]); + return ( { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + const fileContents = await this.gnoProvider.getFileContent(packagePath).catch(() => null); + const fileNames = fileContents?.split('\n') || []; + + if (fileContents === null || fileNames.length === 0) { + throw new Error('Not available realm'); + } + + const renderTokenInfo = await this.fetchGRC20TokenInfoQueryRender(packagePath).catch( + () => null, + ); + if (renderTokenInfo) { + return renderTokenInfo; + } + + const fileTokenInfo = await this.fetchGRC20TokenInfoQueryFiles(packagePath, fileNames).catch( + () => null, + ); + if (fileTokenInfo) { + return fileTokenInfo; + } + + throw new Error('Realm is not GRC20'); + } + public fetchAllGRC20Tokens = async (): Promise => { if (this.apiUrl) { const tokens = await TokenRepository.postRPCRequest<{ @@ -255,6 +290,67 @@ export class TokenRepository { .catch(() => []); }; + private async fetchGRC20TokenInfoQueryRender( + packagePath: string, + ): Promise { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + const { tokenName, tokenSymbol, tokenDecimals } = await this.gnoProvider + .getRenderOutput(packagePath, '') + .then(parseGRC20ByABCIRender); + + return { + main: false, + tokenId: packagePath, + pkgPath: packagePath, + networkId: this.networkId, + display: false, + type: 'grc20', + name: tokenName, + symbol: tokenSymbol, + decimals: tokenDecimals, + image: '', + }; + } + + private async fetchGRC20TokenInfoQueryFiles( + packagePath: string, + fileNames: string[], + ): Promise { + if (!this.gnoProvider) { + throw new Error('Gno provider not initialized.'); + } + + for (const fileName of fileNames) { + const filePath = [packagePath, fileName].join('/'); + const contents = await this.gnoProvider.getFileContent(filePath).catch(() => null); + if (!contents) { + continue; + } + + const tokenInfo = parseGRC20ByFileContents(contents); + + if (tokenInfo) { + return { + main: false, + tokenId: packagePath, + pkgPath: packagePath, + networkId: this.networkId, + display: false, + type: 'grc20', + name: tokenInfo.tokenName, + symbol: tokenInfo.tokenSymbol, + decimals: tokenInfo.tokenDecimals, + image: '', + }; + } + } + + return null; + } + private static postRPCRequest = ( axiosInstance: AxiosInstance, url: string, diff --git a/packages/adena-extension/src/services/resource/token.ts b/packages/adena-extension/src/services/resource/token.ts index cfff91070..e777e3b04 100644 --- a/packages/adena-extension/src/services/resource/token.ts +++ b/packages/adena-extension/src/services/resource/token.ts @@ -1,7 +1,8 @@ +import { parseReamPathItemsByPath } from '@common/utils/parse-utils'; import { isGRC20TokenModel, isNativeTokenModel } from '@common/validation/validation-token'; import { AppInfoResponse, TokenRepository } from '@repositories/common'; -import { GRC20TokenModel, TokenModel, AccountTokenBalance, NetworkMetainfo } from '@types'; +import { AccountTokenBalance, GRC20TokenModel, NetworkMetainfo, TokenModel } from '@types'; export class TokenService { private tokenRepository: TokenRepository; @@ -37,6 +38,21 @@ export class TokenService { .then((tokens) => tokens.filter((token) => !!token)); } + public async fetchGRC20Token(tokenPath: string): Promise { + if (!tokenPath) { + return null; + } + + // validate realm path + try { + parseReamPathItemsByPath(tokenPath); + } catch { + return null; + } + + return this.tokenRepository.fetchGRC20TokenByPackagePath(tokenPath).catch(() => null); + } + public async getAppInfos(): Promise { const response = await this.tokenRepository.fetchAppInfos(); return response; diff --git a/packages/adena-extension/src/services/transaction/message/vm/vm-call-users.ts b/packages/adena-extension/src/services/transaction/message/vm/vm-call-users.ts index 3bbd9c2c5..e489eb239 100644 --- a/packages/adena-extension/src/services/transaction/message/vm/vm-call-users.ts +++ b/packages/adena-extension/src/services/transaction/message/vm/vm-call-users.ts @@ -1,3 +1,4 @@ +import { TRANSACTION_MESSAGE_SEND_OF_REGISTER } from '@common/constants/tx.constant'; import { createMessageOfVmCall } from './vm'; export const createMessageOfVmRegister = (info: { @@ -11,7 +12,7 @@ export const createMessageOfVmRegister = (info: { value: { caller: string; send: string; pkg_path: string; func: string; args: string[] }; } => { const invitor = info.invitor ?? ''; - const send = info.send ?? '200000000ugnot'; + const send = info.send ?? TRANSACTION_MESSAGE_SEND_OF_REGISTER; return createMessageOfVmCall({ caller: info.address, pkgPath: 'gno.land/r/users', diff --git a/packages/adena-extension/src/types/token.ts b/packages/adena-extension/src/types/token.ts index 98d265e6e..7a3b19c5e 100644 --- a/packages/adena-extension/src/types/token.ts +++ b/packages/adena-extension/src/types/token.ts @@ -1,3 +1,6 @@ +import { BaseError } from '@common/errors'; +import { AddingType } from '@components/pages/additional-token/additional-token-type-selector'; + export interface TokenModel { main: boolean; tokenId: string; @@ -59,11 +62,17 @@ export interface TokenInfo { export interface AdditionalTokenProps { opened: boolean; + addingType: AddingType; selected: boolean; keyword: string; + manualTokenPath: string; tokenInfos: TokenInfo[]; - selectedTokenInfo?: TokenInfo; + selectedTokenInfo: TokenInfo | null; + isLoadingManualGRC20Token: boolean; + errorManualGRC20Token: BaseError | null; + selectAddingType: (type: AddingType) => void; onChangeKeyword: (keyword: string) => void; + onChangeManualTokenPath: (keyword: string) => void; onClickOpenButton: (opened: boolean) => void; onClickListItem: (tokenId: string) => void; onClickBack: () => void;