Commit fbf1fb41 authored by Max Alekseenko's avatar Max Alekseenko

Merge branch 'main' into marketplace-tests

parents 0c7453c7 3e376f9d
...@@ -338,14 +338,15 @@ ...@@ -338,14 +338,15 @@
"options": [ "options": [
"main", "main",
"main.L2", "main.L2",
"poa_core", "eth",
"eth_goerli", "eth_goerli",
"sepolia", "sepolia",
"eth",
"rootstock",
"polygon", "polygon",
"zkevm", "zkevm",
"gnosis", "gnosis",
"rootstock",
"stability",
"poa_core",
"localhost", "localhost",
], ],
"default": "main" "default": "main"
......
# 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
...@@ -57,7 +57,7 @@ B. Pre-defined configuration: ...@@ -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. 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. 2. Choose one of the predefined configurations located in the `/configs/envs` folder.
3. Start your local dev server using the `yarn dev:<config_name>` command. 3. Start your local dev server using the `yarn dev:preset <config_preset_name>` command.
4. Open your browser and navigate to the URL provided in the command line output (by default, it is `http://localhost:3000`). 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: ...@@ -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: 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 - `app` - the front-end app itself
- `api` - the main API configuration - `api` - the main API configuration
- `chain` - the Blockchain parameters
- `UI` - the app UI customization - `UI` - the app UI customization
- `meta` - SEO and meta-tags customization
- `features` - the particular feature of the app - `features` - the particular feature of the app
- `services` - some 3rd party service integration which is not related to one particular feature - `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 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. Add the variable to CI configs where it is needed 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/review/values.yaml.gotmpl` - review development environment
- `deploy/values/main/values.yaml` - main 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/review-l2/values.yaml.gotmpl` - review development environment for L2 networks
- `deploy/values/l2-optimism-goerli/values.yaml` - main development environment - `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. 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. 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: 8. Check if modified validation schema is valid by doing the following steps:
- change your current directory to `deploy/tools/envs-validator` - change your current directory to `deploy/tools/envs-validator`
- install deps with `yarn` command - install deps with `yarn` command
- add your variable into `./test/.env.base` test preset or create a new test preset if needed - 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: ...@@ -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`) - 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 - in the main script `index.ts` extend array `envsWithJsonConfig` with your variable name
- run `yarn test` command to see the validation result - 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
&nbsp; &nbsp;
......
...@@ -657,7 +657,7 @@ export const RESOURCES = { ...@@ -657,7 +657,7 @@ export const RESOURCES = {
validators: { validators: {
path: '/api/v2/validators/:chainType', path: '/api/v2/validators/:chainType',
pathParams: [ 'chainType' as const ], pathParams: [ 'chainType' as const ],
filterFields: [ 'address_hash' as const, 'state' as const ], filterFields: [ 'address_hash' as const, 'state_filter' as const ],
}, },
validators_counters: { validators_counters: {
path: '/api/v2/validators/:chainType/counters', path: '/api/v2/validators/:chainType/counters',
......
...@@ -14,7 +14,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -14,7 +14,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/address/[hash]/contract-verification': 'contract verification for %hash%', '/address/[hash]/contract-verification': 'contract verification for %hash%',
'/tokens': 'tokens', '/tokens': 'tokens',
'/token/[hash]': '%symbol% token details', '/token/[hash]': '%symbol% token details',
'/token/[hash]/instance/[id]': 'token instance for %symbol%', '/token/[hash]/instance/[id]': 'NFT instance',
'/apps': 'apps marketplace', '/apps': 'apps marketplace',
'/apps/[id]': '- %app_name%', '/apps/[id]': '- %app_name%',
'/stats': 'statistics', '/stats': 'statistics',
......
...@@ -5,11 +5,8 @@ import type { Route } from 'nextjs-routes'; ...@@ -5,11 +5,8 @@ import type { Route } from 'nextjs-routes';
import generate from './generate'; import generate from './generate';
export default function update<R extends Route>(route: R, apiData: ApiData<R>) { export default function update<R extends Route>(route: R, apiData: ApiData<R>) {
const { title, description, opengraph } = generate(route, apiData); const { title, description } = generate(route, apiData);
window.document.title = title; window.document.title = title;
window.document.querySelector('meta[name="description"]')?.setAttribute('content', description); 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);
} }
...@@ -17,7 +17,8 @@ export enum EventTypes { ...@@ -17,7 +17,8 @@ export enum EventTypes {
PAGE_WIDGET = 'Page widget', PAGE_WIDGET = 'Page widget',
TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction', TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction',
EXPERIMENT_STARTED = 'Experiment started', EXPERIMENT_STARTED = 'Experiment started',
FILTERS = 'Filters' FILTERS = 'Filters',
BUTTON_CLICK = 'Button click',
} }
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
...@@ -73,9 +74,15 @@ Type extends EventTypes.WALLET_CONNECT ? { ...@@ -73,9 +74,15 @@ Type extends EventTypes.WALLET_CONNECT ? {
'Source': 'Header' | 'Smart contracts' | 'Swap button'; 'Source': 'Header' | 'Smart contracts' | 'Swap button';
'Status': 'Started' | 'Connected'; 'Status': 'Started' | 'Connected';
} : } :
Type extends EventTypes.WALLET_ACTION ? { Type extends EventTypes.WALLET_ACTION ? (
'Action': 'Open' | 'Address click'; {
} : 'Action': 'Open' | 'Address click';
} | {
'Action': 'Send Transaction' | 'Sign Message' | 'Sign Typed Data';
'Address': string | undefined;
'AppId': string;
}
) :
Type extends EventTypes.CONTRACT_INTERACTION ? { Type extends EventTypes.CONTRACT_INTERACTION ? {
'Method type': 'Read' | 'Write'; 'Method type': 'Read' | 'Write';
'Method name': string; 'Method name': string;
...@@ -107,5 +114,9 @@ Type extends EventTypes.FILTERS ? { ...@@ -107,5 +114,9 @@ Type extends EventTypes.FILTERS ? {
'Source': 'Marketplace'; 'Source': 'Marketplace';
'Filter name': string; 'Filter name': string;
} : } :
Type extends EventTypes.BUTTON_CLICK ? {
'Content': 'Swap button';
'Source': string;
} :
undefined; undefined;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -132,6 +132,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -132,6 +132,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x01',
}, },
{ {
constant: false, constant: false,
...@@ -146,6 +147,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -146,6 +147,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: true, payable: true,
stateMutability: 'payable', stateMutability: 'payable',
type: 'function', type: 'function',
method_id: '0x02',
}, },
{ {
stateMutability: 'payable', stateMutability: 'payable',
...@@ -159,6 +161,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -159,6 +161,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x03',
}, },
{ {
constant: false, constant: false,
...@@ -173,6 +176,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -173,6 +176,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x04',
}, },
{ {
constant: false, constant: false,
...@@ -190,6 +194,7 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -190,6 +194,7 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x05',
}, },
{ {
constant: false, constant: false,
...@@ -208,5 +213,6 @@ export const write: Array<SmartContractWriteMethod> = [ ...@@ -208,5 +213,6 @@ export const write: Array<SmartContractWriteMethod> = [
payable: false, payable: false,
stateMutability: 'nonpayable', stateMutability: 'nonpayable',
type: 'function', type: 'function',
method_id: '0x06',
}, },
]; ];
...@@ -31,6 +31,8 @@ const getCspReportUrl = () => { ...@@ -31,6 +31,8 @@ const getCspReportUrl = () => {
}; };
export function app(): CspDev.DirectiveDescriptor { export function app(): CspDev.DirectiveDescriptor {
const marketplaceFeaturePayload = getFeaturePayload(config.features.marketplace);
return { return {
'default-src': [ 'default-src': [
// KEY_WORDS.NONE, // KEY_WORDS.NONE,
...@@ -54,6 +56,7 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -54,6 +56,7 @@ export function app(): CspDev.DirectiveDescriptor {
getFeaturePayload(config.features.verifiedTokens)?.api.endpoint, getFeaturePayload(config.features.verifiedTokens)?.api.endpoint,
getFeaturePayload(config.features.addressVerification)?.api.endpoint, getFeaturePayload(config.features.addressVerification)?.api.endpoint,
getFeaturePayload(config.features.nameService)?.api.endpoint, getFeaturePayload(config.features.nameService)?.api.endpoint,
marketplaceFeaturePayload && 'api' in marketplaceFeaturePayload ? marketplaceFeaturePayload.api.endpoint : '',
// chain RPC server // chain RPC server
config.chain.rpcUrl, config.chain.rpcUrl,
......
...@@ -62,12 +62,11 @@ export interface SmartContractMethodBase { ...@@ -62,12 +62,11 @@ export interface SmartContractMethodBase {
type: 'function'; type: 'function';
payable: boolean; payable: boolean;
error?: string; error?: string;
}
export interface SmartContractReadMethod extends SmartContractMethodBase {
method_id: string; method_id: string;
} }
export type SmartContractReadMethod = SmartContractMethodBase;
export interface SmartContractWriteFallback { export interface SmartContractWriteFallback {
payable?: true; payable?: true;
stateMutability: 'payable'; stateMutability: 'payable';
...@@ -85,7 +84,7 @@ export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWr ...@@ -85,7 +84,7 @@ export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWr
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod; export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput { export interface SmartContractMethodInput {
internalType?: SmartContractMethodArgType; internalType?: string; // there could be any string, e.g "enum MyEnum"
name: string; name: string;
type: SmartContractMethodArgType; type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>; components?: Array<SmartContractMethodInput>;
......
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<MethodFormFields>();
const arrayTypeMatch = argType.match(ARRAY_REGEXP);
const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const content = arrayTypeMatch ? (
<ContractMethodFieldArray
name={ fieldName }
argType={ arrayTypeMatch[1] as SmartContractMethodArgType }
size={ Number(arrayTypeMatch[2] || Infinity) }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
) : (
<ContractMethodField
name={ fieldName }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
isOptional={ isOptional }
onChange={ onChange }
/>
);
const isNativeCoinField = fieldType === 'native_coin';
return (
<Flex
flexDir={{ base: 'column', lg: 'row' }}
columnGap={ 3 }
rowGap={{ base: 2, lg: 0 }}
bgColor={ isNativeCoinField ? nativeCoinFieldBgColor : undefined }
py={ isNativeCoinField ? 1 : undefined }
px={ isNativeCoinField ? '6px' : undefined }
mx={ isNativeCoinField ? '-6px' : undefined }
borderRadius="base"
>
<Box
position="relative"
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
color={ isGrouped ? 'text_secondary' : 'initial' }
wordBreak="break-word"
w={{ lg: '250px' }}
flexShrink={ 0 }
>
{ argName }{ isOptional ? '' : '*' } ({ argType })
</Box>
{ content }
</Flex>
);
};
export default React.memo(ContractMethodCallableRow);
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<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
isDisabled: boolean;
isOptional?: boolean;
onChange: () => void;
}
const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => {
const ref = React.useRef<HTMLInputElement>(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<MethodFormFields>; formState: UseFormStateReturn<MethodFormFields> },
) => {
const error: FieldError | undefined = index !== undefined && groupName !== undefined ?
(formState.errors[groupName] as unknown as Array<FieldError>)?.[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 (
<Box w="100%">
<FormControl
id={ name }
isDisabled={ isDisabled }
>
<InputGroup size="xs">
<Input
{ ...field }
{ ...(intMatch ? {
as: NumericFormat,
thousandSeparator: ' ',
decimalScale: 0,
allowNegative: !intMatch.isUnsigned,
} : {}) }
ref={ ref }
isInvalid={ Boolean(error) }
required={ !isOptional }
placeholder={ placeholder }
paddingRight={ hasZerosControl ? '120px' : '40px' }
autoComplete="off"
bgColor={ bgColor }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
</FormControl>
{ error && <Box color="error" fontSize="sm" mt={ 1 }>{ error.message }</Box> }
</Box>
);
}, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]);
const validate = React.useCallback((_value: string | Array<string> | 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 (
<Controller
name={ name }
control={ control }
render={ renderInput }
rules={{ required: isOptional ? false : 'Field is required', validate }}
/>
);
};
export default React.memo(ContractMethodField);
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<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>;
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<HTMLButtonElement>) => {
const itemIndex = event.currentTarget.getAttribute('data-index');
if (itemIndex) {
remove(Number(itemIndex));
}
}, [ remove ]);
return (
<Flex flexDir="column" rowGap={ 3 } w="100%">
{ fields.map((field, index, array) => {
return (
<Flex key={ field.id } columnGap={ 3 }>
<ContractMethodField
name={ `${ name }[${ index }]` }
groupName={ name }
index={ index }
argType={ argType }
placeholder={ argType }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isDisabled }
onChange={ onChange }
/>
{ array.length > 1 && size === Infinity && (
<IconButton
aria-label="remove"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleRemoveButtonClick }
icon={ <IconSvg name="minus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
{ index === array.length - 1 && size === Infinity && (
<IconButton
aria-label="add"
data-index={ index }
variant="outline"
w="30px"
h="30px"
flexShrink={ 0 }
onClick={ handleAddButtonClick }
icon={ <IconSvg name="plus" boxSize={ 4 }/> }
isDisabled={ isDisabled }
/>
) }
</Flex>
);
}) }
</Flex>
);
};
export default React.memo(ContractMethodFieldArray);
...@@ -45,50 +45,54 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -45,50 +45,54 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
return ( return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}> <AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
<Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }> { ({ isExpanded }) => (
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left"> <>
{ 'method_id' in data && ( <Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }>
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }> <AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
<Box { 'method_id' in data && (
boxSize={ 5 } <Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
color="text_secondary" <Box
_hover={{ color: 'link_hovered' }} boxSize={ 5 }
mr={ 2 } color="text_secondary"
onClick={ handleCopyLinkClick } _hover={{ color: 'link_hovered' }}
onMouseEnter={ onOpen } mr={ 2 }
onMouseLeave={ onClose } onClick={ handleCopyLinkClick }
> onMouseEnter={ onOpen }
<IconSvg name="link" boxSize={ 5 }/> onMouseLeave={ onClose }
>
<IconSvg name="link" boxSize={ 5 }/>
</Box>
</Tooltip>
) }
<Box as="span" fontWeight={ 500 } mr={ 1 }>
{ index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name }
</Box> </Box>
</Tooltip> { data.type === 'fallback' && (
) } <Hint
<Box as="span" fontWeight={ 500 } mr={ 1 }> label={
{ index + 1 }. { data.type === 'fallback' || data.type === 'receive' ? data.type : data.name } `The fallback function is executed on a call to the contract if none of the other functions match
</Box>
{ data.type === 'fallback' && (
<Hint
label={
`The fallback function is executed on a call to the contract if none of the other functions match
the given function signature, or if no data was supplied at all and there is no receive Ether function. the given function signature, or if no data was supplied at all and there is no receive Ether function.
The fallback function always receives data, but in order to also receive Ether it must be marked payable.` The fallback function always receives data, but in order to also receive Ether it must be marked payable.`
}/> }/>
) } ) }
{ data.type === 'receive' && ( { data.type === 'receive' && (
<Hint <Hint
label={ label={
`The receive function is executed on a call to the contract with empty calldata. `The receive function is executed on a call to the contract with empty calldata.
This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()). This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()).
If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer.
If neither a receive Ether nor a payable fallback function is present, If neither a receive Ether nor a payable fallback function is present,
the contract cannot receive Ether through regular transactions and throws an exception.` the contract cannot receive Ether through regular transactions and throws an exception.`
}/> }/>
) } ) }
<AccordionIcon/> <AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton> </AccordionButton>
</Element> </Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)"> <AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) } { renderContent(data, index, id) }
</AccordionPanel> </AccordionPanel>
</>
) }
</AccordionItem> </AccordionItem>
); );
}; };
......
...@@ -37,7 +37,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -37,7 +37,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); 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(/read/i).click();
await component.getByText(/wei/i).click(); await component.getByText(/wei/i).click();
......
...@@ -14,9 +14,9 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -14,9 +14,9 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractMethodConstant from './ContractMethodConstant'; import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult'; import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount'; import useWatchAccount from './useWatchAccount';
const ContractRead = () => { const ContractRead = () => {
...@@ -40,7 +40,7 @@ const ContractRead = () => { ...@@ -40,7 +40,7 @@ const ContractRead = () => {
}, },
}); });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<string | Array<unknown>>) => { const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<unknown>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', { return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
queryParams: { queryParams: {
...@@ -72,11 +72,12 @@ const ContractRead = () => { ...@@ -72,11 +72,12 @@ const ContractRead = () => {
} }
return ( return (
<ContractMethodCallable <ContractMethodForm
key={ id + '_' + index } key={ id + '_' + index }
data={ item } data={ item }
onSubmit={ handleMethodFormSubmit } onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractReadResult } resultComponent={ ContractReadResult }
methodType="read"
/> />
); );
}, [ handleMethodFormSubmit ]); }, [ handleMethodFormSubmit ]);
......
...@@ -14,8 +14,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert'; ...@@ -14,8 +14,8 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractWriteResult from './ContractWriteResult'; import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi'; import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils'; import { getNativeCoinValue, prepareAbi } from './utils';
...@@ -39,12 +39,14 @@ const ContractWrite = () => { ...@@ -39,12 +39,14 @@ const ContractWrite = () => {
}, },
queryOptions: { queryOptions: {
enabled: Boolean(addressHash), enabled: Boolean(addressHash),
refetchOnMount: false,
}, },
}); });
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi }); const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<string | Array<unknown>>) => { // TODO @tom2drum maybe move this inside the form
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<unknown>) => {
if (!isConnected) { if (!isConnected) {
throw new Error('Wallet is not connected'); throw new Error('Wallet is not connected');
} }
...@@ -66,21 +68,22 @@ const ContractWrite = () => { ...@@ -66,21 +68,22 @@ const ContractWrite = () => {
return { hash }; 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; const methodName = item.name;
if (!methodName) { if (!methodName) {
throw new Error('Method name is not defined'); 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 abi = prepareAbi(contractAbi, item);
const hash = await walletClient?.writeContract({ const hash = await walletClient?.writeContract({
args: _args, args: _args,
abi, abi,
functionName: methodName, functionName: methodName,
address: addressHash as `0x${ string }`, address: addressHash as `0x${ string }`,
value: value as undefined, value,
}); });
return { hash }; return { hash };
...@@ -88,12 +91,12 @@ const ContractWrite = () => { ...@@ -88,12 +91,12 @@ const ContractWrite = () => {
const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => { const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return ( return (
<ContractMethodCallable <ContractMethodForm
key={ id + '_' + index } key={ id + '_' + index }
data={ item } data={ item }
onSubmit={ handleMethodFormSubmit } onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractWriteResult } resultComponent={ ContractWriteResult }
isWrite methodType="write"
/> />
); );
}, [ handleMethodFormSubmit ]); }, [ handleMethodFormSubmit ]);
......
import React from 'react'; import React from 'react';
import { useWaitForTransaction } from 'wagmi'; import { useWaitForTransaction } from 'wagmi';
import type { ResultComponentProps } from './methodForm/types';
import type { ContractMethodWriteResult } from './types'; import type { ContractMethodWriteResult } from './types';
import type { SmartContractWriteMethod } from 'types/api/contract';
import ContractWriteResultDumb from './ContractWriteResultDumb'; import ContractWriteResultDumb from './ContractWriteResultDumb';
interface Props { const ContractWriteResult = ({ result, onSettle }: ResultComponentProps<SmartContractWriteMethod>) => {
result: ContractMethodWriteResult;
onSettle: () => void;
}
const ContractWriteResult = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined; const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransaction({ const txInfo = useWaitForTransaction({
hash: txHash, hash: txHash,
}); });
return <ContractWriteResultDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>; return <ContractWriteResultDumb result={ result as ContractMethodWriteResult } onSettle={ onSettle } txInfo={ txInfo }/>;
}; };
export default React.memo(ContractWriteResult); export default React.memo(ContractWriteResult) as typeof ContractWriteResult;
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<HTMLButtonElement>) => void;
isDisabled?: boolean;
type: 'add' | 'remove';
className?: string;
}
const ContractMethodArrayButton = ({ className, type, index, onClick, isDisabled }: Props) => {
return (
<IconButton
className={ className }
aria-label={ type }
data-index={ index }
variant="outline"
w="20px"
h="20px"
flexShrink={ 0 }
onClick={ onClick }
icon={ <IconSvg name={ type === 'remove' ? 'minus' : 'plus' } boxSize={ 3 }/> }
isDisabled={ isDisabled }
/>
);
};
export default React.memo(chakra(ContractMethodArrayButton));
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<HTMLButtonElement>) => void;
onRemoveClick?: (event: React.MouseEvent<HTMLButtonElement>) => 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 (
<Accordion allowToggle w="100%" bgColor={ level === 0 ? bgColorLevel0 : bgColor } borderRadius="base">
<AccordionItem _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => (
<>
<AccordionButton
as="div"
cursor="pointer"
px="6px"
py="6px"
wordBreak="break-all"
textAlign="left"
_hover={{ bgColor: 'inherit' }}
>
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
<Box fontSize="sm" lineHeight={ 5 } fontWeight={ 700 } mr="auto" ml={ 1 } color={ isInvalid ? 'error' : undefined }>
{ label }
</Box>
{ onRemoveClick && <ContractMethodArrayButton index={ index } onClick={ onRemoveClick } type="remove"/> }
{ onAddClick && <ContractMethodArrayButton index={ index } onClick={ onAddClick } type="add" ml={ 2 }/> }
</AccordionButton>
<AccordionPanel display="flex" flexDir="column" rowGap={ 1 } pl="18px" pr="6px">
{ children }
</AccordionPanel>
</>
) }
</AccordionItem>
</Accordion>
);
};
export default React.memo(ContractMethodFieldAccordion);
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<HTMLInputElement>(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 (
<Flex
className={ className }
flexDir={{ base: 'column', md: 'row' }}
alignItems="flex-start"
columnGap={ 3 }
w="100%"
bgColor={ isNativeCoin ? nativeCoinRowBgColor : undefined }
borderRadius="base"
px="6px"
py={ isNativeCoin ? 1 : 0 }
>
{ !hideLabel && <ContractMethodFieldLabel data={ data } isOptional={ isOptional } level={ level }/> }
<FormControl isDisabled={ isDisabled }>
<InputGroup size="xs">
<Input
{ ...field }
{ ...(argTypeMatchInt ? {
as: NumericFormat,
thousandSeparator: ' ',
decimalScale: 0,
allowNegative: !argTypeMatchInt.isUnsigned,
} : {}) }
ref={ ref }
required={ !isOptional }
isInvalid={ Boolean(error) }
placeholder={ data.type }
autoComplete="off"
bgColor={ inputBgColor }
paddingRight={ hasMultiplyButton ? '120px' : '40px' }
/>
<InputRightElement w="auto" right={ 1 }>
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
{ hasMultiplyButton && <ContractMethodMultiplyButton onClick={ handleMultiplyButtonClick } isDisabled={ isDisabled }/> }
</InputRightElement>
</InputGroup>
{ error && <Box color="error" fontSize="sm" lineHeight={ 5 } mt={ 1 }>{ error.message }</Box> }
</FormControl>
</Flex>
);
};
export default React.memo(chakra(ContractMethodFieldInput));
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<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
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<HTMLButtonElement>) => {
event.preventDefault();
setRegisteredIndices((prev) => [ ...prev, prev[prev.length - 1] + 1 ]);
}, []);
const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
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 (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputArray
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
);
}
const isTupleArray = data.type.includes('tuple');
if (isTupleArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
onAddClick={ onAddClick }
onRemoveClick={ onRemoveClick }
index={ parentIndex }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputTuple
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
);
}
// primitive value array
return (
<Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px">
<ContractMethodFieldLabel data={ data } level={ level }/>
<Flex flexDir="column" rowGap={ 1 } w="100%">
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
<ContractMethodFieldInput
data={ itemData }
hideLabel
path={ `${ basePath }:${ index }` }
level={ level }
px={ 0 }
isDisabled={ isDisabled }
/>
{ registeredIndices.length > 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> }
{ index === registeredIndices.length - 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> }
</Flex>
);
}) }
</Flex>
</Flex>
);
};
export default React.memo(ContractMethodFieldInputArray);
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<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
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 (
<ContractMethodFieldAccordion
{ ...accordionProps }
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
{ data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') {
return (
<ContractMethodFieldInputTuple
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ level + 1 }
isDisabled={ isDisabled }
/>
);
}
const arrayMatch = component.type.match(ARRAY_REGEXP);
if (arrayMatch) {
const [ , itemType ] = arrayMatch;
return (
<ContractMethodFieldInputArray
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled }
/>
);
}
return (
<ContractMethodFieldInput
key={ index }
data={ component }
path={ `${ basePath }:${ index }` }
isDisabled={ isDisabled }
level={ level }
/>
);
}) }
</ContractMethodFieldAccordion>
);
};
export default React.memo(ContractMethodFieldInputTuple);
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 (
<Box
w="250px"
fontSize="sm"
lineHeight={ 5 }
py="6px"
flexShrink={ 0 }
fontWeight={ 500 }
color={ level > 1 ? color : undefined }
>
{ getFieldLabel(data, !isOptional) }
</Box>
);
};
export default React.memo(ContractMethodFieldLabel);
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(
<TestApp>
<ContractMethodForm<SmartContractWriteMethod>
data={ data }
onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write"
/>
</TestApp>,
);
// 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();
});
import { Box, Button, chakra, Flex } from '@chakra-ui/react'; import { Box, Button, Flex, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { MethodFormFields, ContractMethodCallResult } from './types'; import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract'; import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import ContractMethodCallableRow from './ContractMethodCallableRow'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import { formatFieldValues, transformFieldsToArgs } from './utils'; import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
interface ResultComponentProps<T extends SmartContractMethod> { import ContractMethodFormOutputs from './ContractMethodFormOutputs';
item: T; import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils';
result: ContractMethodCallResult<T>; import type { ContractMethodFormFields } from './utils';
onSettle: () => void;
}
interface Props<T extends SmartContractMethod> { interface Props<T extends SmartContractMethod> {
data: T; data: T;
onSubmit: (data: T, args: Array<string | Array<unknown>>) => Promise<ContractMethodCallResult<T>>; onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null; resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null;
isWrite?: boolean; methodType: 'read' | 'write';
} }
// groupName%groupIndex:inputName%inputIndex const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props<T>) => {
const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) =>
`${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`;
const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>(); const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>();
const [ isLoading, setLoading ] = React.useState(false); const [ isLoading, setLoading ] = React.useState(false);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => { const formApi = useForm<ContractMethodFormFields>({
return [ mode: 'all',
...('inputs' in data ? data.inputs : []), shouldUnregister: true,
...('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<MethodFormFields>({
mode: 'onBlur',
}); });
const handleTxSettle = React.useCallback(() => { const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
setLoading(false); const args = transformFormDataToMethodArgs(formData);
}, []);
const handleFormChange = React.useCallback(() => {
result && setResult(undefined);
}, [ result ]);
const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const formattedData = formatFieldValues(formData, inputs);
const args = transformFieldsToArgs(formattedData);
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
...@@ -76,11 +50,31 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -76,11 +50,31 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
}) })
.finally(() => { .finally(() => {
mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, { mixpanel.logEvent(mixpanel.EventTypes.CONTRACT_INTERACTION, {
'Method type': isWrite ? 'Write' : 'Read', 'Method type': methodType === 'write' ? 'Write' : 'Read',
'Method name': 'name' in data ? data.name : 'Fallback', 'Method name': 'name' in data ? data.name : 'Fallback',
}); });
}); });
}, [ inputs, onSubmit, data, isWrite ]); }, [ data, methodType, onSubmit ]);
const handleTxSettle = React.useCallback(() => {
setLoading(false);
}, []);
const handleFormChange = React.useCallback(() => {
result && setResult(undefined);
}, [ result ]);
const inputs: Array<SmartContractMethodInput> = 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 : []; const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
...@@ -92,68 +86,23 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -92,68 +86,23 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
onSubmit={ formApi.handleSubmit(onFormSubmit) } onSubmit={ formApi.handleSubmit(onFormSubmit) }
onChange={ handleFormChange } onChange={ handleFormChange }
> >
<Flex <Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
flexDir="column"
rowGap={ 3 }
mb={ 3 }
_empty={{ display: 'none' }}
>
{ inputs.map((input, index) => { { inputs.map((input, index) => {
const fieldName = getFormFieldName({ name: input.name, index }); if (input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
if (input.type === 'tuple' && input.components) {
return (
<React.Fragment key={ fieldName }>
{ index !== 0 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
<Box
fontWeight={ 500 }
lineHeight="20px"
py={{ lg: '6px' }}
fontSize="sm"
wordBreak="break-word"
>
{ input.name } ({ input.type })
</Box>
{ input.components.map((component, componentIndex) => {
const fieldName = getFormFieldName(
{ name: component.name, index: componentIndex },
{ name: input.name, index },
);
return (
<ContractMethodCallableRow
key={ fieldName }
fieldName={ fieldName }
argName={ component.name }
argType={ component.type }
isDisabled={ isLoading }
onChange={ handleFormChange }
isGrouped
/>
);
}) }
{ index !== inputs.length - 1 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
</React.Fragment>
);
} }
return ( const arrayMatch = input.type.match(ARRAY_REGEXP);
<ContractMethodCallableRow if (arrayMatch) {
key={ fieldName } return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
fieldName={ fieldName } }
fieldType={ input.fieldType }
argName={ input.name } return <ContractMethodFieldInput key={ index } data={ input } path={ `${ index }` } isDisabled={ isLoading } level={ 0 }/>;
argType={ input.type }
isDisabled={ isLoading }
isOptional={ input.fieldType === 'native_coin' && inputs.length > 1 }
onChange={ handleFormChange }
/>
);
}) } }) }
</Flex> </Flex>
<Button <Button
isLoading={ isLoading } isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Read' } loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline" variant="outline"
size="sm" size="sm"
flexShrink={ 0 } flexShrink={ 0 }
...@@ -161,29 +110,14 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -161,29 +110,14 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
px={ 4 } px={ 4 }
type="submit" type="submit"
> >
{ isWrite ? 'Write' : 'Read' } { methodType === 'write' ? 'Write' : 'Read' }
</Button> </Button>
</chakra.form> </chakra.form>
</FormProvider> </FormProvider>
{ !isWrite && outputs.length > 0 && ( { methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> }
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ outputs.map(({ type, name }, index) => {
return (
<>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < outputs.length - 1 && <span>, </span> }
</>
);
}) }
</p>
</Flex>
) }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> } { result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box> </Box>
); );
}; };
export default React.memo(ContractMethodCallable) as typeof ContractMethodCallable; export default React.memo(ContractMethodForm) as typeof ContractMethodForm;
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<SmartContractMethodOutput>;
}
const ContractMethodFormOutputs = ({ data }: Props) => {
if (data.length === 0) {
return null;
}
return (
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ data.map(({ type, name }, index) => {
return (
<>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < data.length - 1 && <span>, </span> }
</>
);
}) }
</p>
</Flex>
);
};
export default React.memo(ContractMethodFormOutputs);
...@@ -21,7 +21,7 @@ interface Props { ...@@ -21,7 +21,7 @@ interface Props {
isDisabled?: boolean; isDisabled?: boolean;
} }
const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { const ContractMethodMultiplyButton = ({ onClick, isDisabled }: Props) => {
const [ selectedOption, setSelectedOption ] = React.useState<number | undefined>(18); const [ selectedOption, setSelectedOption ] = React.useState<number | undefined>(18);
const [ customValue, setCustomValue ] = React.useState<number>(); const [ customValue, setCustomValue ] = React.useState<number>();
const { isOpen, onToggle, onClose } = useDisclosure(); const { isOpen, onToggle, onClose } = useDisclosure();
...@@ -78,7 +78,14 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { ...@@ -78,7 +78,14 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => {
onClick={ onToggle } onClick={ onToggle }
isDisabled={ isDisabled } isDisabled={ isDisabled }
> >
<IconSvg name="arrows/east-mini" transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' } boxSize={ 6 }/> <IconSvg
name="arrows/east-mini"
transitionDuration="fast"
transitionProperty="transform"
transitionTimingFunction="ease-in-out"
transform={ isOpen ? 'rotate(90deg)' : 'rotate(-90deg)' }
boxSize={ 6 }
/>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<Portal> <Portal>
...@@ -126,4 +133,4 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => { ...@@ -126,4 +133,4 @@ const ContractMethodFieldZeroes = ({ onClick, isDisabled }: Props) => {
); );
}; };
export default React.memo(ContractMethodFieldZeroes); export default React.memo(ContractMethodMultiplyButton);
import type { ContractMethodCallResult } from '../types';
import type { SmartContractMethod } from 'types/api/contract';
export interface ResultComponentProps<T extends SmartContractMethod> {
item: T;
result: ContractMethodCallResult<T>;
onSettle: () => void;
}
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 };
}
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 ]);
}
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',
]);
});
});
import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract';
export type ContractMethodFormFields = Record<string, string | undefined>;
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<unknown> = [];
for (const field in formData) {
const value = formData[field];
if (value !== undefined) {
_set(result, field.replaceAll(':', '.'), value);
}
}
return filterOurEmptyItems(result);
}
function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
// 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 || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
}
import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/api/contract'; import type { SmartContractQueryMethodRead, SmartContractMethod, SmartContractReadMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
...@@ -12,4 +12,4 @@ export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceEr ...@@ -12,4 +12,4 @@ export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceEr
export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined; export type ContractMethodWriteResult = Error | { hash: `0x${ string }` | undefined } | undefined;
export type ContractMethodCallResult<T extends SmartContractMethod> = export type ContractMethodCallResult<T extends SmartContractMethod> =
T extends { method_id: string } ? ContractMethodReadResult : ContractMethodWriteResult; T extends SmartContractReadMethod ? ContractMethodReadResult : ContractMethodWriteResult;
import type { SmartContractMethodInput } from 'types/api/contract'; import { prepareAbi } from './utils';
import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils';
describe('function prepareAbi()', () => { describe('function prepareAbi()', () => {
const commonAbi = [ const commonAbi = [
...@@ -48,6 +46,7 @@ describe('function prepareAbi()', () => { ...@@ -48,6 +46,7 @@ describe('function prepareAbi()', () => {
type: 'function' as const, type: 'function' as const,
constant: false, constant: false,
payable: true, payable: true,
method_id: '0x2e0e2d3e',
}; };
it('if there is only one method with provided name, does nothing', () => { it('if there is only one method with provided name, does nothing', () => {
...@@ -100,100 +99,3 @@ describe('function prepareAbi()', () => { ...@@ -100,100 +99,3 @@ describe('function prepareAbi()', () => {
expect(item).toEqual(commonAbi[2]); 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<SmartContractMethodInput> = [
{
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<SmartContractMethodInput> = [
{ 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' ],
]);
});
});
import type { Abi } from 'abitype'; import type { Abi } from 'abitype';
import _mapValues from 'lodash/mapValues';
import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types'; import type { SmartContractWriteMethod } from 'types/api/contract';
import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract';
export const INT_REGEXP = /^(u)?int(\d+)?$/i; export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
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<unknown>) => {
const _value = Array.isArray(value) ? value[0] : value;
if (typeof _value !== 'string') {
return BigInt(0); 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<string, unknown>).message === 'string'
);
}
export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
if ('name' in item) { if ('name' in item) {
const hasMethodsWithSameName = abi.filter((abiItem) => 'name' in abiItem ? abiItem.name === item.name : false).length > 1; 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 { ...@@ -91,107 +38,3 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
return abi; return abi;
} }
function getFieldType(fieldName: string, inputs: Array<SmartContractMethodInput>) {
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<string>;
}
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<SmartContractMethodInput>) {
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<string, MethodArgType>,
[ 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<MethodArgType>)[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<string> ]>)
.sort((a, b) => a[0] - b[0])
.map(([ , value ]) => value);
return args;
}
...@@ -33,7 +33,7 @@ const AddressCollections = ({ collectionsQuery, address, hasActiveFilters }: Pro ...@@ -33,7 +33,7 @@ const AddressCollections = ({ collectionsQuery, address, hasActiveFilters }: Pro
</ActionBar> </ActionBar>
); );
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({ const collectionUrl = route({
pathname: '/token/[hash]', pathname: '/token/[hash]',
query: { query: {
......
...@@ -33,7 +33,7 @@ const isRollup = config.features.rollup.isEnabled; ...@@ -33,7 +33,7 @@ const isRollup = config.features.rollup.isEnabled;
const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum, socketInfoAlert }: Props) => { const BlocksTable = ({ data, isLoading, top, page, showSocketInfo, socketInfoNum, socketInfoAlert }: Props) => {
const widthBase = const widthBase =
VALIDATOR_COL_WEIGHT + (!config.UI.views.block.hiddenFields?.miner ? VALIDATOR_COL_WEIGHT : 0) +
GAS_COL_WEIGHT + GAS_COL_WEIGHT +
(!isRollup && !config.UI.views.block.hiddenFields?.total_reward ? REWARD_COL_WEIGHT : 0) + (!isRollup && !config.UI.views.block.hiddenFields?.total_reward ? REWARD_COL_WEIGHT : 0) +
(!isRollup && !config.UI.views.block.hiddenFields?.burnt_fees ? FEES_COL_WEIGHT : 0); (!isRollup && !config.UI.views.block.hiddenFields?.burnt_fees ? FEES_COL_WEIGHT : 0);
......
...@@ -27,7 +27,6 @@ const ICONS: Record<keyof GasPrices, IconName> = { ...@@ -27,7 +27,6 @@ const ICONS: Record<keyof GasPrices, IconName> = {
}; };
const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => { const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => {
const bgColors = { const bgColors = {
fast: 'transparent', fast: 'transparent',
average: useColorModeValue('gray.50', 'whiteAlpha.200'), average: useColorModeValue('gray.50', 'whiteAlpha.200'),
...@@ -43,19 +42,19 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => { ...@@ -43,19 +42,19 @@ const GasTrackerPriceSnippet = ({ data, type, isLoading }: Props) => {
w={{ lg: 'calc(100% / 3)' }} w={{ lg: 'calc(100% / 3)' }}
bgColor={ bgColors[type] } bgColor={ bgColors[type] }
> >
<Skeleton textStyle="h3" display="inline-block" isLoaded={ !isLoading }>{ TITLES[type] }</Skeleton> <Skeleton textStyle="h3" isLoaded={ !isLoading } w="fit-content">{ TITLES[type] }</Skeleton>
<Flex columnGap={ 3 } alignItems="center" mt={ 3 }> <Flex columnGap={ 3 } alignItems="center" mt={ 3 }>
<IconSvg name={ ICONS[type] } boxSize={{ base: '30px', xl: 10 }} isLoading={ isLoading } flexShrink={ 0 }/> <IconSvg name={ ICONS[type] } boxSize={{ base: '30px', xl: 10 }} isLoading={ isLoading } flexShrink={ 0 }/>
<Skeleton isLoaded={ !isLoading }> <Skeleton isLoaded={ !isLoading }>
<GasPrice data={ data } fontSize={{ base: '36px', xl: '48px' }} lineHeight="48px" fontWeight={ 600 } letterSpacing="-1px" fontFamily="heading"/> <GasPrice data={ data } fontSize={{ base: '36px', xl: '48px' }} lineHeight="48px" fontWeight={ 600 } letterSpacing="-1px" fontFamily="heading"/>
</Skeleton> </Skeleton>
</Flex> </Flex>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 3 } display="inline-block"> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 3 } w="fit-content">
{ data.price && data.fiat_price && <GasPrice data={ data } prefix={ `${ asymp } ` } unitMode="secondary"/> } { data.price && data.fiat_price && <GasPrice data={ data } prefix={ `${ asymp } ` } unitMode="secondary"/> }
<span> per transaction</span> <span> per transaction</span>
{ data.time && <span> / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s</span> } { data.time && <span> / { (data.time / SECOND).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s</span> }
</Skeleton> </Skeleton>
<Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 2 } display="inline-block" whiteSpace="pre"> <Skeleton isLoaded={ !isLoading } fontSize="sm" color="text_secondary" mt={ 2 } w="fit-content" whiteSpace="pre">
{ data.base_fee && <span>Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> } { data.base_fee && <span>Base { data.base_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
{ data.base_fee && data.priority_fee && <span> / </span> } { data.base_fee && data.priority_fee && <span> / </span> }
{ data.priority_fee && <span>Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> } { data.priority_fee && <span>Priority { data.priority_fee.toLocaleString(undefined, { maximumFractionDigits: 0 }) }</span> }
......
...@@ -30,6 +30,7 @@ export default function useMarketplace() { ...@@ -30,6 +30,7 @@ export default function useMarketplace() {
const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL); const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL);
const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery); const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery);
const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]); const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]);
const [ isFavoriteAppsLoaded, setIsFavoriteAppsLoaded ] = React.useState<boolean>(false);
const [ isAppInfoModalOpen, setIsAppInfoModalOpen ] = React.useState<boolean>(false); const [ isAppInfoModalOpen, setIsAppInfoModalOpen ] = React.useState<boolean>(false);
const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false); const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false);
...@@ -71,11 +72,16 @@ export default function useMarketplace() { ...@@ -71,11 +72,16 @@ export default function useMarketplace() {
setSelectedCategoryId(newCategory); setSelectedCategoryId(newCategory);
}, []); }, []);
const { isPlaceholderData, isError, error, data, displayedApps } = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps); const {
const { isPlaceholderData: isCategoriesPlaceholderData, data: categories } = useMarketplaceCategories(data, isPlaceholderData); isPlaceholderData, isError, error, data, displayedApps,
} = useMarketplaceApps(debouncedFilterQuery, selectedCategoryId, favoriteApps, isFavoriteAppsLoaded);
const {
isPlaceholderData: isCategoriesPlaceholderData, data: categories,
} = useMarketplaceCategories(data, isPlaceholderData);
React.useEffect(() => { React.useEffect(() => {
setFavoriteApps(getFavoriteApps()); setFavoriteApps(getFavoriteApps());
setIsFavoriteAppsLoaded(true);
}, [ ]); }, [ ]);
React.useEffect(() => { React.useEffect(() => {
......
...@@ -47,7 +47,12 @@ function sortApps(apps: Array<MarketplaceAppOverview>, favoriteApps: Array<strin ...@@ -47,7 +47,12 @@ function sortApps(apps: Array<MarketplaceAppOverview>, favoriteApps: Array<strin
}); });
} }
export default function useMarketplaceApps(filter: string, selectedCategoryId: string = MarketplaceCategory.ALL, favoriteApps: Array<string> = []) { export default function useMarketplaceApps(
filter: string,
selectedCategoryId: string = MarketplaceCategory.ALL,
favoriteApps: Array<string> = [],
isFavoriteAppsLoaded: boolean = false, // eslint-disable-line @typescript-eslint/no-inferrable-types
) {
const fetch = useFetch(); const fetch = useFetch();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
...@@ -55,7 +60,7 @@ export default function useMarketplaceApps(filter: string, selectedCategoryId: s ...@@ -55,7 +60,7 @@ export default function useMarketplaceApps(filter: string, selectedCategoryId: s
const lastFavoriteAppsRef = React.useRef(favoriteApps); const lastFavoriteAppsRef = React.useRef(favoriteApps);
React.useEffect(() => { React.useEffect(() => {
lastFavoriteAppsRef.current = favoriteApps; 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<unknown, ResourceError<unknown>, Array<MarketplaceAppOverview>>({ const { isPlaceholderData, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppOverview>>({
queryKey: [ 'marketplace-dapps' ], queryKey: [ 'marketplace-dapps' ],
......
...@@ -4,6 +4,7 @@ import type { Account, SignTypedDataParameters } from 'viem'; ...@@ -4,6 +4,7 @@ import type { Account, SignTypedDataParameters } from 'viem';
import { useAccount, useSendTransaction, useSwitchNetwork, useNetwork, useSignMessage, useSignTypedData } from 'wagmi'; import { useAccount, useSendTransaction, useSwitchNetwork, useNetwork, useSignMessage, useSignTypedData } from 'wagmi';
import config from 'configs/app'; import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
type SendTransactionArgs = { type SendTransactionArgs = {
chainId?: number; chainId?: number;
...@@ -20,7 +21,7 @@ export type SignTypedDataArgs< ...@@ -20,7 +21,7 @@ export type SignTypedDataArgs<
TPrimaryType extends string = string, TPrimaryType extends string = string,
> = SignTypedDataParameters<TTypedData, TPrimaryType, Account>; > = SignTypedDataParameters<TTypedData, TPrimaryType, Account>;
export default function useMarketplaceWallet() { export default function useMarketplaceWallet(appId: string) {
const { address } = useAccount(); const { address } = useAccount();
const { chain } = useNetwork(); const { chain } = useNetwork();
const { sendTransactionAsync } = useSendTransaction(); const { sendTransactionAsync } = useSendTransaction();
...@@ -28,6 +29,13 @@ export default function useMarketplaceWallet() { ...@@ -28,6 +29,13 @@ export default function useMarketplaceWallet() {
const { signTypedDataAsync } = useSignTypedData(); const { signTypedDataAsync } = useSignTypedData();
const { switchNetworkAsync } = useSwitchNetwork({ chainId: Number(config.chain.id) }); const { switchNetworkAsync } = useSwitchNetwork({ chainId: Number(config.chain.id) });
const logEvent = useCallback((event: mixpanel.EventPayload<mixpanel.EventTypes.WALLET_ACTION>['Action']) => {
mixpanel.logEvent(
mixpanel.EventTypes.WALLET_ACTION,
{ Action: event, Address: address, AppId: appId },
);
}, [ address, appId ]);
const switchNetwork = useCallback(async() => { const switchNetwork = useCallback(async() => {
if (Number(config.chain.id) !== chain?.id) { if (Number(config.chain.id) !== chain?.id) {
await switchNetworkAsync?.(); await switchNetworkAsync?.();
...@@ -37,14 +45,16 @@ export default function useMarketplaceWallet() { ...@@ -37,14 +45,16 @@ export default function useMarketplaceWallet() {
const sendTransaction = useCallback(async(transaction: SendTransactionArgs) => { const sendTransaction = useCallback(async(transaction: SendTransactionArgs) => {
await switchNetwork(); await switchNetwork();
const tx = await sendTransactionAsync(transaction); const tx = await sendTransactionAsync(transaction);
logEvent('Send Transaction');
return tx.hash; return tx.hash;
}, [ sendTransactionAsync, switchNetwork ]); }, [ sendTransactionAsync, switchNetwork, logEvent ]);
const signMessage = useCallback(async(message: string) => { const signMessage = useCallback(async(message: string) => {
await switchNetwork(); await switchNetwork();
const signature = await signMessageAsync({ message }); const signature = await signMessageAsync({ message });
logEvent('Sign Message');
return signature; return signature;
}, [ signMessageAsync, switchNetwork ]); }, [ signMessageAsync, switchNetwork, logEvent ]);
const signTypedData = useCallback(async(typedData: SignTypedDataArgs) => { const signTypedData = useCallback(async(typedData: SignTypedDataArgs) => {
await switchNetwork(); await switchNetwork();
...@@ -52,8 +62,9 @@ export default function useMarketplaceWallet() { ...@@ -52,8 +62,9 @@ export default function useMarketplaceWallet() {
typedData.domain.chainId = Number(typedData.domain.chainId); typedData.domain.chainId = Number(typedData.domain.chainId);
} }
const signature = await signTypedDataAsync(typedData); const signature = await signTypedDataAsync(typedData);
logEvent('Sign Typed Data');
return signature; return signature;
}, [ signTypedDataAsync, switchNetwork ]); }, [ signTypedDataAsync, switchNetwork, logEvent ]);
return { return {
address, address,
......
...@@ -97,13 +97,12 @@ const MarketplaceAppContent = ({ address, data, isPending }: Props) => { ...@@ -97,13 +97,12 @@ const MarketplaceAppContent = ({ address, data, isPending }: Props) => {
}; };
const MarketplaceApp = () => { const MarketplaceApp = () => {
const { address, sendTransaction, signMessage, signTypedData } = useMarketplaceWallet();
useAutoConnectWallet();
const fetch = useFetch(); const fetch = useFetch();
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const router = useRouter(); const router = useRouter();
const id = getQueryParamString(router.query.id); const id = getQueryParamString(router.query.id);
const { address, sendTransaction, signMessage, signTypedData } = useMarketplaceWallet(id);
useAutoConnectWallet();
const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({ const query = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>({
queryKey: [ 'marketplace-dapps', id ], queryKey: [ 'marketplace-dapps', id ],
......
import { Box, Heading, Text } from '@chakra-ui/react'; import { Box, Heading, Text, Icon } from '@chakra-ui/react';
import React from '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 { interface Props {
text: string | JSX.Element; text: string | JSX.Element;
...@@ -14,11 +17,7 @@ const EmptySearchResult = ({ text }: Props) => { ...@@ -14,11 +17,7 @@ const EmptySearchResult = ({ text }: Props) => {
flexDirection="column" flexDirection="column"
alignItems="center" alignItems="center"
> >
<IconSvg <Icon as={ emptySearchResultIcon } boxSize={ 60 }/>
name="empty_search_result"
boxSize={ 60 }
display="block"
/>
<Heading <Heading
as="h3" as="h3"
......
...@@ -143,7 +143,7 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa ...@@ -143,7 +143,7 @@ const PageTitle = ({ title, contentAfter, withTextAd, backLink, className, isLoa
{ withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto" w={{ base: '100%', lg: 'auto' }}/> } { withTextAd && <TextAd order={{ base: -1, lg: 100 }} mb={{ base: 6, lg: 0 }} ml="auto" w={{ base: '100%', lg: 'auto' }}/> }
</Flex> </Flex>
{ secondRow && ( { secondRow && (
<Flex alignItems="center" minH={ 10 } overflow="hidden"> <Flex alignItems="center" minH={ 10 } overflow="hidden" _empty={{ display: 'none' }}>
{ secondRow } { secondRow }
</Flex> </Flex>
) } ) }
......
...@@ -24,6 +24,8 @@ export interface Params<Resource extends PaginatedResources> { ...@@ -24,6 +24,8 @@ export interface Params<Resource extends PaginatedResources> {
type NextPageParams = Record<string, unknown>; type NextPageParams = Record<string, unknown>;
const INITIAL_PAGE_PARAMS = { '1': {} };
function getPaginationParamsFromQuery(queryString: string | Array<string> | undefined) { function getPaginationParamsFromQuery(queryString: string | Array<string> | undefined) {
if (queryString) { if (queryString) {
try { try {
...@@ -136,7 +138,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -136,7 +138,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
router.push({ pathname: router.pathname, query: nextRouterQuery }, undefined, { shallow: true }).then(() => { router.push({ pathname: router.pathname, query: nextRouterQuery }, undefined, { shallow: true }).then(() => {
queryClient.removeQueries({ queryKey: [ resourceName ] }); queryClient.removeQueries({ queryKey: [ resourceName ] });
setPage(1); setPage(1);
setPageParams({}); setPageParams(INITIAL_PAGE_PARAMS);
window.setTimeout(() => { window.setTimeout(() => {
// FIXME after router is updated we still have inactive queries for previously visited page (e.g third), where we came from // 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 :) // so have to remove it but with some delay :)
...@@ -166,7 +168,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -166,7 +168,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
).then(() => { ).then(() => {
setHasPages(false); setHasPages(false);
setPage(1); setPage(1);
setPageParams({}); setPageParams(INITIAL_PAGE_PARAMS);
}); });
}, [ router, resource.filterFields, scrollToTop ]); }, [ router, resource.filterFields, scrollToTop ]);
...@@ -186,7 +188,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -186,7 +188,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
).then(() => { ).then(() => {
setHasPages(false); setHasPages(false);
setPage(1); setPage(1);
setPageParams({}); setPageParams(INITIAL_PAGE_PARAMS);
}); });
}, [ router, scrollToTop ]); }, [ router, scrollToTop ]);
......
import { Button, Box } from '@chakra-ui/react'; import { Button, Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import getPageType from 'lib/mixpanel/getPageType';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
const feature = config.features.swapButton; const feature = config.features.swapButton;
const 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) { if (!feature.isEnabled) {
return null; return null;
} }
...@@ -27,6 +37,7 @@ const SwapButton = () => { ...@@ -27,6 +37,7 @@ const SwapButton = () => {
borderRadius="sm" borderRadius="sm"
height={ 5 } height={ 5 }
px={ 1.5 } px={ 1.5 }
onClick={ handleClick }
> >
<IconSvg name="swap" boxSize={ 3 } mr={{ base: 0, sm: 1 }}/> <IconSvg name="swap" boxSize={ 3 } mr={{ base: 0, sm: 1 }}/>
<Box display={{ base: 'none', sm: 'inline' }}> <Box display={{ base: 'none', sm: 'inline' }}>
......
...@@ -83,6 +83,10 @@ const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => { ...@@ -83,6 +83,10 @@ const TokenInventory = ({ inventoryQuery, tokenQuery, ownerFilter }: Props) => {
isError={ inventoryQuery.isError } isError={ inventoryQuery.isError }
items={ items } items={ items }
emptyText="There are no tokens." emptyText="There are no tokens."
filterProps={{
hasActiveFilters: Boolean(ownerFilter),
emptyFilteredText: 'No tokens found for the selected owner.',
}}
content={ content } content={ content }
actionBar={ actionBar } actionBar={ actionBar }
/> />
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment