diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d8682527e6..c0b016dabe 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -338,14 +338,15 @@ "options": [ "main", "main.L2", - "poa_core", + "eth", "eth_goerli", "sepolia", - "eth", - "rootstock", "polygon", "zkevm", "gnosis", + "rootstock", + "stability", + "poa_core", "localhost", ], "default": "main" diff --git a/configs/envs/.env.stability b/configs/envs/.env.stability new file mode 100644 index 0000000000..a395322118 --- /dev/null +++ b/configs/envs/.env.stability @@ -0,0 +1,60 @@ +# Set of ENVs for Ethereum network explorer +# https://eth.blockscout.com/ + +# app configuration +NEXT_PUBLIC_APP_PROTOCOL=http +NEXT_PUBLIC_APP_HOST=localhost +NEXT_PUBLIC_APP_PORT=3000 + +# blockchain parameters +NEXT_PUBLIC_NETWORK_NAME=Stability Testnet +NEXT_PUBLIC_NETWORK_SHORT_NAME=Stability +NEXT_PUBLIC_NETWORK_ID=20180427 +NEXT_PUBLIC_NETWORK_CURRENCY_NAME=FREE +NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=FREE +NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18 +NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE=validation +NEXT_PUBLIC_NETWORK_RPC_URL=https://free.testnet.stabilityprotocol.com +NEXT_PUBLIC_IS_TESTNET=true + +# api configuration +NEXT_PUBLIC_API_HOST=stability-testnet.blockscout.com +NEXT_PUBLIC_API_BASE_PATH=/ + +# ui config +## homepage +NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs'] +NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="rgb(255, 145, 0)" +NEXT_PUBLIC_HOMEPAGE_PLATE_BACKGROUND="rgba(46, 51, 81, 1)" +NEXT_PUBLIC_HOMEPAGE_PLATE_TEXT_COLOR="rgba(122, 235, 246, 1)" +## sidebar +NEXT_PUBLIC_FEATURED_NETWORKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/featured-networks/rsk-testnet.json +NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/stability.svg +NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/stability-dark.svg +NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/stability-short.svg +NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/stability-short-dark.svg +## footer +## views +NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS="['top_accounts']" +NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS="['value','fee_currency','gas_price','gas_fees','burnt_fees']" +NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS="['fee_per_gas']" +NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS="['burnt_fees','total_reward']" +## misc + +# app features +NEXT_PUBLIC_APP_ENV=development +NEXT_PUBLIC_HAS_BEACON_CHAIN=false +NEXT_PUBLIC_STATS_API_HOST=https://stats-stability-testnet.k8s.blockscout.com +NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com +NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com +NEXT_PUBLIC_CONTRACT_CODE_IDES="[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout={domain}','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]" +NEXT_PUBLIC_MARKETPLACE_ENABLED=true +NEXT_PUBLIC_MARKETPLACE_CATEGORIES_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/marketplace-categories/default.json +NEXT_PUBLIC_MARKETPLACE_SUBMIT_FORM=https://airtable.com/shrqUAcjgGJ4jU88C +NEXT_PUBLIC_MARKETPLACE_SUGGEST_IDEAS_FORM=https://airtable.com/appiy5yijZpMMSKjT/pag3t82DUCyhGRZZO/form +NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com/ +NEXT_PUBLIC_GAS_TRACKER_ENABLED=false +NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE='stability' + +#meta +NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/stability.png diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 08dc19eaf2..58dcae80c9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -57,7 +57,7 @@ B. Pre-defined configuration: 1. Optionally, clone `.env.example` file into `configs/envs/.env.secrets`. Fill it with necessary secrets for integrating with [external services](./ENVS.md#external-services-configuration). Include only secrets your need. 2. Choose one of the predefined configurations located in the `/configs/envs` folder. -3. Start your local dev server using the `yarn dev:` command. +3. Start your local dev server using the `yarn dev:preset ` command. 4. Open your browser and navigate to the URL provided in the command line output (by default, it is `http://localhost:3000`). @@ -79,18 +79,21 @@ These are the steps that you have to follow to make everything work: 2. Make sure that you have added a property to React app config (`configs/app/index.ts`) in appropriate section that is associated with this variable; do not use ENV variable values directly in the application code; decide where this variable belongs to and place it under the certain section: - `app` - the front-end app itself - `api` - the main API configuration + - `chain` - the Blockchain parameters - `UI` - the app UI customization + - `meta` - SEO and meta-tags customization - `features` - the particular feature of the app - - `services` - some 3rd party service integration which is not related to one particular feature -3. For local development purposes add the variable with its appropriate values to pre-defined ENV configs `configs/envs` where it is needed -4. Add the variable to CI configs where it is needed + - `services` - some 3rd party service integration which is not related to one particular feature +3. If a new variable is meant to store the URL of an external API service, remember to include its value in the Content-Security-Policy document header. Refer to `nextjs/csp/policies/app.ts` for details. +4. For local development purposes add the variable with its appropriate values to pre-defined ENV configs `configs/envs` where it is needed +5. Add the variable to CI configs where it is needed - `deploy/values/review/values.yaml.gotmpl` - review development environment - `deploy/values/main/values.yaml` - main development environment - `deploy/values/review-l2/values.yaml.gotmpl` - review development environment for L2 networks - `deploy/values/l2-optimism-goerli/values.yaml` - main development environment -5. If your variable is meant to receive a link to some external resource (image or JSON-config file), extend the array `ASSETS_ENVS` in `deploy/scripts/download_assets.sh` with your variable name -6. Add validation schema for the new variable into the file `deploy/tools/envs-validator/schema.ts` -7. Check if modified validation schema is valid by doing the following steps: +6. If your variable is meant to receive a link to some external resource (image or JSON-config file), extend the array `ASSETS_ENVS` in `deploy/scripts/download_assets.sh` with your variable name +7. Add validation schema for the new variable into the file `deploy/tools/envs-validator/schema.ts` +8. Check if modified validation schema is valid by doing the following steps: - change your current directory to `deploy/tools/envs-validator` - install deps with `yarn` command - add your variable into `./test/.env.base` test preset or create a new test preset if needed @@ -98,7 +101,7 @@ These are the steps that you have to follow to make everything work: - add example of file content into `./test/assets` directory; the file name should be constructed by stripping away prefix `NEXT_PUBLIC_` and postfix `_URL` if any, and converting the remaining string to lowercase (for example, `NEXT_PUBLIC_MARKETPLACE_CONFIG_URL` will become `marketplace_config.json`) - in the main script `index.ts` extend array `envsWithJsonConfig` with your variable name - run `yarn test` command to see the validation result -8. Don't forget to mention in the PR notes that new ENV variable was added +9. Don't forget to mention in the PR notes that new ENV variable was added   diff --git a/lib/api/resources.ts b/lib/api/resources.ts index dc259df435..3d79b00062 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -672,7 +672,7 @@ export const RESOURCES = { validators: { path: '/api/v2/validators/:chainType', pathParams: [ 'chainType' as const ], - filterFields: [ 'address_hash' as const, 'state' as const ], + filterFields: [ 'address_hash' as const, 'state_filter' as const ], }, validators_counters: { path: '/api/v2/validators/:chainType/counters', diff --git a/lib/metadata/templates/title.ts b/lib/metadata/templates/title.ts index c238b665c6..57abbca8af 100644 --- a/lib/metadata/templates/title.ts +++ b/lib/metadata/templates/title.ts @@ -14,7 +14,7 @@ const TEMPLATE_MAP: Record = { '/address/[hash]/contract-verification': 'contract verification for %hash%', '/tokens': 'tokens', '/token/[hash]': '%symbol% token details', - '/token/[hash]/instance/[id]': 'token instance for %symbol%', + '/token/[hash]/instance/[id]': 'NFT instance', '/apps': 'apps marketplace', '/apps/[id]': '- %app_name%', '/stats': 'statistics', diff --git a/lib/metadata/update.ts b/lib/metadata/update.ts index 0171973cb7..f6168c1ae6 100644 --- a/lib/metadata/update.ts +++ b/lib/metadata/update.ts @@ -5,11 +5,8 @@ import type { Route } from 'nextjs-routes'; import generate from './generate'; export default function update(route: R, apiData: ApiData) { - const { title, description, opengraph } = generate(route, apiData); + const { title, description } = generate(route, apiData); window.document.title = title; window.document.querySelector('meta[name="description"]')?.setAttribute('content', description); - window.document.querySelector('meta[property="og:title"]')?.setAttribute('content', opengraph.title); - opengraph.description && - window.document.querySelector('meta[property="og:description"]')?.setAttribute('content', opengraph.description); } diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index db7a89b34a..2fb6455497 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -17,7 +17,8 @@ export enum EventTypes { PAGE_WIDGET = 'Page widget', TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction', EXPERIMENT_STARTED = 'Experiment started', - FILTERS = 'Filters' + FILTERS = 'Filters', + BUTTON_CLICK = 'Button click', } /* eslint-disable @typescript-eslint/indent */ @@ -107,5 +108,9 @@ Type extends EventTypes.FILTERS ? { 'Source': 'Marketplace'; 'Filter name': string; } : +Type extends EventTypes.BUTTON_CLICK ? { + 'Content': 'Swap button'; + 'Source': string; +} : undefined; /* eslint-enable @typescript-eslint/indent */ diff --git a/mocks/contract/methods.ts b/mocks/contract/methods.ts index 745c19770a..6c1bdf367e 100644 --- a/mocks/contract/methods.ts +++ b/mocks/contract/methods.ts @@ -132,6 +132,7 @@ export const write: Array = [ payable: false, stateMutability: 'nonpayable', type: 'function', + method_id: '0x01', }, { constant: false, @@ -146,6 +147,7 @@ export const write: Array = [ payable: true, stateMutability: 'payable', type: 'function', + method_id: '0x02', }, { stateMutability: 'payable', @@ -159,6 +161,7 @@ export const write: Array = [ payable: false, stateMutability: 'nonpayable', type: 'function', + method_id: '0x03', }, { constant: false, @@ -173,6 +176,7 @@ export const write: Array = [ payable: false, stateMutability: 'nonpayable', type: 'function', + method_id: '0x04', }, { constant: false, @@ -190,6 +194,7 @@ export const write: Array = [ payable: false, stateMutability: 'nonpayable', type: 'function', + method_id: '0x05', }, { constant: false, @@ -208,5 +213,6 @@ export const write: Array = [ payable: false, stateMutability: 'nonpayable', type: 'function', + method_id: '0x06', }, ]; diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts index c1b5355516..d1596cb1c5 100644 --- a/nextjs/csp/policies/app.ts +++ b/nextjs/csp/policies/app.ts @@ -31,6 +31,8 @@ const getCspReportUrl = () => { }; export function app(): CspDev.DirectiveDescriptor { + const marketplaceFeaturePayload = getFeaturePayload(config.features.marketplace); + return { 'default-src': [ // KEY_WORDS.NONE, @@ -54,6 +56,7 @@ export function app(): CspDev.DirectiveDescriptor { getFeaturePayload(config.features.verifiedTokens)?.api.endpoint, getFeaturePayload(config.features.addressVerification)?.api.endpoint, getFeaturePayload(config.features.nameService)?.api.endpoint, + marketplaceFeaturePayload && 'api' in marketplaceFeaturePayload ? marketplaceFeaturePayload.api.endpoint : '', // chain RPC server config.chain.rpcUrl, diff --git a/types/api/contract.ts b/types/api/contract.ts index 9a88813175..e5e01463e6 100644 --- a/types/api/contract.ts +++ b/types/api/contract.ts @@ -62,12 +62,11 @@ export interface SmartContractMethodBase { type: 'function'; payable: boolean; error?: string; -} - -export interface SmartContractReadMethod extends SmartContractMethodBase { method_id: string; } +export type SmartContractReadMethod = SmartContractMethodBase; + export interface SmartContractWriteFallback { payable?: true; stateMutability: 'payable'; @@ -85,7 +84,7 @@ export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWr export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod; export interface SmartContractMethodInput { - internalType?: SmartContractMethodArgType; + internalType?: string; // there could be any string, e.g "enum MyEnum" name: string; type: SmartContractMethodArgType; components?: Array; diff --git a/ui/address/contract/ContractMethodCallable.tsx b/ui/address/contract/ContractMethodCallable.tsx deleted file mode 100644 index d0e423ecc4..0000000000 --- a/ui/address/contract/ContractMethodCallable.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { Box, Button, chakra, Flex } from '@chakra-ui/react'; -import React from 'react'; -import type { SubmitHandler } from 'react-hook-form'; -import { useForm, FormProvider } from 'react-hook-form'; - -import type { MethodFormFields, ContractMethodCallResult } from './types'; -import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract'; - -import config from 'configs/app'; -import * as mixpanel from 'lib/mixpanel/index'; -import IconSvg from 'ui/shared/IconSvg'; - -import ContractMethodCallableRow from './ContractMethodCallableRow'; -import { formatFieldValues, transformFieldsToArgs } from './utils'; - -interface ResultComponentProps { - item: T; - result: ContractMethodCallResult; - onSettle: () => void; -} - -interface Props { - data: T; - onSubmit: (data: T, args: Array>) => Promise>; - resultComponent: (props: ResultComponentProps) => JSX.Element | null; - isWrite?: boolean; -} - -// groupName%groupIndex:inputName%inputIndex -const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) => - `${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`; - -const ContractMethodCallable = ({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props) => { - - const [ result, setResult ] = React.useState>(); - const [ isLoading, setLoading ] = React.useState(false); - - const inputs: Array = React.useMemo(() => { - return [ - ...('inputs' in data ? data.inputs : []), - ...('stateMutability' in data && data.stateMutability === 'payable' ? [ { - name: `Send native ${ config.chain.currency.symbol || 'coin' }`, - type: 'uint256' as const, - internalType: 'uint256' as const, - fieldType: 'native_coin' as const, - } ] : []), - ]; - }, [ data ]); - - const formApi = useForm({ - mode: 'onBlur', - }); - - const handleTxSettle = React.useCallback(() => { - setLoading(false); - }, []); - - const handleFormChange = React.useCallback(() => { - result && setResult(undefined); - }, [ result ]); - - const onFormSubmit: SubmitHandler = React.useCallback(async(formData) => { - const formattedData = formatFieldValues(formData, inputs); - const args = transformFieldsToArgs(formattedData); - - setResult(undefined); - setLoading(true); - - onSubmit(data, args) - .then((result) => { - setResult(result); - }) - .catch((error) => { - setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error); - setLoading(false); - }) - .finally(() => { - mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, { - 'Method type': isWrite ? 'Write' : 'Read', - 'Method name': 'name' in data ? data.name : 'Fallback', - }); - }); - }, [ inputs, onSubmit, data, isWrite ]); - - const outputs = 'outputs' in data && data.outputs ? data.outputs : []; - - return ( - - - - - { inputs.map((input, index) => { - const fieldName = getFormFieldName({ name: input.name, index }); - - if (input.type === 'tuple' && input.components) { - return ( - - { index !== 0 && <>
} - - { input.name } ({ input.type }) - - { input.components.map((component, componentIndex) => { - const fieldName = getFormFieldName( - { name: component.name, index: componentIndex }, - { name: input.name, index }, - ); - - return ( - - ); - }) } - { index !== inputs.length - 1 && <>
} - - ); - } - - return ( - 1 } - onChange={ handleFormChange } - /> - ); - }) } - - - - - { !isWrite && outputs.length > 0 && ( - - -

- { outputs.map(({ type, name }, index) => { - return ( - <> - { name } - { name ? `(${ type })` : type } - { index < outputs.length - 1 && , } - - ); - }) } -

-
- ) } - { result && } - - ); -}; - -export default React.memo(ContractMethodCallable) as typeof ContractMethodCallable; diff --git a/ui/address/contract/ContractMethodCallableRow.tsx b/ui/address/contract/ContractMethodCallableRow.tsx deleted file mode 100644 index 2cc01d7861..0000000000 --- a/ui/address/contract/ContractMethodCallableRow.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { Box, Flex, useColorModeValue } from '@chakra-ui/react'; -import React from 'react'; -import { useFormContext } from 'react-hook-form'; - -import type { MethodFormFields } from './types'; -import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract'; - -import ContractMethodField from './ContractMethodField'; -import ContractMethodFieldArray from './ContractMethodFieldArray'; -import { ARRAY_REGEXP } from './utils'; - -interface Props { - fieldName: string; - fieldType?: SmartContractMethodInput['fieldType']; - argName: string; - argType: SmartContractMethodArgType; - onChange: () => void; - isDisabled: boolean; - isGrouped?: boolean; - isOptional?: boolean; -} - -const ContractMethodCallableRow = ({ argName, fieldName, fieldType, argType, onChange, isDisabled, isGrouped, isOptional }: Props) => { - const { control, getValues, setValue } = useFormContext(); - const arrayTypeMatch = argType.match(ARRAY_REGEXP); - const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100'); - - const content = arrayTypeMatch ? ( - - ) : ( - - ); - - const isNativeCoinField = fieldType === 'native_coin'; - - return ( - - - { argName }{ isOptional ? '' : '*' } ({ argType }) - - { content } - - ); -}; - -export default React.memo(ContractMethodCallableRow); diff --git a/ui/address/contract/ContractMethodField.tsx b/ui/address/contract/ContractMethodField.tsx deleted file mode 100644 index 47837114f6..0000000000 --- a/ui/address/contract/ContractMethodField.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { - Box, - FormControl, - Input, - InputGroup, - InputRightElement, - useColorModeValue, -} from '@chakra-ui/react'; -import React from 'react'; -import type { Control, ControllerRenderProps, FieldError, UseFormGetValues, UseFormSetValue, UseFormStateReturn } from 'react-hook-form'; -import { Controller } from 'react-hook-form'; -import { NumericFormat } from 'react-number-format'; -import { isAddress, isHex, getAddress } from 'viem'; - -import type { MethodFormFields } from './types'; -import type { SmartContractMethodArgType } from 'types/api/contract'; - -import ClearButton from 'ui/shared/ClearButton'; - -import ContractMethodFieldZeroes from './ContractMethodFieldZeroes'; -import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils'; - -interface Props { - name: string; - index?: number; - groupName?: string; - placeholder: string; - argType: SmartContractMethodArgType; - control: Control; - setValue: UseFormSetValue; - getValues: UseFormGetValues; - isDisabled: boolean; - isOptional?: boolean; - onChange: () => void; -} - -const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => { - const ref = React.useRef(null); - const bgColor = useColorModeValue('white', 'black'); - - const handleClear = React.useCallback(() => { - setValue(name, ''); - onChange(); - ref.current?.focus(); - }, [ name, onChange, setValue ]); - - const handleAddZeroesClick = React.useCallback((power: number) => { - const value = groupName && index !== undefined ? getValues()[groupName][index] : getValues()[name]; - const zeroes = Array(power).fill('0').join(''); - const newValue = value ? value + zeroes : '1' + zeroes; - setValue(name, newValue); - onChange(); - }, [ getValues, groupName, index, name, onChange, setValue ]); - - const intMatch = React.useMemo(() => { - const match = argType.match(INT_REGEXP); - if (!match) { - return null; - } - - const [ , isUnsigned, power = '256' ] = match; - const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned)); - - return { isUnsigned, power, min, max }; - }, [ argType ]); - - const bytesMatch = React.useMemo(() => { - return argType.match(BYTES_REGEXP); - }, [ argType ]); - - const renderInput = React.useCallback(( - { field, formState }: { field: ControllerRenderProps; formState: UseFormStateReturn }, - ) => { - const error: FieldError | undefined = index !== undefined && groupName !== undefined ? - (formState.errors[groupName] as unknown as Array)?.[index] : - formState.errors[name]; - - // show control for all inputs which allows to insert 10^18 or greater numbers - const hasZerosControl = intMatch && Number(intMatch.power) >= 64; - - return ( - - - - - - { typeof field.value === 'string' && field.value.replace('\n', '') && } - { hasZerosControl && } - - - - { error && { error.message } } - - ); - }, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]); - - const validate = React.useCallback((_value: string | Array | undefined) => { - if (typeof _value === 'object' || !_value) { - return; - } - - const value = _value.replace('\n', ''); - - if (!value && !isOptional) { - return 'Field is required'; - } - - if (argType === 'address') { - if (!isAddress(value)) { - return 'Invalid address format'; - } - - // all lowercase addresses are valid - const isInLowerCase = value === value.toLowerCase(); - if (isInLowerCase) { - return true; - } - - // check if address checksum is valid - return getAddress(value) === value ? true : 'Invalid address checksum'; - } - - if (intMatch) { - const formattedValue = Number(value.replace(/\s/g, '')); - - if (Object.is(formattedValue, NaN)) { - return 'Invalid integer format'; - } - - if (formattedValue > intMatch.max || formattedValue < intMatch.min) { - const lowerBoundary = intMatch.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(intMatch.power) / 2 }`; - const upperBoundary = intMatch.isUnsigned ? `2 ^ ${ intMatch.power } - 1` : `2 ^ ${ Number(intMatch.power) / 2 } - 1`; - return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`; - } - - return true; - } - - if (argType === 'bool') { - const formattedValue = formatBooleanValue(value); - if (formattedValue === undefined) { - return 'Invalid boolean format. Allowed values: 0, 1, true, false'; - } - } - - if (bytesMatch) { - const [ , length ] = bytesMatch; - - if (!isHex(value)) { - return 'Invalid bytes format'; - } - - if (length) { - const valueLengthInBytes = value.replace('0x', '').length / 2; - return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true; - } - - return true; - } - - return true; - }, [ isOptional, argType, intMatch, bytesMatch ]); - - return ( - - ); -}; - -export default React.memo(ContractMethodField); diff --git a/ui/address/contract/ContractMethodFieldArray.tsx b/ui/address/contract/ContractMethodFieldArray.tsx deleted file mode 100644 index 35018b2f7f..0000000000 --- a/ui/address/contract/ContractMethodFieldArray.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Flex, IconButton } from '@chakra-ui/react'; -import React from 'react'; -import type { Control, UseFormGetValues, UseFormSetValue } from 'react-hook-form'; -import { useFieldArray } from 'react-hook-form'; - -import type { MethodFormFields } from './types'; -import type { SmartContractMethodArgType } from 'types/api/contract'; - -import IconSvg from 'ui/shared/IconSvg'; - -import ContractMethodField from './ContractMethodField'; - -interface Props { - name: string; - size: number; - argType: SmartContractMethodArgType; - control: Control; - setValue: UseFormSetValue; - getValues: UseFormGetValues; - isDisabled: boolean; - onChange: () => void; -} - -const ContractMethodFieldArray = ({ control, name, setValue, getValues, isDisabled, argType, onChange, size }: Props) => { - const { fields, append, remove } = useFieldArray({ - name: name as never, - control, - }); - - React.useEffect(() => { - if (fields.length === 0) { - if (size === Infinity) { - append(''); - } else { - for (let i = 0; i < size - 1; i++) { - // a little hack to append multiple empty fields in the array - // had to adjust code in ContractMethodField as well - append('\n'); - } - } - } - - }, [ fields.length, append, size ]); - - const handleAddButtonClick = React.useCallback(() => { - append(''); - }, [ append ]); - - const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent) => { - const itemIndex = event.currentTarget.getAttribute('data-index'); - if (itemIndex) { - remove(Number(itemIndex)); - } - }, [ remove ]); - - return ( - - { fields.map((field, index, array) => { - return ( - - - { array.length > 1 && size === Infinity && ( - } - isDisabled={ isDisabled } - /> - ) } - { index === array.length - 1 && size === Infinity && ( - } - isDisabled={ isDisabled } - /> - ) } - - ); - }) } - - ); -}; - -export default React.memo(ContractMethodFieldArray); diff --git a/ui/address/contract/ContractMethodsAccordionItem.tsx b/ui/address/contract/ContractMethodsAccordionItem.tsx index d69222cdad..b30c0d998b 100644 --- a/ui/address/contract/ContractMethodsAccordionItem.tsx +++ b/ui/address/contract/ContractMethodsAccordionItem.tsx @@ -45,50 +45,54 @@ const ContractMethodsAccordionItem = ({ data, ind return ( - - - { 'method_id' in data && ( - - - + { ({ isExpanded }) => ( + <> + + + { 'method_id' in data && ( + + + + + + ) } + + { index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name } - - ) } - - { index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name } - - { data.type === 'fallback' && ( - - ) } - { data.type === 'receive' && ( - + ) } + { data.type === 'receive' && ( + - ) } - - - - - { renderContent(data, index, id) } - + }/> + ) } + + + + + { renderContent(data, index, id) } + + + ) } ); }; diff --git a/ui/address/contract/ContractRead.pw.tsx b/ui/address/contract/ContractRead.pw.tsx index d540c1c07e..1dc215d070 100644 --- a/ui/address/contract/ContractRead.pw.tsx +++ b/ui/address/contract/ContractRead.pw.tsx @@ -37,7 +37,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { await expect(component).toHaveScreenshot(); - await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466'); + await component.getByPlaceholder(/address/i).fill('0xa113Ce24919C08a26C952E81681dAc861d6a2466'); await component.getByText(/read/i).click(); await component.getByText(/wei/i).click(); diff --git a/ui/address/contract/ContractRead.tsx b/ui/address/contract/ContractRead.tsx index 412c409f6d..7a2fd94ded 100644 --- a/ui/address/contract/ContractRead.tsx +++ b/ui/address/contract/ContractRead.tsx @@ -14,9 +14,9 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; import ContractConnectWallet from './ContractConnectWallet'; import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractImplementationAddress from './ContractImplementationAddress'; -import ContractMethodCallable from './ContractMethodCallable'; import ContractMethodConstant from './ContractMethodConstant'; import ContractReadResult from './ContractReadResult'; +import ContractMethodForm from './methodForm/ContractMethodForm'; import useWatchAccount from './useWatchAccount'; const ContractRead = () => { @@ -40,7 +40,7 @@ const ContractRead = () => { }, }); - const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array>) => { + const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array) => { return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', { pathParams: { hash: addressHash }, queryParams: { @@ -72,11 +72,12 @@ const ContractRead = () => { } return ( - ); }, [ handleMethodFormSubmit ]); diff --git a/ui/address/contract/ContractWrite.tsx b/ui/address/contract/ContractWrite.tsx index b199b99291..3e68421ed2 100644 --- a/ui/address/contract/ContractWrite.tsx +++ b/ui/address/contract/ContractWrite.tsx @@ -14,8 +14,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; import ContractConnectWallet from './ContractConnectWallet'; import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractImplementationAddress from './ContractImplementationAddress'; -import ContractMethodCallable from './ContractMethodCallable'; import ContractWriteResult from './ContractWriteResult'; +import ContractMethodForm from './methodForm/ContractMethodForm'; import useContractAbi from './useContractAbi'; import { getNativeCoinValue, prepareAbi } from './utils'; @@ -39,12 +39,14 @@ const ContractWrite = () => { }, queryOptions: { enabled: Boolean(addressHash), + refetchOnMount: false, }, }); const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi }); - const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array>) => { + // TODO @tom2drum maybe move this inside the form + const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array) => { if (!isConnected) { throw new Error('Wallet is not connected'); } @@ -66,21 +68,22 @@ const ContractWrite = () => { return { hash }; } - const _args = 'stateMutability' in item && item.stateMutability === 'payable' ? args.slice(0, -1) : args; - const value = 'stateMutability' in item && item.stateMutability === 'payable' ? getNativeCoinValue(args[args.length - 1]) : undefined; const methodName = item.name; if (!methodName) { throw new Error('Method name is not defined'); } + const _args = args.slice(0, item.inputs.length); + const value = getNativeCoinValue(args[item.inputs.length]); const abi = prepareAbi(contractAbi, item); + const hash = await walletClient?.writeContract({ args: _args, abi, functionName: methodName, address: addressHash as `0x${ string }`, - value: value as undefined, + value, }); return { hash }; @@ -88,12 +91,12 @@ const ContractWrite = () => { const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => { return ( - ); }, [ handleMethodFormSubmit ]); diff --git a/ui/address/contract/ContractWriteResult.tsx b/ui/address/contract/ContractWriteResult.tsx index 9fed0e271e..266f64dd03 100644 --- a/ui/address/contract/ContractWriteResult.tsx +++ b/ui/address/contract/ContractWriteResult.tsx @@ -1,22 +1,19 @@ import React from 'react'; import { useWaitForTransaction } from 'wagmi'; +import type { ResultComponentProps } from './methodForm/types'; import type { ContractMethodWriteResult } from './types'; +import type { SmartContractWriteMethod } from 'types/api/contract'; import ContractWriteResultDumb from './ContractWriteResultDumb'; -interface Props { - result: ContractMethodWriteResult; - onSettle: () => void; -} - -const ContractWriteResult = ({ result, onSettle }: Props) => { +const ContractWriteResult = ({ result, onSettle }: ResultComponentProps) => { const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined; const txInfo = useWaitForTransaction({ hash: txHash, }); - return ; + return ; }; -export default React.memo(ContractWriteResult); +export default React.memo(ContractWriteResult) as typeof ContractWriteResult; diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png index 11221c4dfe..f19ba84553 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png index bf6bb309fa..9402cd11fc 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png index ab26adfc56..82900c2f57 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png index dcf7af98eb..635406e6f5 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_default_base-view-mobile-dark-mode-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png index 1408d976c6..930ca02a9e 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png index 2aba458f92..b5d534a0d0 100644 Binary files a/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png and b/ui/address/contract/__screenshots__/ContractRead.pw.tsx_mobile_base-view-mobile-dark-mode-2.png differ diff --git a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png index eee1a2a500..e58e9c4755 100644 Binary files a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png and b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_default_base-view-mobile-1.png differ diff --git a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png index 5724e9b234..c9a99a0bfb 100644 Binary files a/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png and b/ui/address/contract/__screenshots__/ContractWrite.pw.tsx_mobile_base-view-mobile-1.png differ diff --git a/ui/address/contract/methodForm/ContractMethodArrayButton.tsx b/ui/address/contract/methodForm/ContractMethodArrayButton.tsx new file mode 100644 index 0000000000..a39df7c926 --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodArrayButton.tsx @@ -0,0 +1,31 @@ +import { IconButton, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import IconSvg from 'ui/shared/IconSvg'; + +interface Props { + index: number; + onClick: (event: React.MouseEvent) => void; + isDisabled?: boolean; + type: 'add' | 'remove'; + className?: string; +} + +const ContractMethodArrayButton = ({ className, type, index, onClick, isDisabled }: Props) => { + return ( + } + isDisabled={ isDisabled } + /> + ); +}; + +export default React.memo(chakra(ContractMethodArrayButton)); diff --git a/ui/address/contract/methodForm/ContractMethodFieldAccordion.tsx b/ui/address/contract/methodForm/ContractMethodFieldAccordion.tsx new file mode 100644 index 0000000000..93084f71de --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodFieldAccordion.tsx @@ -0,0 +1,51 @@ +import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +import ContractMethodArrayButton from './ContractMethodArrayButton'; + +export interface Props { + label: string; + level: number; + children: React.ReactNode; + onAddClick?: (event: React.MouseEvent) => void; + onRemoveClick?: (event: React.MouseEvent) => void; + index?: number; + isInvalid?: boolean; +} + +const ContractMethodFieldAccordion = ({ label, level, children, onAddClick, onRemoveClick, index, isInvalid }: Props) => { + const bgColorLevel0 = useColorModeValue('blackAlpha.50', 'whiteAlpha.50'); + const bgColor = useColorModeValue('whiteAlpha.700', 'blackAlpha.700'); + + return ( + + + { ({ isExpanded }) => ( + <> + + + + { label } + + { onRemoveClick && } + { onAddClick && } + + + { children } + + + ) } + + + ); +}; + +export default React.memo(ContractMethodFieldAccordion); diff --git a/ui/address/contract/methodForm/ContractMethodFieldInput.tsx b/ui/address/contract/methodForm/ContractMethodFieldInput.tsx new file mode 100644 index 0000000000..be6ac43ed9 --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodFieldInput.tsx @@ -0,0 +1,97 @@ +import { Box, Flex, FormControl, Input, InputGroup, InputRightElement, chakra, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; +import { useController, useFormContext } from 'react-hook-form'; +import { NumericFormat } from 'react-number-format'; + +import type { SmartContractMethodInput } from 'types/api/contract'; + +import ClearButton from 'ui/shared/ClearButton'; + +import ContractMethodFieldLabel from './ContractMethodFieldLabel'; +import ContractMethodMultiplyButton from './ContractMethodMultiplyButton'; +import useArgTypeMatchInt from './useArgTypeMatchInt'; +import useValidateField from './useValidateField'; + +interface Props { + data: SmartContractMethodInput; + hideLabel?: boolean; + path: string; + className?: string; + isDisabled: boolean; + level: number; +} + +const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDisabled, level }: Props) => { + const ref = React.useRef(null); + + const isNativeCoin = data.fieldType === 'native_coin'; + const isOptional = isNativeCoin; + + const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type }); + const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt }); + + const { control, setValue, getValues } = useFormContext(); + const { field, fieldState } = useController({ control, name, rules: { validate, required: isOptional ? false : 'Field is required' } }); + + const inputBgColor = useColorModeValue('white', 'black'); + const nativeCoinRowBgColor = useColorModeValue('gray.100', 'gray.700'); + + const hasMultiplyButton = argTypeMatchInt && Number(argTypeMatchInt.power) >= 64; + + const handleClear = React.useCallback(() => { + setValue(name, ''); + ref.current?.focus(); + }, [ name, setValue ]); + + const handleMultiplyButtonClick = React.useCallback((power: number) => { + const zeroes = Array(power).fill('0').join(''); + const value = getValues(name); + const newValue = value ? value + zeroes : '1' + zeroes; + setValue(name, newValue); + }, [ getValues, name, setValue ]); + + const error = fieldState.error; + + return ( + + { !hideLabel && } + + + + + { typeof field.value === 'string' && field.value.replace('\n', '') && } + { hasMultiplyButton && } + + + { error && { error.message } } + + + ); +}; + +export default React.memo(chakra(ContractMethodFieldInput)); diff --git a/ui/address/contract/methodForm/ContractMethodFieldInputArray.tsx b/ui/address/contract/methodForm/ContractMethodFieldInputArray.tsx new file mode 100644 index 0000000000..85a2611e90 --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodFieldInputArray.tsx @@ -0,0 +1,147 @@ +import { Flex } from '@chakra-ui/react'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import type { SmartContractMethodInput, SmartContractMethodArgType } from 'types/api/contract'; + +import ContractMethodArrayButton from './ContractMethodArrayButton'; +import type { Props as AccordionProps } from './ContractMethodFieldAccordion'; +import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; +import ContractMethodFieldInput from './ContractMethodFieldInput'; +import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; +import ContractMethodFieldLabel from './ContractMethodFieldLabel'; +import { getFieldLabel } from './utils'; + +interface Props extends Pick { + data: SmartContractMethodInput; + level: number; + basePath: string; + isDisabled: boolean; +} + +const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRemoveClick, index: parentIndex, isDisabled }: Props) => { + const { formState: { errors } } = useFormContext(); + const fieldsWithErrors = Object.keys(errors); + const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath)); + + const [ registeredIndices, setRegisteredIndices ] = React.useState([ 0 ]); + + const handleAddButtonClick = React.useCallback((event: React.MouseEvent) => { + event.preventDefault(); + setRegisteredIndices((prev) => [ ...prev, prev[prev.length - 1] + 1 ]); + }, []); + + const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent) => { + event.preventDefault(); + const itemIndex = event.currentTarget.getAttribute('data-index'); + if (itemIndex) { + setRegisteredIndices((prev) => prev.filter((index) => index !== Number(itemIndex))); + } + }, [ ]); + + const getItemData = (index: number) => { + const childrenType = data.type.slice(0, -2) as SmartContractMethodArgType; + const childrenInternalType = data.internalType?.slice(0, parentIndex !== undefined ? -4 : -2).replaceAll('struct ', ''); + + const namePostfix = childrenInternalType ? ' ' + childrenInternalType : ''; + const nameParentIndex = parentIndex !== undefined ? `${ parentIndex + 1 }.` : ''; + const nameIndex = index + 1; + + return { + ...data, + type: childrenType, + name: `#${ nameParentIndex + nameIndex }${ namePostfix }`, + }; + }; + const isNestedArray = data.type.includes('[][]'); + + if (isNestedArray) { + return ( + + { registeredIndices.map((registeredIndex, index) => { + const itemData = getItemData(index); + + return ( + 1 ? handleRemoveButtonClick : undefined } + index={ registeredIndex } + isDisabled={ isDisabled } + /> + ); + }) } + + ); + } + + const isTupleArray = data.type.includes('tuple'); + + if (isTupleArray) { + return ( + + { registeredIndices.map((registeredIndex, index) => { + const itemData = getItemData(index); + + return ( + 1 ? handleRemoveButtonClick : undefined } + index={ registeredIndex } + isDisabled={ isDisabled } + /> + ); + }) } + + ); + } + + // primitive value array + return ( + + + + { registeredIndices.map((registeredIndex, index) => { + const itemData = getItemData(index); + + return ( + + + { registeredIndices.length > 1 && + } + { index === registeredIndices.length - 1 && + } + + ); + }) } + + + ); +}; + +export default React.memo(ContractMethodFieldInputArray); diff --git a/ui/address/contract/methodForm/ContractMethodFieldInputTuple.tsx b/ui/address/contract/methodForm/ContractMethodFieldInputTuple.tsx new file mode 100644 index 0000000000..79adfb0d8a --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodFieldInputTuple.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import type { SmartContractMethodInput } from 'types/api/contract'; + +import type { Props as AccordionProps } from './ContractMethodFieldAccordion'; +import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; +import ContractMethodFieldInput from './ContractMethodFieldInput'; +import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; +import { ARRAY_REGEXP, getFieldLabel } from './utils'; + +interface Props extends Pick { + data: SmartContractMethodInput; + basePath: string; + level: number; + isDisabled: boolean; +} + +const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...accordionProps }: Props) => { + const { formState: { errors } } = useFormContext(); + const fieldsWithErrors = Object.keys(errors); + const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath)); + + return ( + + { data.components?.map((component, index) => { + if (component.components && component.type === 'tuple') { + return ( + + ); + } + + const arrayMatch = component.type.match(ARRAY_REGEXP); + if (arrayMatch) { + const [ , itemType ] = arrayMatch; + return ( + + ); + } + + return ( + + ); + }) } + + ); +}; + +export default React.memo(ContractMethodFieldInputTuple); diff --git a/ui/address/contract/methodForm/ContractMethodFieldLabel.tsx b/ui/address/contract/methodForm/ContractMethodFieldLabel.tsx new file mode 100644 index 0000000000..ea2b4be02c --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodFieldLabel.tsx @@ -0,0 +1,32 @@ +import { Box, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; + +import type { SmartContractMethodInput } from 'types/api/contract'; + +import { getFieldLabel } from './utils'; + +interface Props { + data: SmartContractMethodInput; + isOptional?: boolean; + level: number; +} + +const ContractMethodFieldLabel = ({ data, isOptional, level }: Props) => { + const color = useColorModeValue('blackAlpha.600', 'whiteAlpha.600'); + + return ( + 1 ? color : undefined } + > + { getFieldLabel(data, !isOptional) } + + ); +}; + +export default React.memo(ContractMethodFieldLabel); diff --git a/ui/address/contract/methodForm/ContractMethodForm.pw.tsx b/ui/address/contract/methodForm/ContractMethodForm.pw.tsx new file mode 100644 index 0000000000..2fd5fbb589 --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodForm.pw.tsx @@ -0,0 +1,135 @@ +import { test, expect } from '@playwright/experimental-ct-react'; +import React from 'react'; + +import type { SmartContractWriteMethod } from 'types/api/contract'; + +import TestApp from 'playwright/TestApp'; + +import ContractMethodForm from './ContractMethodForm'; + +const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` }); +const resultComponent = () => null; + +const data: SmartContractWriteMethod = { + inputs: [ + // TUPLE + { + components: [ + { internalType: 'address', name: 'offerToken', type: 'address' }, + { internalType: 'uint256', name: 'offerIdentifier', type: 'uint256' }, + { internalType: 'enum BasicOrderType', name: 'basicOrderType', type: 'uint8' }, + { + components: [ + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'address payable', name: 'recipient', type: 'address' }, + ], + internalType: 'struct AdditionalRecipient[]', + name: 'additionalRecipients', + type: 'tuple[]', + }, + { internalType: 'bytes', name: 'signature', type: 'bytes' }, + ], + internalType: 'struct BasicOrderParameters', + name: 'parameters', + type: 'tuple', + }, + + // NESTED ARRAY OF TUPLES + { + components: [ + { + internalType: 'uint256', + name: 'orderIndex', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'itemIndex', + type: 'uint256', + }, + ], + internalType: 'struct FulfillmentComponent[][]', + name: '', + type: 'tuple[][]', + }, + + // LITERALS + { internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' }, + { internalType: 'address', name: 'recipient', type: 'address' }, + { internalType: 'uint256', name: 'maximumFulfilled', type: 'uint256' }, + { internalType: 'int8[]', name: 'criteriaProof', type: 'int8[]' }, + ], + method_id: '87201b41', + name: 'fulfillAvailableAdvancedOrders', + outputs: [ + { internalType: 'bool[]', name: '', type: 'bool[]' }, + { + components: [ + { + components: [ + { internalType: 'enum ItemType', name: 'itemType', type: 'uint8' }, + { internalType: 'address', name: 'token', type: 'address' }, + { internalType: 'uint256', name: 'identifier', type: 'uint256' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'address payable', name: 'recipient', type: 'address' }, + ], + internalType: 'struct ReceivedItem', + name: 'item', + type: 'tuple', + }, + { internalType: 'address', name: 'offerer', type: 'address' }, + { internalType: 'bytes32', name: 'conduitKey', type: 'bytes32' }, + ], + internalType: 'struct Execution[]', + name: '', + type: 'tuple[]', + }, + ], + stateMutability: 'payable', + type: 'function', + payable: true, + constant: false, +}; + +test('base view +@mobile +@dark-mode', async({ mount }) => { + + const component = await mount( + + + data={ data } + onSubmit={ onSubmit } + resultComponent={ resultComponent } + methodType="write" + /> + , + ); + + // fill top level fields + await component.getByPlaceholder('address').last().fill('0x0000'); + await component.getByPlaceholder('uint256').last().fill('42'); + await component.getByRole('button', { name: '×' }).last().click(); + await component.getByPlaceholder('bytes32').last().fill('aa'); + await component.getByRole('button', { name: 'add' }).last().click(); + await component.getByRole('button', { name: 'add' }).last().click(); + await component.getByPlaceholder('int8', { exact: true }).first().fill('1'); + await component.getByPlaceholder('int8', { exact: true }).last().fill('3'); + + // expand all sections + await component.getByText('parameters').click(); + await component.getByText('additionalRecipients').click(); + await component.getByText('#1 AdditionalRecipient').click(); + await component.getByRole('button', { name: 'add' }).first().click(); + await component.getByPlaceholder('uint256').nth(1).fill('42'); + await component.getByPlaceholder('address').nth(1).fill('0xd789a607CEac2f0E14867de4EB15b15C9FFB5859'); + + await component.getByText('struct FulfillmentComponent[][]').click(); + await component.getByRole('button', { name: 'add' }).nth(1).click(); + await component.getByText('#1 FulfillmentComponent[]').click(); + await component.getByText('#1.1 FulfillmentComponent').click(); + await component.getByRole('button', { name: 'add' }).nth(1).click(); + + // submit form + await component.getByRole('button', { name: 'Write' }).click(); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/address/contract/methodForm/ContractMethodForm.tsx b/ui/address/contract/methodForm/ContractMethodForm.tsx new file mode 100644 index 0000000000..5bcd5e3af6 --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodForm.tsx @@ -0,0 +1,123 @@ +import { Box, Button, Flex, chakra } from '@chakra-ui/react'; +import React from 'react'; +import type { SubmitHandler } from 'react-hook-form'; +import { useForm, FormProvider } from 'react-hook-form'; + +import type { ContractMethodCallResult } from '../types'; +import type { ResultComponentProps } from './types'; +import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract'; + +import config from 'configs/app'; +import * as mixpanel from 'lib/mixpanel/index'; + +import ContractMethodFieldInput from './ContractMethodFieldInput'; +import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; +import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; +import ContractMethodFormOutputs from './ContractMethodFormOutputs'; +import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils'; +import type { ContractMethodFormFields } from './utils'; + +interface Props { + data: T; + onSubmit: (data: T, args: Array) => Promise>; + resultComponent: (props: ResultComponentProps) => JSX.Element | null; + methodType: 'read' | 'write'; +} + +const ContractMethodForm = ({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props) => { + + const [ result, setResult ] = React.useState>(); + const [ isLoading, setLoading ] = React.useState(false); + + const formApi = useForm({ + mode: 'all', + shouldUnregister: true, + }); + + const onFormSubmit: SubmitHandler = React.useCallback(async(formData) => { + const args = transformFormDataToMethodArgs(formData); + + setResult(undefined); + setLoading(true); + + onSubmit(data, args) + .then((result) => { + setResult(result); + }) + .catch((error) => { + setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error); + setLoading(false); + }) + .finally(() => { + mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, { + 'Method type': methodType === 'write' ? 'Write' : 'Read', + 'Method name': 'name' in data ? data.name : 'Fallback', + }); + }); + }, [ data, methodType, onSubmit ]); + + const handleTxSettle = React.useCallback(() => { + setLoading(false); + }, []); + + const handleFormChange = React.useCallback(() => { + result && setResult(undefined); + }, [ result ]); + + const inputs: Array = React.useMemo(() => { + return [ + ...('inputs' in data ? data.inputs : []), + ...('stateMutability' in data && data.stateMutability === 'payable' ? [ { + name: `Send native ${ config.chain.currency.symbol || 'coin' }`, + type: 'uint256' as const, + internalType: 'uint256' as const, + fieldType: 'native_coin' as const, + } ] : []), + ]; + }, [ data ]); + + const outputs = 'outputs' in data && data.outputs ? data.outputs : []; + + return ( + + + + + { inputs.map((input, index) => { + if (input.components && input.type === 'tuple') { + return ; + } + + const arrayMatch = input.type.match(ARRAY_REGEXP); + if (arrayMatch) { + return ; + } + + return ; + }) } + + + + + { methodType === 'read' && } + { result && } + + ); +}; + +export default React.memo(ContractMethodForm) as typeof ContractMethodForm; diff --git a/ui/address/contract/methodForm/ContractMethodFormOutputs.tsx b/ui/address/contract/methodForm/ContractMethodFormOutputs.tsx new file mode 100644 index 0000000000..17797bea0d --- /dev/null +++ b/ui/address/contract/methodForm/ContractMethodFormOutputs.tsx @@ -0,0 +1,35 @@ +import { Flex, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import type { SmartContractMethodOutput } from 'types/api/contract'; + +import IconSvg from 'ui/shared/IconSvg'; + +interface Props { + data: Array; +} + +const ContractMethodFormOutputs = ({ data }: Props) => { + if (data.length === 0) { + return null; + } + + return ( + + +

+ { data.map(({ type, name }, index) => { + return ( + <> + { name } + { name ? `(${ type })` : type } + { index < data.length - 1 && , } + + ); + }) } +

+
+ ); +}; + +export default React.memo(ContractMethodFormOutputs); diff --git a/ui/address/contract/ContractMethodFieldZeroes.tsx b/ui/address/contract/methodForm/ContractMethodMultiplyButton.tsx similarity index 89% rename from ui/address/contract/ContractMethodFieldZeroes.tsx rename to ui/address/contract/methodForm/ContractMethodMultiplyButton.tsx index 1c0bfc9852..7b12ab727e 100644 --- a/ui/address/contract/ContractMethodFieldZeroes.tsx +++ b/ui/address/contract/methodForm/ContractMethodMultiplyButton.tsx @@ -21,7 +21,7 @@ interface Props { isDisabled?: boolean; } -const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { +const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => { const [ selectedOption, setSelectedOption ] = React.useState(18); const [ customValue, setCustomValue ] = React.useState(); const { isOpen, onToggle, onClose } = useDisclosure(); @@ -78,7 +78,14 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { onClick={ onToggle } isDisabled={ isDisabled } > - + @@ -126,4 +133,4 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { ); }; -export default React.memo(ContractMethodFieldZeroes); +export default React.memo(ContractMethodMultiplyButton); diff --git a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png b/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png new file mode 100644 index 0000000000..0efe77db56 Binary files /dev/null and b/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_dark-color-mode_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png b/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png new file mode 100644 index 0000000000..c5bc0169c9 Binary files /dev/null and b/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_default_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png b/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png new file mode 100644 index 0000000000..c386ad9a29 Binary files /dev/null and b/ui/address/contract/methodForm/__screenshots__/ContractMethodForm.pw.tsx_mobile_base-view-mobile-dark-mode-1.png differ diff --git a/ui/address/contract/methodForm/types.ts b/ui/address/contract/methodForm/types.ts new file mode 100644 index 0000000000..845d6d3621 --- /dev/null +++ b/ui/address/contract/methodForm/types.ts @@ -0,0 +1,8 @@ +import type { ContractMethodCallResult } from '../types'; +import type { SmartContractMethod } from 'types/api/contract'; + +export interface ResultComponentProps { + item: T; + result: ContractMethodCallResult; + onSettle: () => void; +} diff --git a/ui/address/contract/methodForm/useArgTypeMatchInt.tsx b/ui/address/contract/methodForm/useArgTypeMatchInt.tsx new file mode 100644 index 0000000000..bb3f375e7c --- /dev/null +++ b/ui/address/contract/methodForm/useArgTypeMatchInt.tsx @@ -0,0 +1,26 @@ +import type { SmartContractMethodArgType } from 'types/api/contract'; + +import { INT_REGEXP, getIntBoundaries } from './utils'; + +interface Params { + argType: SmartContractMethodArgType; +} + +export interface MatchInt { + isUnsigned: boolean; + power: string; + min: number; + max: number; +} + +export default function useArgTypeMatchInt({ argType }: Params): MatchInt | null { + const match = argType.match(INT_REGEXP); + if (!match) { + return null; + } + + const [ , isUnsigned, power = '256' ] = match; + const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned)); + + return { isUnsigned: Boolean(isUnsigned), power, min, max }; +} diff --git a/ui/address/contract/methodForm/useValidateField.tsx b/ui/address/contract/methodForm/useValidateField.tsx new file mode 100644 index 0000000000..482ad73195 --- /dev/null +++ b/ui/address/contract/methodForm/useValidateField.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { getAddress, isAddress, isHex } from 'viem'; + +import type { SmartContractMethodArgType } from 'types/api/contract'; + +import type { MatchInt } from './useArgTypeMatchInt'; +import { BYTES_REGEXP, formatBooleanValue } from './utils'; + +interface Params { + argType: SmartContractMethodArgType; + argTypeMatchInt: MatchInt | null; + isOptional: boolean; +} + +export default function useValidateField({ isOptional, argType, argTypeMatchInt }: Params) { + + const bytesMatch = React.useMemo(() => { + return argType.match(BYTES_REGEXP); + }, [ argType ]); + + return React.useCallback((value: string | undefined) => { + if (!value) { + return isOptional ? true : 'Field is required'; + } + + if (argType === 'address') { + if (!isAddress(value)) { + return 'Invalid address format'; + } + + // all lowercase addresses are valid + const isInLowerCase = value === value.toLowerCase(); + if (isInLowerCase) { + return true; + } + + // check if address checksum is valid + return getAddress(value) === value ? true : 'Invalid address checksum'; + } + + if (argTypeMatchInt) { + const formattedValue = Number(value.replace(/\s/g, '')); + + if (Object.is(formattedValue, NaN)) { + return 'Invalid integer format'; + } + + if (formattedValue > argTypeMatchInt.max || formattedValue < argTypeMatchInt.min) { + const lowerBoundary = argTypeMatchInt.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(argTypeMatchInt.power) - 1 }`; + const upperBoundary = argTypeMatchInt.isUnsigned ? `2 ^ ${ argTypeMatchInt.power } - 1` : `2 ^ ${ Number(argTypeMatchInt.power) - 1 } - 1`; + return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`; + } + + return true; + } + + if (argType === 'bool') { + const formattedValue = formatBooleanValue(value); + if (formattedValue === undefined) { + return 'Invalid boolean format. Allowed values: 0, 1, true, false'; + } + } + + if (bytesMatch) { + const [ , length ] = bytesMatch; + + if (!isHex(value)) { + return 'Invalid bytes format'; + } + + if (length) { + const valueLengthInBytes = value.replace('0x', '').length / 2; + return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true; + } + + return true; + } + + return true; + }, [ isOptional, argType, argTypeMatchInt, bytesMatch ]); +} diff --git a/ui/address/contract/methodForm/utils.test.ts b/ui/address/contract/methodForm/utils.test.ts new file mode 100644 index 0000000000..f49b3fe9f9 --- /dev/null +++ b/ui/address/contract/methodForm/utils.test.ts @@ -0,0 +1,49 @@ +import { transformFormDataToMethodArgs } from './utils'; + +describe('transformFormDataToMethodArgs', () => { + it('should transform form data to method args array', () => { + const formData = { + '1': '1', + '2': '2', + '0:1': '0:1', + '0:0:0': '0:0:0', + '0:0:1:0': '0:0:1:0', + '0:0:1:3': '0:0:1:3', + '0:0:2:1:0': '0:0:2:1:0', + '0:0:2:1:1': '0:0:2:1:1', + '0:0:2:2:0': '0:0:2:2:0', + '0:0:2:2:2': '0:0:2:2:2', + '0:0:2:5:3': '0:0:2:5:3', + '0:0:2:5:8': '0:0:2:5:8', + }; + const result = transformFormDataToMethodArgs(formData); + expect(result).toEqual([ + [ + [ + '0:0:0', + [ + '0:0:1:0', + '0:0:1:3', + ], + [ + [ + '0:0:2:1:0', + '0:0:2:1:1', + ], + [ + '0:0:2:2:0', + '0:0:2:2:2', + ], + [ + '0:0:2:5:3', + '0:0:2:5:8', + ], + ], + ], + '0:1', + ], + '1', + '2', + ]); + }); +}); diff --git a/ui/address/contract/methodForm/utils.ts b/ui/address/contract/methodForm/utils.ts new file mode 100644 index 0000000000..be8edb5324 --- /dev/null +++ b/ui/address/contract/methodForm/utils.ts @@ -0,0 +1,66 @@ +import _set from 'lodash/set'; + +import type { SmartContractMethodInput } from 'types/api/contract'; + +export type ContractMethodFormFields = Record; + +export const INT_REGEXP = /^(u)?int(\d+)?$/i; + +export const BYTES_REGEXP = /^bytes(\d+)?$/i; + +export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/; + +export const getIntBoundaries = (power: number, isUnsigned: boolean) => { + const maxUnsigned = 2 ** power; + const max = isUnsigned ? maxUnsigned - 1 : maxUnsigned / 2 - 1; + const min = isUnsigned ? 0 : -maxUnsigned / 2; + return [ min, max ]; +}; + +export const formatBooleanValue = (value: string) => { + const formattedValue = value.toLowerCase(); + + switch (formattedValue) { + case 'true': + case '1': { + return 'true'; + } + + case 'false': + case '0': { + return 'false'; + } + + default: + return; + } +}; + +export function transformFormDataToMethodArgs(formData: ContractMethodFormFields) { + const result: Array = []; + + for (const field in formData) { + const value = formData[field]; + if (value !== undefined) { + _set(result, field.replaceAll(':', '.'), value); + } + } + + return filterOurEmptyItems(result); +} + +function filterOurEmptyItems(array: Array): Array { + // The undefined value may occur in two cases: + // 1. When an optional form field is left blank by the user. + // The only optional field is the native coin value, which is safely handled in the form submit handler. + // 2. When the user adds and removes items from a field array. + // In this scenario, empty items need to be filtered out to maintain the correct sequence of arguments. + return array + .map((item) => Array.isArray(item) ? filterOurEmptyItems(item) : item) + .filter((item) => item !== undefined); +} + +export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) { + const name = input.name || input.internalType || ''; + return `${ name } (${ input.type })${ isRequired ? '*' : '' }`; +} diff --git a/ui/address/contract/types.ts b/ui/address/contract/types.ts index b076f30f78..26753dcbf1 100644 --- a/ui/address/contract/types.ts +++ b/ui/address/contract/types.ts @@ -1,4 +1,4 @@ -import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/api/contract'; +import type { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract'; import type { ResourceError } from 'lib/api/resources'; @@ -12,4 +12,4 @@ export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceEr export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined; export type ContractMethodCallResult = - T extends { method_id: string } ? ContractMethodReadResult : ContractMethodWriteResult; + T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult; diff --git a/ui/address/contract/utils.test.ts b/ui/address/contract/utils.test.ts index da20410583..47e12d3b67 100644 --- a/ui/address/contract/utils.test.ts +++ b/ui/address/contract/utils.test.ts @@ -1,6 +1,4 @@ -import type { SmartContractMethodInput } from 'types/api/contract'; - -import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils'; +import { prepareAbi } from './utils'; describe('function prepareAbi()', () => { const commonAbi = [ @@ -48,6 +46,7 @@ describe('function prepareAbi()', () => { type: 'function' as const, constant: false, payable: true, + method_id: '0x2e0e2d3e', }; it('if there is only one method with provided name, does nothing', () => { @@ -100,100 +99,3 @@ describe('function prepareAbi()', () => { expect(item).toEqual(commonAbi[2]); }); }); - -describe('function formatFieldValues()', () => { - const formFields = { - '_tx%0:nonce%0': '1 000 000 000 000 000 000', - '_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c', - '_tx%0:targets%2': [ - '1', - 'true', - ], - '_l2OutputIndex%1': '0xaeff', - '_paused%2': '0', - '_withdrawalProof%3': [ - '0x0f', - '0x02', - ], - }; - - const inputs: Array = [ - { - components: [ - { internalType: 'uint256', name: 'nonce', type: 'uint256' }, - { internalType: 'address', name: 'sender', type: 'address' }, - { internalType: 'bool[]', name: 'targets', type: 'bool[]' }, - ], - internalType: 'tuple', - name: '_tx', - type: 'tuple', - }, - { internalType: 'bytes32', name: '_l2OutputIndex', type: 'bytes32' }, - { - internalType: 'bool', - name: '_paused', - type: 'bool', - }, - { - internalType: 'bytes32[]', - name: '_withdrawalProof', - type: 'bytes32[]', - }, - ]; - - it('converts values to correct format', () => { - const result = formatFieldValues(formFields, inputs); - expect(result).toEqual({ - '_tx%0:nonce%0': '1000000000000000000', - '_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c', - '_tx%0:targets%2': [ - true, - true, - ], - '_l2OutputIndex%1': '0xaeff', - '_paused%2': false, - '_withdrawalProof%3': [ - '0x0f', - '0x02', - ], - }); - }); - - it('converts nested array string representation to correct format', () => { - const formFields = { - '_withdrawalProof%0': '[ [ 1 ], [ 2, 3 ], [ 4 ]]', - }; - const inputs: Array = [ - { internalType: 'uint[][]', name: '_withdrawalProof', type: 'uint[][]' }, - ]; - const result = formatFieldValues(formFields, inputs); - - expect(result).toEqual({ - '_withdrawalProof%0': [ [ 1 ], [ 2, 3 ], [ 4 ] ], - }); - }); -}); - -describe('function transformFieldsToArgs()', () => { - it('groups struct and array fields', () => { - const formFields = { - '_paused%2': 'primitive_1', - '_l2OutputIndex%1': 'primitive_0', - '_tx%0:nonce%0': 'struct_0', - '_tx%0:sender%1': 'struct_1', - '_tx%0:target%2': [ 'struct_2_0', 'struct_2_1' ], - '_withdrawalProof%3': [ - 'array_0', - 'array_1', - ], - }; - - const args = transformFieldsToArgs(formFields); - expect(args).toEqual([ - [ 'struct_0', 'struct_1', [ 'struct_2_0', 'struct_2_1' ] ], - 'primitive_0', - 'primitive_1', - [ 'array_0', 'array_1' ], - ]); - }); -}); diff --git a/ui/address/contract/utils.ts b/ui/address/contract/utils.ts index 4474b89eb8..8fa04e839c 100644 --- a/ui/address/contract/utils.ts +++ b/ui/address/contract/utils.ts @@ -1,68 +1,15 @@ import type { Abi } from 'abitype'; -import _mapValues from 'lodash/mapValues'; -import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types'; -import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract'; +import type { SmartContractWriteMethod } from 'types/api/contract'; -export const INT_REGEXP = /^(u)?int(\d+)?$/i; - -export const BYTES_REGEXP = /^bytes(\d+)?$/i; - -export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/; - -export const getIntBoundaries = (power: number, isUnsigned: boolean) => { - const maxUnsigned = 2 ** power; - const max = isUnsigned ? maxUnsigned - 1 : maxUnsigned / 2 - 1; - const min = isUnsigned ? 0 : -maxUnsigned / 2; - return [ min, max ]; -}; - -export const formatBooleanValue = (value: string) => { - const formattedValue = value.toLowerCase(); - - switch (formattedValue) { - case 'true': - case '1': { - return 'true'; - } - - case 'false': - case '0': { - return 'false'; - } - - default: - return; - } -}; - -export const getNativeCoinValue = (value: string | Array) => { - const _value = Array.isArray(value) ? value[0] : value; - - if (typeof _value !== 'string') { +export const getNativeCoinValue = (value: unknown) => { + if (typeof value !== 'string') { return BigInt(0); } - return BigInt(_value); + return BigInt(value); }; -interface ExtendedError extends Error { - detectedNetwork?: { - chain: number; - name: string; - }; - reason?: string; -} - -export function isExtendedError(error: unknown): error is ExtendedError { - return ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as Record).message === 'string' - ); -} - export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { if ('name' in item) { const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1; @@ -91,107 +38,3 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { return abi; } - -function getFieldType(fieldName: string, inputs: Array) { - const chunks = fieldName.split(':'); - - if (chunks.length === 1) { - const [ , index ] = chunks[0].split('%'); - return inputs[Number(index)].type; - } else { - const group = chunks[0].split('%'); - const input = chunks[1].split('%'); - - return inputs[Number(group[1])].components?.[Number(input[1])].type; - } -} - -function parseArrayValue(value: string) { - try { - const parsedResult = JSON.parse(value); - if (Array.isArray(parsedResult)) { - return parsedResult as Array; - } - throw new Error('Not an array'); - } catch (error) { - return ''; - } -} - -function castValue(value: string, type: SmartContractMethodArgType) { - if (type === 'bool') { - return formatBooleanValue(value) === 'true'; - } - - const intMatch = type.match(INT_REGEXP); - if (intMatch) { - return value.replaceAll(' ', ''); - } - - const isNestedArray = (type.match(/\[/g) || []).length > 1; - const isNestedTuple = type.includes('tuple'); - if (isNestedArray || isNestedTuple) { - return parseArrayValue(value) || value; - } - - return value; -} - -export function formatFieldValues(formFields: MethodFormFields, inputs: Array) { - const formattedFields = _mapValues(formFields, (value, key) => { - const type = getFieldType(key, inputs); - - if (!type) { - return value; - } - - if (Array.isArray(value)) { - const arrayMatch = type.match(ARRAY_REGEXP); - - if (arrayMatch) { - return value.map((item) => castValue(item, arrayMatch[1] as SmartContractMethodArgType)); - } - - return value; - } - - return castValue(value, type); - }); - - return formattedFields; -} - -export function transformFieldsToArgs(formFields: MethodFormFieldsFormatted) { - const unGroupedFields = Object.entries(formFields) - .reduce(( - result: Record, - [ key, value ]: [ string, MethodArgType ], - ) => { - const chunks = key.split(':'); - - if (chunks.length > 1) { - const groupKey = chunks[0]; - const [ , fieldIndex ] = chunks[1].split('%'); - - if (result[groupKey] === undefined) { - result[groupKey] = []; - } - - (result[groupKey] as Array)[Number(fieldIndex)] = value; - return result; - } - - result[key] = value; - return result; - }, {}); - - const args = (Object.entries(unGroupedFields) - .map(([ key, value ]) => { - const [ , index ] = key.split('%'); - return [ Number(index), value ]; - }) as Array<[ number, string | Array ]>) - .sort((a, b) => a[0] - b[0]) - .map(([ , value ]) => value); - - return args; -} diff --git a/ui/address/tokens/AddressCollections.tsx b/ui/address/tokens/AddressCollections.tsx index 96b8326116..2be76c5c87 100644 --- a/ui/address/tokens/AddressCollections.tsx +++ b/ui/address/tokens/AddressCollections.tsx @@ -33,7 +33,7 @@ const AddressCollections = ({ collectionsQuery, address, hasActiveFilters }: Pro ); - const content = data?.items ? data?.items.map((item, index) => { + const content = data?.items ? data?.items.filter((item) => item.token_instances.length > 0).map((item, index) => { const collectionUrl = route({ pathname: '/token/[hash]', query: { diff --git a/ui/blocks/BlocksTable.tsx b/ui/blocks/BlocksTable.tsx index cfd481321d..9a422d0de5 100644 --- a/ui/blocks/BlocksTable.tsx +++ b/ui/blocks/BlocksTable.tsx @@ -33,7 +33,7 @@ const isRollup = config.features.rollup.isEnabled; const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum, socketInfoAlert }: Props) => { const widthBase = - VALIDATOR_COL_WEIGHT + + (!config.UI.views.block.hiddenFields?.miner ? VALIDATOR_COL_WEIGHT : 0) + GAS_COL_WEIGHT + (!isRollup && !config.UI.views.block.hiddenFields?.total_reward ? REWARD_COL_WEIGHT : 0) + (!isRollup && !config.UI.views.block.hiddenFields?.burnt_fees ? FEES_COL_WEIGHT : 0); diff --git a/ui/gasTracker/GasTrackerPriceSnippet.tsx b/ui/gasTracker/GasTrackerPriceSnippet.tsx index 177edb1e09..575fc2d732 100644 --- a/ui/gasTracker/GasTrackerPriceSnippet.tsx +++ b/ui/gasTracker/GasTrackerPriceSnippet.tsx @@ -27,7 +27,6 @@ const ICONS: Record = { }; const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => { - const bgColors = { fast: 'transparent', average: useColorModeValue('gray.50', 'whiteAlpha.200'), @@ -43,19 +42,19 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => { w={{ lg: 'calc(100% / 3)' }} bgColor={ bgColors[type] } > - { TITLES[type] } + { TITLES[type] } - + { data.price && data.fiat_price && } per transaction { data.time && / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s } - + { data.base_fee && Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) } } { data.base_fee && data.priority_fee && / } { data.priority_fee && Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) } } diff --git a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-gwei-as-primary-unit-dark-mode-1.png b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-gwei-as-primary-unit-dark-mode-1.png index ddf77df850..791fee07ea 100644 Binary files a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-gwei-as-primary-unit-dark-mode-1.png and b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-gwei-as-primary-unit-dark-mode-1.png differ diff --git a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-usd-as-primary-unit-dark-mode-1.png b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-usd-as-primary-unit-dark-mode-1.png index ca8c6b0094..1a7b39a125 100644 Binary files a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-usd-as-primary-unit-dark-mode-1.png and b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_dark-color-mode_with-usd-as-primary-unit-dark-mode-1.png differ diff --git a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-gwei-as-primary-unit-dark-mode-1.png b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-gwei-as-primary-unit-dark-mode-1.png index edd6c4ff21..28bef4c82c 100644 Binary files a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-gwei-as-primary-unit-dark-mode-1.png and b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-gwei-as-primary-unit-dark-mode-1.png differ diff --git a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-usd-as-primary-unit-dark-mode-1.png b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-usd-as-primary-unit-dark-mode-1.png index 9d1674a654..ad7261ea29 100644 Binary files a/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-usd-as-primary-unit-dark-mode-1.png and b/ui/gasTracker/__screenshots__/GasTrackerPriceSnippet.pw.tsx_default_with-usd-as-primary-unit-dark-mode-1.png differ diff --git a/ui/marketplace/useMarketplace.tsx b/ui/marketplace/useMarketplace.tsx index 80221c65a2..20cfc8424e 100644 --- a/ui/marketplace/useMarketplace.tsx +++ b/ui/marketplace/useMarketplace.tsx @@ -30,6 +30,7 @@ export default function useMarketplace() { const [ selectedCategoryId, setSelectedCategoryId ] = React.useState(MarketplaceCategory.ALL); const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery); const [ favoriteApps, setFavoriteApps ] = React.useState>([]); + const [ isFavoriteAppsLoaded, setIsFavoriteAppsLoaded ] = React.useState(false); const [ isAppInfoModalOpen, setIsAppInfoModalOpen ] = React.useState(false); const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState(false); @@ -71,11 +72,16 @@ export default function useMarketplace() { setSelectedCategoryId(newCategory); }, []); - const { isPlaceholderData, isError, error, data, displayedApps } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps); - const { isPlaceholderData: isCategoriesPlaceholderData, data: categories } = useMarketplaceCategories(data, isPlaceholderData); + const { + isPlaceholderData, isError, error, data, displayedApps, + } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded); + const { + isPlaceholderData: isCategoriesPlaceholderData, data: categories, + } = useMarketplaceCategories(data, isPlaceholderData); React.useEffect(() => { setFavoriteApps(getFavoriteApps()); + setIsFavoriteAppsLoaded(true); }, [ ]); React.useEffect(() => { diff --git a/ui/marketplace/useMarketplaceApps.tsx b/ui/marketplace/useMarketplaceApps.tsx index 360b5f0bca..e865de0c6d 100644 --- a/ui/marketplace/useMarketplaceApps.tsx +++ b/ui/marketplace/useMarketplaceApps.tsx @@ -47,7 +47,12 @@ function sortApps(apps: Array, favoriteApps: Array = []) { +export default function useMarketplaceApps( + filter: string, + selectedCategoryId: string = MarketplaceCategory.ALL, + favoriteApps: Array = [], + isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types +) { const fetch = useFetch(); const apiFetch = useApiFetch(); @@ -55,7 +60,7 @@ export default function useMarketplaceApps(filter: string, selectedCategoryId: s const lastFavoriteAppsRef = React.useRef(favoriteApps); React.useEffect(() => { lastFavoriteAppsRef.current = favoriteApps; - }, [ selectedCategoryId ]); // eslint-disable-line react-hooks/exhaustive-deps + }, [ selectedCategoryId, isFavoriteAppsLoaded ]); // eslint-disable-line react-hooks/exhaustive-deps const { isPlaceholderData, isError, error, data } = useQuery, Array>({ queryKey: [ 'marketplace-dapps' ], diff --git a/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png index daae678871..f773687f4e 100644 Binary files a/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/GasTracker.pw.tsx_dark-color-mode_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png index 7e95806cb4..f8330a6402 100644 Binary files a/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/GasTracker.pw.tsx_default_base-view-dark-mode-mobile-1.png differ diff --git a/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png b/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png index 59f2896793..3d0aba111a 100644 Binary files a/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png and b/ui/pages/__screenshots__/GasTracker.pw.tsx_mobile_base-view-dark-mode-mobile-1.png differ diff --git a/ui/shared/EmptySearchResult.tsx b/ui/shared/EmptySearchResult.tsx index ed6e71875d..92c27fbecc 100644 --- a/ui/shared/EmptySearchResult.tsx +++ b/ui/shared/EmptySearchResult.tsx @@ -1,7 +1,10 @@ -import { Box, Heading, Text } from '@chakra-ui/react'; +import { Box, Heading, Text, Icon } from '@chakra-ui/react'; import React from 'react'; -import IconSvg from 'ui/shared/IconSvg'; +// This icon doesn't work properly when it is in the sprite +// Probably because of radial gradient +// eslint-disable-next-line no-restricted-imports +import emptySearchResultIcon from 'icons/empty_search_result.svg'; interface Props { text: string | JSX.Element; @@ -14,11 +17,7 @@ const EmptySearchResult = ({ text }: Props) => { flexDirection="column" alignItems="center" > - + } { secondRow && ( - + { secondRow } ) } diff --git a/ui/shared/pagination/useQueryWithPages.ts b/ui/shared/pagination/useQueryWithPages.ts index 4478f91883..bf776f9d9a 100644 --- a/ui/shared/pagination/useQueryWithPages.ts +++ b/ui/shared/pagination/useQueryWithPages.ts @@ -24,6 +24,8 @@ export interface Params { type NextPageParams = Record; +const INITIAL_PAGE_PARAMS = { '1': {} }; + function getPaginationParamsFromQuery(queryString: string | Array | undefined) { if (queryString) { try { @@ -147,7 +149,7 @@ export default function useQueryWithPages({ router.push({ pathname: router.pathname, query: nextRouterQuery }, undefined, { shallow: true }).then(() => { queryClient.removeQueries({ queryKey: [ resourceName ] }); setPage(1); - setPageParams({}); + setPageParams(INITIAL_PAGE_PARAMS); window.setTimeout(() => { // FIXME after router is updated we still have inactive queries for previously visited page (e.g third), where we came from // so have to remove it but with some delay :) @@ -177,7 +179,7 @@ export default function useQueryWithPages({ ).then(() => { setHasPages(false); setPage(1); - setPageParams({}); + setPageParams(INITIAL_PAGE_PARAMS); }); }, [ router, resource.filterFields, scrollToTop ]); @@ -197,7 +199,7 @@ export default function useQueryWithPages({ ).then(() => { setHasPages(false); setPage(1); - setPageParams({}); + setPageParams(INITIAL_PAGE_PARAMS); }); }, [ router, scrollToTop ]); diff --git a/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_mobile_recent-keywords-suggest-mobile-1.png b/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_mobile_recent-keywords-suggest-mobile-1.png index bc7a68e250..a7c2b5af44 100644 Binary files a/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_mobile_recent-keywords-suggest-mobile-1.png and b/ui/snippets/searchBar/__screenshots__/SearchBar.pw.tsx_mobile_recent-keywords-suggest-mobile-1.png differ diff --git a/ui/snippets/topBar/SwapButton.tsx b/ui/snippets/topBar/SwapButton.tsx index 3792ac90a1..b446455f9a 100644 --- a/ui/snippets/topBar/SwapButton.tsx +++ b/ui/snippets/topBar/SwapButton.tsx @@ -1,14 +1,24 @@ import { Button, Box } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; import React from 'react'; import { route } from 'nextjs-routes'; import config from 'configs/app'; +import getPageType from 'lib/mixpanel/getPageType'; +import * as mixpanel from 'lib/mixpanel/index'; import IconSvg from 'ui/shared/IconSvg'; const feature = config.features.swapButton; const SwapButton = () => { + const router = useRouter(); + const source = getPageType(router.pathname); + + const handleClick = React.useCallback(() => { + mixpanel.logEvent(mixpanel.EventTypes.BUTTON_CLICK, { Content: 'Swap button', Source: source }); + }, [ source ]); + if (!feature.isEnabled) { return null; } @@ -27,6 +37,7 @@ const SwapButton = () => { borderRadius="sm" height={ 5 } px={ 1.5 } + onClick={ handleClick } > diff --git a/ui/token/TokenInventory.tsx b/ui/token/TokenInventory.tsx index eed0e3fe47..dce3a726f6 100644 --- a/ui/token/TokenInventory.tsx +++ b/ui/token/TokenInventory.tsx @@ -83,6 +83,10 @@ const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => { isError={ inventoryQuery.isError } items={ items } emptyText="There are no tokens." + filterProps={{ + hasActiveFilters: Boolean(ownerFilter), + emptyFilteredText: 'No tokens found for the selected owner.', + }} content={ content } actionBar={ actionBar } />