diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 532435dc83..e0cb8684b8 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -40,7 +40,9 @@ jobs: const tag = process.env.TAG; const REGEXP = /^v[0-9]+.[0-9]+.[0-9]+-[a-z]+((\.|-)\d+)?$/i; const match = tag.match(REGEXP); - return match && !match[1] ? 'true' : 'false'; + const isInitial = match && !match[1] ? true : false; + core.info('is_initial flag value: ', isInitial); + return isInitial; label_issues: name: Add pre-release label to issues diff --git a/configs/app/features/index.ts b/configs/app/features/index.ts index ad45f9a4a1..0879fb17d5 100644 --- a/configs/app/features/index.ts +++ b/configs/app/features/index.ts @@ -17,6 +17,7 @@ export { default as sentry } from './sentry'; export { default as sol2uml } from './sol2uml'; export { default as stats } from './stats'; export { default as suave } from './suave'; +export { default as txInterpretation } from './txInterpretation'; export { default as web3Wallet } from './web3Wallet'; export { default as verifiedTokens } from './verifiedTokens'; export { default as zkEvmRollup } from './zkEvmRollup'; diff --git a/configs/app/features/txInterpretation.ts b/configs/app/features/txInterpretation.ts new file mode 100644 index 0000000000..c22067ee27 --- /dev/null +++ b/configs/app/features/txInterpretation.ts @@ -0,0 +1,34 @@ +import type { Feature } from './types'; +import type { Provider } from 'types/client/txInterpretation'; +import { PROVIDERS } from 'types/client/txInterpretation'; + +import { getEnvValue } from '../utils'; + +const title = 'Transaction interpretation'; + +const provider: Provider = (() => { + const value = getEnvValue('NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER'); + + if (value && PROVIDERS.includes(value as Provider)) { + return value as Provider; + } + + return 'none'; +})(); + +const config: Feature<{ provider: Provider }> = (() => { + if (provider !== 'none') { + return Object.freeze({ + title, + provider, + isEnabled: true, + }); + } + + return Object.freeze({ + title, + isEnabled: false, + }); +})(); + +export default config; diff --git a/configs/envs/.env.eth b/configs/envs/.env.eth index 71dc3fdd4d..75ef364321 100644 --- a/configs/envs/.env.eth +++ b/configs/envs/.env.eth @@ -42,6 +42,7 @@ NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s.blockscout.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com +NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout #meta NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true diff --git a/deploy/tools/envs-validator/schema.ts b/deploy/tools/envs-validator/schema.ts index 85966c74ed..6dee3620b3 100644 --- a/deploy/tools/envs-validator/schema.ts +++ b/deploy/tools/envs-validator/schema.ts @@ -15,6 +15,7 @@ import type { MarketplaceAppOverview } from '../../../types/client/marketplace'; import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items'; import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items'; import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token'; +import { PROVIDERS as TX_INTERPRETATION_PROVIDERS } from '../../../types/client/txInterpretation'; import type { WalletType } from '../../../types/client/wallets'; import { SUPPORTED_WALLETS } from '../../../types/client/wallets'; import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks'; @@ -438,6 +439,7 @@ const schema = yup return isNoneSchema.isValidSync(data) || isArrayOfWalletsSchema.isValidSync(data); }), NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: yup.boolean(), + NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: yup.string().oneOf(TX_INTERPRETATION_PROVIDERS), NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string().oneOf(SUPPORTED_AD_TEXT_PROVIDERS), NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(), NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), diff --git a/docs/ENVS.md b/docs/ENVS.md index 58b81b2c11..bee048088a 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -44,6 +44,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will - [Solidity to UML diagrams](ENVS.md#solidity-to-uml-diagrams) - [Blockchain statistics](ENVS.md#blockchain-statistics) - [Web3 wallet integration](ENVS.md#web3-wallet-integration-add-token-or-network-to-the-wallet) (add token or network to the wallet) + - [Transaction interpretation](ENVS.md#transaction-interpretation) - [Verified tokens info](ENVS.md#verified-tokens-info) - [Bridged tokens](ENVS.md#bridged-tokens) - [Safe{Core} address tags](ENVS.md#safecore-address-tags) @@ -473,6 +474,14 @@ This feature is **enabled by default** with the `['metamask']` value. To switch   +### Transaction interpretation + +| Variable | Type| Description | Compulsoriness | Default value | Example value | +| --- | --- | --- | --- | --- | --- | +| NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER | `blockscout` \| `none` | Transaction interpretation provider that displays human readable transaction description | - | `none` | `blockscout` | + +  + ### Verified tokens info | Variable | Type| Description | Compulsoriness | Default value | Example value | diff --git a/icons/lightning.svg b/icons/lightning.svg new file mode 100644 index 0000000000..91b1ae92ca --- /dev/null +++ b/icons/lightning.svg @@ -0,0 +1,3 @@ + + + diff --git a/jest/setup.ts b/jest/setup.ts index c371af3c56..6ccc67ad4c 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -1,4 +1,5 @@ import dotenv from 'dotenv'; +import { TextEncoder, TextDecoder } from 'util'; import fetchMock from 'jest-fetch-mock'; @@ -6,6 +7,8 @@ fetchMock.enableMocks(); const envs = dotenv.config({ path: './configs/envs/.env.jest' }); +Object.assign(global, { TextDecoder, TextEncoder }); + Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn().mockImplementation(query => ({ diff --git a/lib/api/resources.ts b/lib/api/resources.ts index 788a187a22..60d224dad6 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -65,6 +65,7 @@ import type { TransactionsResponseWatchlist, TransactionsSorting, } from 'types/api/transaction'; +import type { TxInterpretationResponse } from 'types/api/txInterpretation'; import type { TTxsFilters } from 'types/api/txsFilters'; import type { TxStateChanges } from 'types/api/txStateChanges'; import type { VerifiedContractsSorting } from 'types/api/verifiedContracts'; @@ -246,6 +247,10 @@ export const RESOURCES = { pathParams: [ 'hash' as const ], filterFields: [], }, + tx_interpretation: { + path: '/api/v2/transactions/:hash/summary', + pathParams: [ 'hash' as const ], + }, withdrawals: { path: '/api/v2/withdrawals', filterFields: [], @@ -651,6 +656,7 @@ Q extends 'tx_logs' ? LogsResponseTx : Q extends 'tx_token_transfers' ? TokenTransferResponse : Q extends 'tx_raw_trace' ? RawTracesResponse : Q extends 'tx_state_changes' ? TxStateChanges : +Q extends 'tx_interpretation' ? TxInterpretationResponse : Q extends 'addresses' ? AddressesResponse : Q extends 'address' ? Address : Q extends 'address_counters' ? AddressCounters : diff --git a/lib/hooks/useContractTabs.tsx b/lib/hooks/useContractTabs.tsx index 5736495c71..ca02f16ddd 100644 --- a/lib/hooks/useContractTabs.tsx +++ b/lib/hooks/useContractTabs.tsx @@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) { // { id: 'contact_decompiled_code', title: 'Decompiled code', component:
Decompiled code
} : // undefined, data?.has_methods_read ? - { id: 'read_contract', title: 'Read contract', component: } : + { id: 'read_contract', title: 'Read contract', component: } : undefined, data?.has_methods_read_proxy ? - { id: 'read_proxy', title: 'Read proxy', component: } : + { id: 'read_proxy', title: 'Read proxy', component: } : undefined, data?.has_custom_methods_read ? - { id: 'read_custom_methods', title: 'Read custom', component: } : + { id: 'read_custom_methods', title: 'Read custom', component: } : undefined, data?.has_methods_write ? - { id: 'write_contract', title: 'Write contract', component: } : + { id: 'write_contract', title: 'Write contract', component: } : undefined, data?.has_methods_write_proxy ? - { id: 'write_proxy', title: 'Write proxy', component: } : + { id: 'write_proxy', title: 'Write proxy', component: } : undefined, data?.has_custom_methods_write ? - { id: 'write_custom_methods', title: 'Write custom', component: } : + { id: 'write_custom_methods', title: 'Write custom', component: } : undefined, ].filter(Boolean); }, [ data ]); diff --git a/lib/hooks/useNavItems.tsx b/lib/hooks/useNavItems.tsx index cd2a1adbad..f330a53c07 100644 --- a/lib/hooks/useNavItems.tsx +++ b/lib/hooks/useNavItems.tsx @@ -170,7 +170,6 @@ export default function useNavItems(): ReturnType { { text: 'Verify contract', nextRoute: { pathname: '/contract-verification' as const }, - icon: 'verify-contract', isActive: pathname.startsWith('/contract-verification'), }, ...config.UI.sidebar.otherLinks, diff --git a/lib/mixpanel/utils.ts b/lib/mixpanel/utils.ts index fae1b8e327..b886f32249 100644 --- a/lib/mixpanel/utils.ts +++ b/lib/mixpanel/utils.ts @@ -13,6 +13,7 @@ export enum EventTypes { CONTRACT_VERIFICATION = 'Contract verification', QR_CODE = 'QR code', PAGE_WIDGET = 'Page widget', + TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction' } /* eslint-disable @typescript-eslint/indent */ @@ -78,5 +79,8 @@ Type extends EventTypes.QR_CODE ? { Type extends EventTypes.PAGE_WIDGET ? { 'Type': 'Tokens dropdown' | 'Tokens show all (icon)' | 'Add to watchlist' | 'Address actions (more button)'; } : +Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { + 'Type': 'Address click' | 'Token click'; +} : undefined; /* eslint-enable @typescript-eslint/indent */ diff --git a/lib/sentry/config.ts b/lib/sentry/config.ts index f02b2b2d1c..d52c20e382 100644 --- a/lib/sentry/config.ts +++ b/lib/sentry/config.ts @@ -56,6 +56,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => { 'Script error.', // Relay and WalletConnect errors + 'The quota has been exceeded', 'Attempt to connect to relay via', 'WebSocket connection failed for URL: wss://relay.walletconnect.com', ], @@ -67,9 +68,11 @@ export const config: Sentry.BrowserOptions | undefined = (() => { // Woopra flakiness /eatdifferent\.com\.woopra-ns\.com/i, /static\.woopra\.com\/js\/woopra\.js/i, - // Chrome extensions + // Chrome and other extensions /extensions\//i, /^chrome:\/\//i, + /^chrome-extension:\/\//i, + /^moz-extension:\/\//i, // Other plugins /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb /webappstoolbarba\.texthelp\.com\//i, diff --git a/mocks/contract/methods.ts b/mocks/contract/methods.ts index 5f6f092d86..745c19770a 100644 --- a/mocks/contract/methods.ts +++ b/mocks/contract/methods.ts @@ -9,12 +9,12 @@ export const read: Array = [ { constant: true, inputs: [ - { internalType: 'address', name: '', type: 'address' }, + { internalType: 'address', name: 'wallet', type: 'address' }, ], method_id: '70a08231', name: 'FLASHLOAN_PREMIUM_TOTAL', outputs: [ - { internalType: 'uint256', name: '', type: 'uint256' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, ], payable: false, stateMutability: 'view', @@ -97,7 +97,7 @@ export const read: Array = [ export const readResultSuccess: SmartContractQueryMethodReadSuccess = { is_error: false, result: { - names: [ 'uint256' ], + names: [ 'amount' ], output: [ { type: 'uint256', value: '42' }, ], diff --git a/mocks/txs/txInterpretation.ts b/mocks/txs/txInterpretation.ts new file mode 100644 index 0000000000..b351363a52 --- /dev/null +++ b/mocks/txs/txInterpretation.ts @@ -0,0 +1,45 @@ +import type { TxInterpretationResponse } from 'types/api/txInterpretation'; + +export const txInterpretation: TxInterpretationResponse = { + data: { + summaries: [ { + summary_template: `{action_type} {amount} {token} to {to_address} on {timestamp}`, + summary_template_variables: { + action_type: { type: 'string', value: 'Transfer' }, + amount: { type: 'currency', value: '100' }, + token: { + type: 'token', + value: { + name: 'Duck', + type: 'ERC-20', + symbol: 'DUCK', + address: '0x486a3c5f34cDc4EF133f248f1C81168D78da52e8', + holders: '1152', + decimals: '18', + icon_url: null, + total_supply: '210000000000000000000000000', + exchange_rate: null, + circulating_market_cap: null, + }, + }, + to_address: { + type: 'address', + value: { + hash: '0x48c04ed5691981C42154C6167398f95e8f38a7fF', + implementation_name: null, + is_contract: false, + is_verified: false, + name: null, + private_tags: [], + public_tags: [], + watchlist_names: [], + }, + }, + timestamp: { + type: 'timestamp', + value: '1687005431', + }, + }, + } ], + }, +}; diff --git a/nextjs/csp/policies/ad.ts b/nextjs/csp/policies/ad.ts index 4b7905fdf2..2808dfb07c 100644 --- a/nextjs/csp/policies/ad.ts +++ b/nextjs/csp/policies/ad.ts @@ -9,11 +9,11 @@ export function ad(): CspDev.DirectiveDescriptor { 'connect-src': [ 'coinzilla.com', '*.coinzilla.com', - 'request-global.czilladx.com', + 'https://request-global.czilladx.com', '*.slise.xyz', ], 'frame-src': [ - 'request-global.czilladx.com', + 'https://request-global.czilladx.com', ], 'script-src': [ 'coinzillatag.com', @@ -27,7 +27,7 @@ export function ad(): CspDev.DirectiveDescriptor { 'cdn.coinzilla.io', ], 'font-src': [ - 'request-global.czilladx.com', + 'https://request-global.czilladx.com', ], }; } diff --git a/nextjs/csp/policies/app.ts b/nextjs/csp/policies/app.ts index ef338a22dc..37822e4766 100644 --- a/nextjs/csp/policies/app.ts +++ b/nextjs/csp/policies/app.ts @@ -9,7 +9,6 @@ import { KEY_WORDS } from '../utils'; const MAIN_DOMAINS = [ `*.${ config.app.host }`, config.app.host, - getFeaturePayload(config.features.sol2uml)?.api.endpoint, ].filter(Boolean); const getCspReportUrl = () => { @@ -113,6 +112,7 @@ export function app(): CspDev.DirectiveDescriptor { 'font-src': [ KEY_WORDS.DATA, + ...MAIN_DOMAINS, ], 'object-src': [ diff --git a/package.json b/package.json index 395abadc60..adfe8e8319 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "react-identicons": "^1.2.5", "react-intersection-observer": "^9.5.2", "react-jazzicon": "^1.0.4", + "react-number-format": "^5.3.1", "react-scroll": "^1.8.7", "swagger-ui-react": "^5.9.0", "use-font-face-observer": "^1.2.1", diff --git a/playwright/utils/configs.ts b/playwright/utils/configs.ts index 5871ee3c45..4d9bc8802f 100644 --- a/playwright/utils/configs.ts +++ b/playwright/utils/configs.ts @@ -29,6 +29,9 @@ export const featureEnvs = { value: '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]', }, ], + txInterpretation: [ + { name: 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', value: 'blockscout' }, + ], zkRollup: [ { name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' }, { name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' }, diff --git a/public/icons/name.d.ts b/public/icons/name.d.ts index 8a6cb918a7..31e3f50b9d 100644 --- a/public/icons/name.d.ts +++ b/public/icons/name.d.ts @@ -52,6 +52,7 @@ | "graphQL" | "info" | "key" + | "lightning" | "link" | "lock" | "minus" diff --git a/stubs/txInterpretation.ts b/stubs/txInterpretation.ts new file mode 100644 index 0000000000..e54c2cddae --- /dev/null +++ b/stubs/txInterpretation.ts @@ -0,0 +1,34 @@ +import type { TxInterpretationResponse } from 'types/api/txInterpretation'; + +import { TOKEN_INFO_ERC_20 } from './token'; + +export const TX_INTERPRETATION: TxInterpretationResponse = { + data: { + summaries: [ + { + summary_template: '{action_type} {source_amount} Ether into {destination_amount} {destination_token}', + summary_template_variables: { + action_type: { type: 'string', value: 'Wrap' }, + source_amount: { type: 'currency', value: '0.7' }, + destination_amount: { type: 'currency', value: '0.7' }, + destination_token: { + type: 'token', + value: TOKEN_INFO_ERC_20, + }, + }, + }, + { + summary_template: '{action_type} {source_amount} Ether into {destination_amount} {destination_token}', + summary_template_variables: { + action_type: { type: 'string', value: 'Wrap' }, + source_amount: { type: 'currency', value: '0.7' }, + destination_amount: { type: 'currency', value: '0.7' }, + destination_token: { + type: 'token', + value: TOKEN_INFO_ERC_20, + }, + }, + }, + ], + }, +}; diff --git a/theme/components/Alert/Alert.ts b/theme/components/Alert/Alert.ts index 2360b71a52..c8dfa125b1 100644 --- a/theme/components/Alert/Alert.ts +++ b/theme/components/Alert/Alert.ts @@ -13,7 +13,7 @@ function getBg(props: StyleFunctionProps) { const { theme, colorScheme: c } = props; const darkBg = transparentize(`${ c }.200`, 0.16)(theme); return { - light: `colors.${ c }.100`, + light: `colors.${ c }.${ c === 'red' ? '50' : '100' }`, dark: darkBg, }; } diff --git a/types/api/contract.ts b/types/api/contract.ts index d8451ce974..dd66d0380f 100644 --- a/types/api/contract.ts +++ b/types/api/contract.ts @@ -1,6 +1,6 @@ -import type { Abi } from 'abitype'; +import type { Abi, AbiType } from 'abitype'; -export type SmartContractMethodArgType = 'address' | 'uint256' | 'bool' | 'string' | 'bytes' | 'bytes32' | 'bytes32[]'; +export type SmartContractMethodArgType = AbiType; export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable'; export interface SmartContract { @@ -88,6 +88,8 @@ export interface SmartContractMethodInput { internalType?: SmartContractMethodArgType; name: string; type: SmartContractMethodArgType; + components?: Array; + fieldType?: 'native_coin'; } export interface SmartContractMethodOutput extends SmartContractMethodInput { @@ -97,10 +99,10 @@ export interface SmartContractMethodOutput extends SmartContractMethodInput { export interface SmartContractQueryMethodReadSuccess { is_error: false; result: { - names: Array; + names: Array ]>; output: Array<{ type: string; - value: string; + value: string | Array; }>; }; } diff --git a/types/api/stats.ts b/types/api/stats.ts index d67a53211d..7ce179368f 100644 --- a/types/api/stats.ts +++ b/types/api/stats.ts @@ -16,9 +16,9 @@ export type HomeStats = { } export type GasPrices = { - average: number; - fast: number; - slow: number; + average: number | null; + fast: number | null; + slow: number | null; } export type Counters = { diff --git a/types/api/txInterpretation.ts b/types/api/txInterpretation.ts new file mode 100644 index 0000000000..be9393203f --- /dev/null +++ b/types/api/txInterpretation.ts @@ -0,0 +1,47 @@ +import type { AddressParam } from 'types/api/addressParams'; +import type { TokenInfo } from 'types/api/token'; + +export interface TxInterpretationResponse { + data: { + summaries: Array; + }; +} + +export type TxInterpretationSummary = { + summary_template: string; + summary_template_variables: Record; +} + +export type TxInterpretationVariable = + TxInterpretationVariableString | + TxInterpretationVariableCurrency | + TxInterpretationVariableTimestamp | + TxInterpretationVariableToken | + TxInterpretationVariableAddress; + +export type TxInterpretationVariableType = 'string' | 'currency' | 'timestamp' | 'token' | 'address'; + +export type TxInterpretationVariableString = { + type: 'string'; + value: string; +} + +export type TxInterpretationVariableCurrency = { + type: 'currency'; + value: string; +} + +export type TxInterpretationVariableTimestamp = { + type: 'timestamp'; + value: string; +} + +export type TxInterpretationVariableToken = { + type: 'token'; + value: TokenInfo; +} + +export type TxInterpretationVariableAddress = { + type: 'address'; + value: AddressParam; +} diff --git a/types/client/txInterpretation.ts b/types/client/txInterpretation.ts new file mode 100644 index 0000000000..e264b267bc --- /dev/null +++ b/types/client/txInterpretation.ts @@ -0,0 +1,8 @@ +import type { ArrayElement } from 'types/utils'; + +export const PROVIDERS = [ + 'blockscout', + 'none', +] as const; + +export type Provider = ArrayElement; diff --git a/ui/address/SolidityscanReport.tsx b/ui/address/SolidityscanReport.tsx index 6b6d6200cb..8fe817d4fd 100644 --- a/ui/address/SolidityscanReport.tsx +++ b/ui/address/SolidityscanReport.tsx @@ -84,6 +84,10 @@ const SolidityscanReport = ({ className, hash }: Props) => { const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500'); const popoverBgColor = useColorModeValue('white', 'gray.900'); + const greatScoreColor = useColorModeValue('green.600', 'green.400'); + const averageScoreColor = useColorModeValue('purple.600', 'purple.400'); + const lowScoreColor = useColorModeValue('red.600', 'red.400'); + if (isError || !score) { return null; } @@ -91,13 +95,13 @@ const SolidityscanReport = ({ className, hash }: Props) => { let scoreColor; let scoreLevel; if (score >= 80) { - scoreColor = 'green.600'; + scoreColor = greatScoreColor; scoreLevel = 'GREAT'; } else if (score >= 30) { - scoreColor = 'orange.600'; + scoreColor = averageScoreColor; scoreLevel = 'AVERAGE'; } else { - scoreColor = 'red.600'; + scoreColor = lowScoreColor; scoreLevel = 'LOW'; } @@ -112,7 +116,6 @@ const SolidityscanReport = ({ className, hash }: Props) => { - + + { inputs.map((input, index) => { + const fieldName = getFormFieldName({ name: input.name, index }); + + if (input.type === 'tuple' && input.components) { + return ( + + { index !== 0 && <>
} + + { input.name } ({ input.type }) + + { input.components.map((component, componentIndex) => { + const fieldName = getFormFieldName( + { name: component.name, index: componentIndex }, + { name: input.name, index }, + ); + + return ( + + ); + }) } + { index !== inputs.length - 1 && <>
} + + ); + } + + return ( + 1 } + onChange={ handleFormChange } + /> + ); + }) } + + + + { 'outputs' in data && !isWrite && data.outputs.length > 0 && ( - + - { data.outputs.map(({ type }) => type).join(', ') } +

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

) } { result && } diff --git a/ui/address/contract/ContractMethodCallableRow.tsx b/ui/address/contract/ContractMethodCallableRow.tsx new file mode 100644 index 0000000000..2cc01d7861 --- /dev/null +++ b/ui/address/contract/ContractMethodCallableRow.tsx @@ -0,0 +1,84 @@ +import { Box, Flex, useColorModeValue } from '@chakra-ui/react'; +import React from 'react'; +import { useFormContext } from 'react-hook-form'; + +import type { MethodFormFields } from './types'; +import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract'; + +import ContractMethodField from './ContractMethodField'; +import ContractMethodFieldArray from './ContractMethodFieldArray'; +import { ARRAY_REGEXP } from './utils'; + +interface Props { + fieldName: string; + fieldType?: SmartContractMethodInput['fieldType']; + argName: string; + argType: SmartContractMethodArgType; + onChange: () => void; + isDisabled: boolean; + isGrouped?: boolean; + isOptional?: boolean; +} + +const ContractMethodCallableRow = ({ argName, fieldName, fieldType, argType, onChange, isDisabled, isGrouped, isOptional }: Props) => { + const { control, getValues, setValue } = useFormContext(); + const arrayTypeMatch = argType.match(ARRAY_REGEXP); + const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100'); + + const content = arrayTypeMatch ? ( + + ) : ( + + ); + + const isNativeCoinField = fieldType === 'native_coin'; + + return ( + + + { argName }{ isOptional ? '' : '*' } ({ argType }) + + { content } + + ); +}; + +export default React.memo(ContractMethodCallableRow); diff --git a/ui/address/contract/ContractMethodField.tsx b/ui/address/contract/ContractMethodField.tsx index d85deeaebb..47837114f6 100644 --- a/ui/address/contract/ContractMethodField.tsx +++ b/ui/address/contract/ContractMethodField.tsx @@ -1,12 +1,16 @@ import { + Box, FormControl, Input, InputGroup, InputRightElement, + useColorModeValue, } from '@chakra-ui/react'; import React from 'react'; -import type { Control, ControllerRenderProps, UseFormGetValues, UseFormSetValue } from 'react-hook-form'; +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'; @@ -14,21 +18,25 @@ import type { SmartContractMethodArgType } from 'types/api/contract'; import ClearButton from 'ui/shared/ClearButton'; import ContractMethodFieldZeroes from './ContractMethodFieldZeroes'; -import { addZeroesAllowed } from './utils'; +import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils'; interface Props { + name: string; + index?: number; + groupName?: string; + placeholder: string; + argType: SmartContractMethodArgType; control: Control; setValue: UseFormSetValue; getValues: UseFormGetValues; - placeholder: string; - name: string; - valueType: SmartContractMethodArgType; isDisabled: boolean; + isOptional?: boolean; onChange: () => void; } -const ContractMethodField = ({ control, name, valueType, placeholder, setValue, getValues, isDisabled, onChange }: Props) => { +const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => { const ref = React.useRef(null); + const bgColor = useColorModeValue('white', 'black'); const handleClear = React.useCallback(() => { setValue(name, ''); @@ -37,47 +45,147 @@ const ContractMethodField = ({ control, name, valueType, placeholder, setValue, }, [ name, onChange, setValue ]); const handleAddZeroesClick = React.useCallback((power: number) => { - const value = getValues()[name]; + 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, name, onChange, setValue ]); + }, [ 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 hasZerosControl = addZeroesAllowed(valueType); + const bytesMatch = React.useMemo(() => { + return argType.match(BYTES_REGEXP); + }, [ argType ]); + + const renderInput = React.useCallback(( + { field, formState }: { field: ControllerRenderProps; formState: UseFormStateReturn }, + ) => { + const error: FieldError | undefined = index !== undefined && groupName !== undefined ? + (formState.errors[groupName] as unknown as Array)?.[index] : + formState.errors[name]; + + // show control for all inputs which allows to insert 10^18 or greater numbers + const hasZerosControl = intMatch && Number(intMatch.power) >= 64; - const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps }) => { return ( - - - - - { field.value && } - { hasZerosControl && } - - - + + + + + + { typeof field.value === 'string' && field.value.replace('\n', '') && } + { hasZerosControl && } + + + + { error && { error.message } } + ); - }, [ name, isDisabled, placeholder, hasZerosControl, handleClear, handleAddZeroesClick ]); + }, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]); + + const validate = React.useCallback((_value: string | Array | undefined) => { + if (typeof _value === 'object' || !_value) { + return; + } + + const value = _value.replace('\n', ''); + + if (!value && !isOptional) { + return 'Field is required'; + } + + if (argType === 'address') { + if (!isAddress(value)) { + return 'Invalid address format'; + } + + // all lowercase addresses are valid + const isInLowerCase = value === value.toLowerCase(); + if (isInLowerCase) { + return true; + } + + // check if address checksum is valid + return getAddress(value) === value ? true : 'Invalid address checksum'; + } + + if (intMatch) { + const formattedValue = Number(value.replace(/\s/g, '')); + + if (Object.is(formattedValue, NaN)) { + return 'Invalid integer format'; + } + + if (formattedValue > intMatch.max || formattedValue < intMatch.min) { + const lowerBoundary = intMatch.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(intMatch.power) / 2 }`; + const upperBoundary = intMatch.isUnsigned ? `2 ^ ${ intMatch.power } - 1` : `2 ^ ${ Number(intMatch.power) / 2 } - 1`; + return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`; + } + + return true; + } + + if (argType === 'bool') { + const formattedValue = formatBooleanValue(value); + if (formattedValue === undefined) { + return 'Invalid boolean format. Allowed values: 0, 1, true, false'; + } + } + + if (bytesMatch) { + const [ , length ] = bytesMatch; + + if (!isHex(value)) { + return 'Invalid bytes format'; + } + + if (length) { + const valueLengthInBytes = value.replace('0x', '').length / 2; + return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true; + } + + return true; + } + + return true; + }, [ isOptional, argType, intMatch, bytesMatch ]); return ( - ); }; diff --git a/ui/address/contract/ContractMethodFieldArray.tsx b/ui/address/contract/ContractMethodFieldArray.tsx new file mode 100644 index 0000000000..35018b2f7f --- /dev/null +++ b/ui/address/contract/ContractMethodFieldArray.tsx @@ -0,0 +1,106 @@ +import { Flex, IconButton } from '@chakra-ui/react'; +import React from 'react'; +import type { Control, UseFormGetValues, UseFormSetValue } from 'react-hook-form'; +import { useFieldArray } from 'react-hook-form'; + +import type { MethodFormFields } from './types'; +import type { SmartContractMethodArgType } from 'types/api/contract'; + +import IconSvg from 'ui/shared/IconSvg'; + +import ContractMethodField from './ContractMethodField'; + +interface Props { + name: string; + size: number; + argType: SmartContractMethodArgType; + control: Control; + setValue: UseFormSetValue; + getValues: UseFormGetValues; + isDisabled: boolean; + onChange: () => void; +} + +const ContractMethodFieldArray = ({ control, name, setValue, getValues, isDisabled, argType, onChange, size }: Props) => { + const { fields, append, remove } = useFieldArray({ + name: name as never, + control, + }); + + React.useEffect(() => { + if (fields.length === 0) { + if (size === Infinity) { + append(''); + } else { + for (let i = 0; i < size - 1; i++) { + // a little hack to append multiple empty fields in the array + // had to adjust code in ContractMethodField as well + append('\n'); + } + } + } + + }, [ fields.length, append, size ]); + + const handleAddButtonClick = React.useCallback(() => { + append(''); + }, [ append ]); + + const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent) => { + const itemIndex = event.currentTarget.getAttribute('data-index'); + if (itemIndex) { + remove(Number(itemIndex)); + } + }, [ remove ]); + + return ( + + { fields.map((field, index, array) => { + return ( + + + { array.length > 1 && size === Infinity && ( + } + isDisabled={ isDisabled } + /> + ) } + { index === array.length - 1 && size === Infinity && ( + } + isDisabled={ isDisabled } + /> + ) } + + ); + }) } + + ); +}; + +export default React.memo(ContractMethodFieldArray); diff --git a/ui/address/contract/ContractMethodsAccordion.tsx b/ui/address/contract/ContractMethodsAccordion.tsx index f0bc297987..b1e4c4bf6b 100644 --- a/ui/address/contract/ContractMethodsAccordion.tsx +++ b/ui/address/contract/ContractMethodsAccordion.tsx @@ -11,9 +11,10 @@ interface Props { data: Array; addressHash?: string; renderItemContent: (item: T, index: number, id: number) => React.ReactNode; + tab: string; } -const ContractMethodsAccordion = ({ data, addressHash, renderItemContent }: Props) => { +const ContractMethodsAccordion = ({ data, addressHash, renderItemContent, tab }: Props) => { const [ expandedSections, setExpandedSections ] = React.useState>(data.length === 1 ? [ 0 ] : []); const [ id, setId ] = React.useState(0); @@ -79,6 +80,7 @@ const ContractMethodsAccordion = ({ data, address index={ index } addressHash={ addressHash } renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode } + tab={ tab } /> )) } diff --git a/ui/address/contract/ContractMethodsAccordionItem.tsx b/ui/address/contract/ContractMethodsAccordionItem.tsx index 5f0c462272..d69222cdad 100644 --- a/ui/address/contract/ContractMethodsAccordionItem.tsx +++ b/ui/address/contract/ContractMethodsAccordionItem.tsx @@ -16,9 +16,10 @@ interface Props { id: number; addressHash?: string; renderContent: (item: T, index: number, id: number) => React.ReactNode; + tab: string; } -const ContractMethodsAccordionItem = ({ data, index, id, addressHash, renderContent }: Props) => { +const ContractMethodsAccordionItem = ({ data, index, id, addressHash, renderContent, tab }: Props) => { const url = React.useMemo(() => { if (!('method_id' in data)) { return ''; @@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = ({ data, ind pathname: '/address/[hash]', query: { hash: addressHash ?? '', - tab: 'read_contract', + tab, }, hash: data.method_id, }); - }, [ addressHash, data ]); + }, [ addressHash, data, tab ]); const { hasCopied, onCopy } = useClipboard(url, 1000); const { isOpen, onOpen, onClose } = useDisclosure(); @@ -85,7 +86,7 @@ const ContractMethodsAccordionItem = ({ data, ind - + { renderContent(data, index, id) } diff --git a/ui/address/contract/ContractRead.pw.tsx b/ui/address/contract/ContractRead.pw.tsx index 71fe9fcc3c..d540c1c07e 100644 --- a/ui/address/contract/ContractRead.pw.tsx +++ b/ui/address/contract/ContractRead.pw.tsx @@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { const component = await mount( - + , { hooksConfig }, ); @@ -37,8 +37,8 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { await expect(component).toHaveScreenshot(); - await component.getByPlaceholder(/address/i).type('address-hash'); - await component.getByText(/query/i).click(); + await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466'); + await component.getByText(/read/i).click(); await component.getByText(/wei/i).click(); diff --git a/ui/address/contract/ContractRead.tsx b/ui/address/contract/ContractRead.tsx index 488889c7ba..88330196ea 100644 --- a/ui/address/contract/ContractRead.tsx +++ b/ui/address/contract/ContractRead.tsx @@ -1,10 +1,12 @@ import { Alert, Flex } from '@chakra-ui/react'; +import { useRouter } from 'next/router'; import React from 'react'; import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract'; import useApiFetch from 'lib/api/useApiFetch'; import useApiQuery from 'lib/api/useApiQuery'; +import getQueryParamString from 'lib/router/getQueryParamString'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContentLoader from 'ui/shared/ContentLoader'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; @@ -17,15 +19,15 @@ import ContractMethodConstant from './ContractMethodConstant'; import ContractReadResult from './ContractReadResult'; import useWatchAccount from './useWatchAccount'; -interface Props { - addressHash?: string; - isProxy?: boolean; - isCustomAbi?: boolean; -} - -const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => { +const ContractRead = () => { const apiFetch = useApiFetch(); const account = useWatchAccount(); + const router = useRouter(); + + const tab = getQueryParamString(router.query.tab); + const addressHash = getQueryParamString(router.query.hash); + const isProxy = tab === 'read_proxy'; + const isCustomAbi = tab === 'read_custom_methods'; const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', { pathParams: { hash: addressHash }, @@ -96,7 +98,7 @@ const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => { { isCustomAbi && } { account && } { isProxy && } - + ); }; diff --git a/ui/address/contract/ContractReadResult.pw.tsx b/ui/address/contract/ContractReadResult.pw.tsx index 8cd3341a70..35b4991a0c 100644 --- a/ui/address/contract/ContractReadResult.pw.tsx +++ b/ui/address/contract/ContractReadResult.pw.tsx @@ -99,3 +99,34 @@ test('success', async({ mount }) => { await expect(component).toHaveScreenshot(); }); + +test('complex success', async({ mount }) => { + const result: ContractMethodReadResult = { + is_error: false, + result: { + names: [ + [ + 'data', + [ 'totalSupply', 'owner', 'symbol' ], + ], + 'supports721', + 'page', + ], + output: [ + { + type: 'tuple[uint256,address,string]', + value: [ 1000, '0xe150519ae293922cfe6217feba3add4726f5e851', 'AOC_INCUBATORS' ], + }, + { type: 'bool', value: 'true' }, + { type: 'uint256[]', value: [ 1, 2, 3, 4, 5 ] }, + ], + }, + }; + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/address/contract/ContractReadResult.tsx b/ui/address/contract/ContractReadResult.tsx index 9ed7f5f481..28f701eb9b 100644 --- a/ui/address/contract/ContractReadResult.tsx +++ b/ui/address/contract/ContractReadResult.tsx @@ -2,17 +2,55 @@ import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react'; import React from 'react'; import type { ContractMethodReadResult } from './types'; -import type { SmartContractReadMethod } from 'types/api/contract'; +import type { SmartContractQueryMethodReadSuccess, SmartContractReadMethod } from 'types/api/contract'; import hexToUtf8 from 'lib/hexToUtf8'; +const TUPLE_TYPE_REGEX = /\[(.+)\]/; + const ContractReadResultError = ({ children }: {children: React.ReactNode}) => { return ( { children } ); +}; + +interface ItemProps { + output: SmartContractQueryMethodReadSuccess['result']['output'][0]; + name: SmartContractQueryMethodReadSuccess['result']['names'][0]; +} + +const ContractReadResultItem = ({ output, name }: ItemProps) => { + if (Array.isArray(name)) { + const [ structName, argNames ] = name; + const argTypes = output.type.match(TUPLE_TYPE_REGEX)?.[1].split(','); + return ( + <> +

+ { structName } + ({ output.type }) : +

+ { argNames.map((argName, argIndex) => { + return ( +

+ { argName } + { argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) } +

+ ); + }) } + + ); + } + + return ( +

+ + { name && { name } } + ({ output.type }) : { String(output.value) } +

+ ); }; interface Props { @@ -53,14 +91,12 @@ const ContractReadResult = ({ item, result, onSettle }: Props) => { } return ( - +

- [ { 'name' in item ? item.name : '' } method response ] + [ { 'name' in item ? item.name : '' } method response ]

[

- { result.result.output.map(({ type, value }, index) => ( - { type }: { String(value) } - )) } + { result.result.output.map((output, index) => ) }

]

); diff --git a/ui/address/contract/ContractWrite.pw.tsx b/ui/address/contract/ContractWrite.pw.tsx index d0ccc75e73..bf3007d2e0 100644 --- a/ui/address/contract/ContractWrite.pw.tsx +++ b/ui/address/contract/ContractWrite.pw.tsx @@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => { const component = await mount( - + , { hooksConfig }, ); diff --git a/ui/address/contract/ContractWrite.tsx b/ui/address/contract/ContractWrite.tsx index bbd513545d..b199b99291 100644 --- a/ui/address/contract/ContractWrite.tsx +++ b/ui/address/contract/ContractWrite.tsx @@ -1,3 +1,4 @@ +import { useRouter } from 'next/router'; import React from 'react'; import { useAccount, useWalletClient, useNetwork, useSwitchNetwork } from 'wagmi'; @@ -5,6 +6,7 @@ import type { SmartContractWriteMethod } from 'types/api/contract'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; +import getQueryParamString from 'lib/router/getQueryParamString'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContentLoader from 'ui/shared/ContentLoader'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; @@ -17,18 +19,19 @@ import ContractWriteResult from './ContractWriteResult'; import useContractAbi from './useContractAbi'; import { getNativeCoinValue, prepareAbi } from './utils'; -interface Props { - addressHash?: string; - isProxy?: boolean; - isCustomAbi?: boolean; -} - -const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => { +const ContractWrite = () => { const { data: walletClient } = useWalletClient(); const { isConnected } = useAccount(); const { chain } = useNetwork(); const { switchNetworkAsync } = useSwitchNetwork(); + const router = useRouter(); + + const tab = getQueryParamString(router.query.tab); + const addressHash = getQueryParamString(router.query.hash); + const isProxy = tab === 'write_proxy'; + const isCustomAbi = tab === 'write_custom_methods'; + const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', { pathParams: { hash: addressHash }, queryParams: { @@ -112,7 +115,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => { { isCustomAbi && } { isProxy && } - + ); }; diff --git a/ui/address/contract/ContractWriteResultDumb.tsx b/ui/address/contract/ContractWriteResultDumb.tsx index 42702c490c..6e734f93a1 100644 --- a/ui/address/contract/ContractWriteResultDumb.tsx +++ b/ui/address/contract/ContractWriteResultDumb.tsx @@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { return ( ; +export type MethodFormFields = Record>; +export type MethodFormFieldsFormatted = Record; + +export type MethodArgType = string | boolean | Array; export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError; diff --git a/ui/address/contract/utils.test.ts b/ui/address/contract/utils.test.ts index 135e765f88..da20410583 100644 --- a/ui/address/contract/utils.test.ts +++ b/ui/address/contract/utils.test.ts @@ -1,4 +1,6 @@ -import { prepareAbi } from './utils'; +import type { SmartContractMethodInput } from 'types/api/contract'; + +import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils'; describe('function prepareAbi()', () => { const commonAbi = [ @@ -98,3 +100,100 @@ describe('function prepareAbi()', () => { expect(item).toEqual(commonAbi[2]); }); }); + +describe('function formatFieldValues()', () => { + const formFields = { + '_tx%0:nonce%0': '1 000 000 000 000 000 000', + '_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c', + '_tx%0:targets%2': [ + '1', + 'true', + ], + '_l2OutputIndex%1': '0xaeff', + '_paused%2': '0', + '_withdrawalProof%3': [ + '0x0f', + '0x02', + ], + }; + + const inputs: Array = [ + { + components: [ + { internalType: 'uint256', name: 'nonce', type: 'uint256' }, + { internalType: 'address', name: 'sender', type: 'address' }, + { internalType: 'bool[]', name: 'targets', type: 'bool[]' }, + ], + internalType: 'tuple', + name: '_tx', + type: 'tuple', + }, + { internalType: 'bytes32', name: '_l2OutputIndex', type: 'bytes32' }, + { + internalType: 'bool', + name: '_paused', + type: 'bool', + }, + { + internalType: 'bytes32[]', + name: '_withdrawalProof', + type: 'bytes32[]', + }, + ]; + + it('converts values to correct format', () => { + const result = formatFieldValues(formFields, inputs); + expect(result).toEqual({ + '_tx%0:nonce%0': '1000000000000000000', + '_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c', + '_tx%0:targets%2': [ + true, + true, + ], + '_l2OutputIndex%1': '0xaeff', + '_paused%2': false, + '_withdrawalProof%3': [ + '0x0f', + '0x02', + ], + }); + }); + + it('converts nested array string representation to correct format', () => { + const formFields = { + '_withdrawalProof%0': '[ [ 1 ], [ 2, 3 ], [ 4 ]]', + }; + const inputs: Array = [ + { internalType: 'uint[][]', name: '_withdrawalProof', type: 'uint[][]' }, + ]; + const result = formatFieldValues(formFields, inputs); + + expect(result).toEqual({ + '_withdrawalProof%0': [ [ 1 ], [ 2, 3 ], [ 4 ] ], + }); + }); +}); + +describe('function transformFieldsToArgs()', () => { + it('groups struct and array fields', () => { + const formFields = { + '_paused%2': 'primitive_1', + '_l2OutputIndex%1': 'primitive_0', + '_tx%0:nonce%0': 'struct_0', + '_tx%0:sender%1': 'struct_1', + '_tx%0:target%2': [ 'struct_2_0', 'struct_2_1' ], + '_withdrawalProof%3': [ + 'array_0', + 'array_1', + ], + }; + + const args = transformFieldsToArgs(formFields); + expect(args).toEqual([ + [ 'struct_0', 'struct_1', [ 'struct_2_0', 'struct_2_1' ] ], + 'primitive_0', + 'primitive_1', + [ 'array_0', 'array_1' ], + ]); + }); +}); diff --git a/ui/address/contract/utils.ts b/ui/address/contract/utils.ts index c0b01b6c94..dad1c293f9 100644 --- a/ui/address/contract/utils.ts +++ b/ui/address/contract/utils.ts @@ -1,33 +1,49 @@ import type { Abi } from 'abitype'; +import _mapValues from 'lodash/mapValues'; -import type { SmartContractWriteMethod } from 'types/api/contract'; +import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types'; +import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract'; -export const getNativeCoinValue = (value: string | Array) => { - const _value = Array.isArray(value) ? value[0] : value; +export const INT_REGEXP = /^(u)?int(\d+)?$/i; - if (typeof _value !== 'string') { - return BigInt(0); - } +export const BYTES_REGEXP = /^bytes(\d+)?$/i; - return BigInt(_value); +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 addZeroesAllowed = (valueType: string) => { - if (valueType.includes('[')) { - return false; - } +export const formatBooleanValue = (value: string) => { + const formattedValue = value.toLowerCase(); + + switch (formattedValue) { + case 'true': + case '1': { + return 'true'; + } - const REGEXP = /^u?int(\d+)/i; + case 'false': + case '0': { + return 'false'; + } + + default: + return; + } +}; - const match = valueType.match(REGEXP); - const power = match?.[1]; +export const getNativeCoinValue = (value: string | Array) => { + const _value = Array.isArray(value) ? value[0] : value; - if (power) { - // show control for all inputs which allows to insert 10^18 or greater numbers - return Number(power) >= 64; + if (typeof _value !== 'string') { + return BigInt(0); } - return false; + return BigInt(_value); }; interface ExtendedError extends Error { @@ -75,3 +91,106 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { return abi; } + +function getFieldType(fieldName: string, inputs: Array) { + const chunks = fieldName.split(':'); + + if (chunks.length === 1) { + const [ , index ] = chunks[0].split('%'); + return inputs[Number(index)].type; + } else { + const group = chunks[0].split('%'); + const input = chunks[1].split('%'); + + return inputs[Number(group[1])].components?.[Number(input[1])].type; + } +} + +function parseArrayValue(value: string) { + try { + const parsedResult = JSON.parse(value); + if (Array.isArray(parsedResult)) { + return parsedResult as Array; + } + throw new Error('Not an array'); + } catch (error) { + return ''; + } +} + +function castValue(value: string, type: SmartContractMethodArgType) { + if (type === 'bool') { + return formatBooleanValue(value) === 'true'; + } + + const intMatch = type.match(INT_REGEXP); + if (intMatch) { + return value.replaceAll(' ', ''); + } + + const isNestedArray = (type.match(/\[/g) || []).length > 1; + if (isNestedArray) { + return parseArrayValue(value) || value; + } + + return value; +} + +export function formatFieldValues(formFields: MethodFormFields, inputs: Array) { + const formattedFields = _mapValues(formFields, (value, key) => { + const type = getFieldType(key, inputs); + + if (!type) { + return value; + } + + if (Array.isArray(value)) { + const arrayMatch = type.match(ARRAY_REGEXP); + + if (arrayMatch) { + return value.map((item) => castValue(item, arrayMatch[1] as SmartContractMethodArgType)); + } + + return value; + } + + return castValue(value, type); + }); + + return formattedFields; +} + +export function transformFieldsToArgs(formFields: MethodFormFieldsFormatted) { + const unGroupedFields = Object.entries(formFields) + .reduce(( + result: Record, + [ key, value ]: [ string, MethodArgType ], + ) => { + const chunks = key.split(':'); + + if (chunks.length > 1) { + const groupKey = chunks[0]; + const [ , fieldIndex ] = chunks[1].split('%'); + + if (result[groupKey] === undefined) { + result[groupKey] = []; + } + + (result[groupKey] as Array)[Number(fieldIndex)] = value; + return result; + } + + result[key] = value; + return result; + }, {}); + + const args = (Object.entries(unGroupedFields) + .map(([ key, value ]) => { + const [ , index ] = key.split('%'); + return [ Number(index), value ]; + }) as Array<[ number, string | Array ]>) + .sort((a, b) => a[0] - b[0]) + .map(([ , value ]) => value); + + return args; +} diff --git a/ui/address/tokenSelect/TokenSelectItem.tsx b/ui/address/tokenSelect/TokenSelectItem.tsx index a1fffeac3f..bd9b9ea007 100644 --- a/ui/address/tokenSelect/TokenSelectItem.tsx +++ b/ui/address/tokenSelect/TokenSelectItem.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { route } from 'nextjs-routes'; import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import LinkInternal from 'ui/shared/LinkInternal'; import TruncatedValue from 'ui/shared/TruncatedValue'; import type { TokenEnhancedData } from '../utils/tokenUtils'; @@ -47,11 +48,10 @@ const TokenSelectItem = ({ data }: Props) => { } })(); - // TODO add filter param when token page is ready const url = route({ pathname: '/token/[hash]', query: { hash: data.token.address } }); return ( - { _hover={{ bgColor: useColorModeValue('blue.50', 'gray.800'), }} + color="initial" fontSize="sm" - cursor="pointer" - as="a" href={ url } > @@ -80,7 +79,7 @@ const TokenSelectItem = ({ data }: Props) => { { secondRow } - + ); }; diff --git a/ui/graphQL/GraphQL.tsx b/ui/graphQL/GraphQL.tsx index 2587ec5439..575fc2724c 100644 --- a/ui/graphQL/GraphQL.tsx +++ b/ui/graphQL/GraphQL.tsx @@ -20,18 +20,19 @@ const GraphQL = () => { const { colorMode } = useColorMode(); + const graphqlTheme = window.localStorage.getItem('graphiql:theme'); + // colorModeState used as a key to re-render GraphiQL conponent after color mode change - const [ colorModeState, setColorModeState ] = React.useState(colorMode); + const [ colorModeState, setColorModeState ] = React.useState(graphqlTheme); React.useEffect(() => { if (isBrowser()) { - const graphqlTheme = window.localStorage.getItem('graphiql:theme'); if (graphqlTheme !== colorMode) { window.localStorage.setItem('graphiql:theme', colorMode); setColorModeState(colorMode); } } - }, [ colorMode ]); + }, [ colorMode, graphqlTheme ]); if (!feature.isEnabled) { return null; diff --git a/ui/home/Stats.tsx b/ui/home/Stats.tsx index 41714197b6..3e0b2edbcf 100644 --- a/ui/home/Stats.tsx +++ b/ui/home/Stats.tsx @@ -92,7 +92,7 @@ const Stats = () => { diff --git a/ui/pages/Transaction.tsx b/ui/pages/Transaction.tsx index 894006b550..57328ecc58 100644 --- a/ui/pages/Transaction.tsx +++ b/ui/pages/Transaction.tsx @@ -8,11 +8,8 @@ import useApiQuery from 'lib/api/useApiQuery'; import { useAppContext } from 'lib/contexts/app'; import getQueryParamString from 'lib/router/getQueryParamString'; import { TX } from 'stubs/tx'; -import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; import TextAd from 'ui/shared/ad/TextAd'; -import TxEntity from 'ui/shared/entities/tx/TxEntity'; import EntityTags from 'ui/shared/EntityTags'; -import NetworkExplorers from 'ui/shared/NetworkExplorers'; import PageTitle from 'ui/shared/Page/PageTitle'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; @@ -23,6 +20,7 @@ import TxInternals from 'ui/tx/TxInternals'; import TxLogs from 'ui/tx/TxLogs'; import TxRawTrace from 'ui/tx/TxRawTrace'; import TxState from 'ui/tx/TxState'; +import TxSubHeading from 'ui/tx/TxSubHeading'; import TxTokenTransfer from 'ui/tx/TxTokenTransfer'; const TransactionPageContent = () => { @@ -40,7 +38,11 @@ const TransactionPageContent = () => { }); const tabs: Array = [ - { id: 'index', title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details', component: }, + { + id: 'index', + title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details', + component: , + }, config.features.suave.isEnabled && data?.wrapped ? { id: 'wrapped', title: 'Regular tx details', component: } : undefined, @@ -73,13 +75,7 @@ const TransactionPageContent = () => { }; }, [ appProps.referrer ]); - const titleSecondRow = ( - <> - - { !data?.tx_tag && } - - - ); + const titleSecondRow = ; return ( <> diff --git a/ui/shared/GasInfoTooltipContent/GasInfoTooltipContent.tsx b/ui/shared/GasInfoTooltipContent/GasInfoTooltipContent.tsx index e1d164867a..952d65438b 100644 --- a/ui/shared/GasInfoTooltipContent/GasInfoTooltipContent.tsx +++ b/ui/shared/GasInfoTooltipContent/GasInfoTooltipContent.tsx @@ -11,11 +11,11 @@ const GasInfoTooltipContent = ({ gasPrices }: {gasPrices: GasPrices}) => { return ( Slow - { `${ gasPrices.slow } Gwei` } + { gasPrices.slow !== null ? `${ gasPrices.slow } Gwei` : 'N/A' } Average - { `${ gasPrices.average } Gwei` } + { gasPrices.average !== null ? `${ gasPrices.average } Gwei` : 'N/A' } Fast - { `${ gasPrices.fast } Gwei` } + { gasPrices.fast !== null ? `${ gasPrices.fast } Gwei` : 'N/A' } ); }; diff --git a/ui/shared/LinkInternal.tsx b/ui/shared/LinkInternal.tsx index 09a9764e62..b46b369096 100644 --- a/ui/shared/LinkInternal.tsx +++ b/ui/shared/LinkInternal.tsx @@ -5,7 +5,6 @@ import NextLink from 'next/link'; import type { LegacyRef } from 'react'; import React from 'react'; -// NOTE! use this component only for links to pages that are completely implemented in new UI const LinkInternal = ({ isLoading, ...props }: LinkProps & { isLoading?: boolean }, ref: LegacyRef) => { if (isLoading) { return { props.children }; diff --git a/ui/snippets/topBar/ColorModeSwitch.tsx b/ui/snippets/topBar/ColorModeSwitch.tsx index 35fffea85a..aa7156a1e1 100644 --- a/ui/snippets/topBar/ColorModeSwitch.tsx +++ b/ui/snippets/topBar/ColorModeSwitch.tsx @@ -35,6 +35,7 @@ const ColorModeSwitch = () => { window.document.documentElement.style.setProperty(varName, hex); cookies.set(cookies.NAMES.COLOR_MODE_HEX, hex); + window.localStorage.setItem(cookies.NAMES.COLOR_MODE, nextTheme.colorMode); }, [ setColorMode ]); React.useEffect(() => { diff --git a/ui/snippets/topBar/TopBarStats.tsx b/ui/snippets/topBar/TopBarStats.tsx index 31ab9a2a33..758bcfb1a6 100644 --- a/ui/snippets/topBar/TopBarStats.tsx +++ b/ui/snippets/topBar/TopBarStats.tsx @@ -40,7 +40,7 @@ const TopBarStats = () => { ) } { data?.coin_price && config.UI.homepage.showGasTracker && } - { data?.gas_prices && config.UI.homepage.showGasTracker && ( + { data?.gas_prices && data.gas_prices.average !== null && config.UI.homepage.showGasTracker && ( Gas diff --git a/ui/tx/TxDetails.tsx b/ui/tx/TxDetails.tsx index df1cec00ee..206c6190ab 100644 --- a/ui/tx/TxDetails.tsx +++ b/ui/tx/TxDetails.tsx @@ -44,7 +44,7 @@ import TextSeparator from 'ui/shared/TextSeparator'; import TxFeeStability from 'ui/shared/tx/TxFeeStability'; import Utilization from 'ui/shared/Utilization/Utilization'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; -import TxDetailsActions from 'ui/tx/details/TxDetailsActions'; +import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions'; import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas'; import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice'; import TxDetailsOther from 'ui/tx/details/TxDetailsOther'; @@ -98,8 +98,6 @@ const TxDetails = () => { ...toAddress?.watchlist_names || [], ].map((tag) => { tag.display_name }); - const actionsExist = data.actions && data.actions.length > 0; - const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? ( @@ -242,12 +240,7 @@ const TxDetails = () => { - { actionsExist && ( - <> - - - - ) } + { + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); + +const bsInterpretationTest = test.extend({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: contextWithEnvs(configs.featureEnvs.txInterpretation) as any, +}); + +bsInterpretationTest('with interpretation +@mobile +@dark-mode', async({ mount, page }) => { + await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({ + status: 200, + body: JSON.stringify(txInterpretation), + })); + + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); + +bsInterpretationTest('no interpretation', async({ mount, page }) => { + await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({ + status: 200, + body: JSON.stringify({ data: { summaries: [] } }), + })); + + const component = await mount( + + + , + ); + + await expect(component).toHaveScreenshot(); +}); diff --git a/ui/tx/TxSubHeading.tsx b/ui/tx/TxSubHeading.tsx new file mode 100644 index 0000000000..477aeddef6 --- /dev/null +++ b/ui/tx/TxSubHeading.tsx @@ -0,0 +1,54 @@ +import { Box, Flex, Link } from '@chakra-ui/react'; +import React from 'react'; + +import config from 'configs/app'; +import useApiQuery from 'lib/api/useApiQuery'; +import { TX_INTERPRETATION } from 'stubs/txInterpretation'; +import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; +import TxEntity from 'ui/shared/entities/tx/TxEntity'; +import NetworkExplorers from 'ui/shared/NetworkExplorers'; +import { TX_ACTIONS_BLOCK_ID } from 'ui/tx/details/txDetailsActions/TxDetailsActionsWrapper'; +import TxInterpretation from 'ui/tx/interpretation/TxInterpretation'; + +type Props = { + hash?: string; + hasTag: boolean; +} + +const TxSubHeading = ({ hash, hasTag }: Props) => { + const hasInterpretationFeature = config.features.txInterpretation.isEnabled; + + const txInterpretationQuery = useApiQuery('tx_interpretation', { + pathParams: { hash }, + queryOptions: { + enabled: Boolean(hash) && hasInterpretationFeature, + placeholderData: TX_INTERPRETATION, + }, + }); + + const hasInterpretation = hasInterpretationFeature && + (txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length)); + + return ( + + { hasInterpretation && ( + + + { !txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1 && + all actions } + + ) } + { !hasInterpretation && } + + { !hasTag && } + + + + ); +}; + +export default TxSubHeading; diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_dark-color-mode_with-interpretation-mobile-dark-mode-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_dark-color-mode_with-interpretation-mobile-dark-mode-1.png new file mode 100644 index 0000000000..d207a60184 Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_dark-color-mode_with-interpretation-mobile-dark-mode-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-1.png new file mode 100644 index 0000000000..86e1242778 Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-mobile-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-mobile-1.png new file mode 100644 index 0000000000..86e1242778 Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_no-interpretation-mobile-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_with-interpretation-mobile-dark-mode-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_with-interpretation-mobile-dark-mode-1.png new file mode 100644 index 0000000000..69b55d642c Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_default_with-interpretation-mobile-dark-mode-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_no-interpretation-mobile-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_no-interpretation-mobile-1.png new file mode 100644 index 0000000000..c72dc3aacb Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_no-interpretation-mobile-1.png differ diff --git a/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_with-interpretation-mobile-dark-mode-1.png b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_with-interpretation-mobile-dark-mode-1.png new file mode 100644 index 0000000000..4d0e85260a Binary files /dev/null and b/ui/tx/__screenshots__/TxSubHeading.pw.tsx_mobile_with-interpretation-mobile-dark-mode-1.png differ diff --git a/ui/tx/details/TxDetailsAction.tsx b/ui/tx/details/txDetailsActions/TxDetailsAction.tsx similarity index 100% rename from ui/tx/details/TxDetailsAction.tsx rename to ui/tx/details/txDetailsActions/TxDetailsAction.tsx diff --git a/ui/tx/details/txDetailsActions/TxDetailsActions.tsx b/ui/tx/details/txDetailsActions/TxDetailsActions.tsx new file mode 100644 index 0000000000..fc0622e2af --- /dev/null +++ b/ui/tx/details/txDetailsActions/TxDetailsActions.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import type { TxAction } from 'types/api/txAction'; + +import config from 'configs/app'; +import TxDetailsActionsInterpretation from 'ui/tx/details/txDetailsActions/TxDetailsActionsInterpretation'; +import TxDetailsActionsRaw from 'ui/tx/details/txDetailsActions/TxDetailsActionsRaw'; + +type Props = { + isTxDataLoading: boolean; + actions?: Array; + hash?: string; +} + +const TxDetailsActions = ({ isTxDataLoading, actions, hash }: Props) => { + if (config.features.txInterpretation.isEnabled) { + return ; + } + + /* if tx interpretation is not configured, show tx actions from tx info */ + if (actions && actions.length > 0) { + return ; + } + + return null; +}; + +export default TxDetailsActions; diff --git a/ui/tx/details/txDetailsActions/TxDetailsActionsInterpretation.tsx b/ui/tx/details/txDetailsActions/TxDetailsActionsInterpretation.tsx new file mode 100644 index 0000000000..ade6ac072a --- /dev/null +++ b/ui/tx/details/txDetailsActions/TxDetailsActionsInterpretation.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import useApiQuery from 'lib/api/useApiQuery'; +import { TX_INTERPRETATION } from 'stubs/txInterpretation'; +import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; +import TxInterpretation from 'ui/tx/interpretation/TxInterpretation'; + +import TxDetailsActionsWrapper from './TxDetailsActionsWrapper'; + +interface Props { + hash?: string; + isTxDataLoading: boolean; +} + +const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => { + const txInterpretationQuery = useApiQuery('tx_interpretation', { + pathParams: { hash }, + queryOptions: { + enabled: Boolean(hash) && !isTxDataLoading, + placeholderData: TX_INTERPRETATION, + refetchOnMount: false, + }, + }); + + const actions = txInterpretationQuery.data?.data.summaries; + + if (!actions || actions.length < 2) { + return null; + } + + return ( + <> + + { actions.map((action, index: number) => ( + + ), + ) } + + + + ); +}; + +export default TxDetailsActionsInterpretation; diff --git a/ui/tx/details/txDetailsActions/TxDetailsActionsRaw.tsx b/ui/tx/details/txDetailsActions/TxDetailsActionsRaw.tsx new file mode 100644 index 0000000000..a024347519 --- /dev/null +++ b/ui/tx/details/txDetailsActions/TxDetailsActionsRaw.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import type { TxAction } from 'types/api/txAction'; + +import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; + +import TxDetailsAction from './TxDetailsAction'; +import TxDetailsActionsWrapper from './TxDetailsActionsWrapper'; + +interface Props { + actions: Array; + isLoading: boolean; +} + +const TxDetailsActionsRaw = ({ actions, isLoading }: Props) => { + return ( + <> + + { actions.map((action, index: number) => ) } + + + + ); +}; + +export default TxDetailsActionsRaw; diff --git a/ui/tx/details/TxDetailsActions.tsx b/ui/tx/details/txDetailsActions/TxDetailsActionsWrapper.tsx similarity index 83% rename from ui/tx/details/TxDetailsActions.tsx rename to ui/tx/details/txDetailsActions/TxDetailsActionsWrapper.tsx index 200e0315fc..c9e963e803 100644 --- a/ui/tx/details/TxDetailsActions.tsx +++ b/ui/tx/details/txDetailsActions/TxDetailsActionsWrapper.tsx @@ -1,19 +1,18 @@ import { Flex, useColorModeValue } from '@chakra-ui/react'; import React from 'react'; -import type { TxAction } from 'types/api/txAction'; - import DetailsInfoItem from 'ui/shared/DetailsInfoItem'; -import TxDetailsAction from './TxDetailsAction'; - const SCROLL_GRADIENT_HEIGHT = 48; -interface Props { - actions: Array; +type Props = { + children: React.ReactNode; + isLoading?: boolean; } -const TxDetailsActions = ({ actions }: Props) => { +export const TX_ACTIONS_BLOCK_ID = 'tx-actions'; + +const TxDetailsActions = ({ children, isLoading }: Props) => { const containerRef = React.useRef(null); const [ hasScroll, setHasScroll ] = React.useState(false); @@ -34,8 +33,10 @@ const TxDetailsActions = ({ actions }: Props) => { hint="Highlighted events of the transaction" note={ hasScroll ? 'Scroll to see more' : undefined } position="relative" + isLoading={ isLoading } > { pr={ hasScroll ? 5 : 0 } pb={ hasScroll ? 10 : 0 } > - { actions.map((action, index: number) => ) } + { children } ); diff --git a/ui/tx/interpretation/TxInterpretation.tsx b/ui/tx/interpretation/TxInterpretation.tsx new file mode 100644 index 0000000000..293d712977 --- /dev/null +++ b/ui/tx/interpretation/TxInterpretation.tsx @@ -0,0 +1,115 @@ +import { Skeleton, Flex, Text, chakra } from '@chakra-ui/react'; +import BigNumber from 'bignumber.js'; +import React from 'react'; + +import type { TxInterpretationSummary, TxInterpretationVariable } from 'types/api/txInterpretation'; + +import config from 'configs/app'; +import dayjs from 'lib/date/dayjs'; +import * as mixpanel from 'lib/mixpanel/index'; +import AddressEntity from 'ui/shared/entities/address/AddressEntity'; +import TokenEntity from 'ui/shared/entities/token/TokenEntity'; +import IconSvg from 'ui/shared/IconSvg'; + +import { extractVariables, getStringChunks, NATIVE_COIN_SYMBOL_VAR_NAME } from './utils'; + +type Props = { + summary?: TxInterpretationSummary; + isLoading?: boolean; + className?: string; +} + +const TxInterpretationElementByType = ({ variable }: { variable?: TxInterpretationVariable }) => { + const onAddressClick = React.useCallback(() => { + mixpanel.logEvent(mixpanel.EventTypes.TX_INTERPRETATION_INTERACTION, { Type: 'Address click' }); + }, []); + + const onTokenClick = React.useCallback(() => { + mixpanel.logEvent(mixpanel.EventTypes.TX_INTERPRETATION_INTERACTION, { Type: 'Token click' }); + }, []); + + if (!variable) { + return null; + } + + const { type, value } = variable; + switch (type) { + case 'address': { + return ( + + ); + } + case 'token': + return ( + + ); + case 'currency': { + let numberString = ''; + if (BigNumber(value).isLessThan(0.1)) { + numberString = BigNumber(value).toPrecision(2); + } else if (BigNumber(value).isLessThan(10000)) { + numberString = BigNumber(value).dp(2).toFormat(); + } else if (BigNumber(value).isLessThan(1000000)) { + numberString = BigNumber(value).dividedBy(1000).toFormat(2) + 'K'; + } else { + numberString = BigNumber(value).dividedBy(1000000).toFormat(2) + 'M'; + } + return { numberString + ' ' }; + } + case 'timestamp': + // timestamp is in unix format + return { dayjs(Number(value) * 1000).format('llll') + ' ' }; + case 'string': + default: { + return { value.toString() + ' ' }; + } + } +}; + +const TxInterpretation = ({ summary, isLoading, className }: Props) => { + if (!summary) { + return null; + } + + const template = summary.summary_template; + const variables = summary.summary_template_variables; + + const variablesNames = extractVariables(template); + + const chunks = getStringChunks(template); + + return ( + + + { chunks.map((chunk, index) => { + return ( + + { chunk.trim() + (chunk.trim() && variablesNames[index] ? ' ' : '') } + { index < variablesNames.length && ( + variablesNames[index] === NATIVE_COIN_SYMBOL_VAR_NAME ? + { config.chain.currency.symbol + ' ' } : + + ) } + + ); + }) } + + ); +}; + +export default chakra(TxInterpretation); diff --git a/ui/tx/interpretation/utils.test.ts b/ui/tx/interpretation/utils.test.ts new file mode 100644 index 0000000000..c3089594b7 --- /dev/null +++ b/ui/tx/interpretation/utils.test.ts @@ -0,0 +1,13 @@ +import { extractVariables, getStringChunks } from './utils'; + +const template = '{action_type} {source_amount} {native} into {destination_amount} {destination_token}'; + +it('extracts variables names', () => { + const result = extractVariables(template); + expect(result).toEqual([ 'action_type', 'source_amount', 'native', 'destination_amount', 'destination_token' ]); +}); + +it('split string without capturing variables', () => { + const result = getStringChunks(template); + expect(result).toEqual([ '', ' ', ' ', ' into ', ' ', '' ]); +}); diff --git a/ui/tx/interpretation/utils.ts b/ui/tx/interpretation/utils.ts new file mode 100644 index 0000000000..c6ffbb6e42 --- /dev/null +++ b/ui/tx/interpretation/utils.ts @@ -0,0 +1,18 @@ +// we use that regex as a separator when splitting template and dont want to capture variables +// eslint-disable-next-line regexp/no-useless-non-capturing-group +export const VAR_REGEXP = /\{(?:[^}]+)\}/g; + +export const NATIVE_COIN_SYMBOL_VAR_NAME = 'native'; + +export function extractVariables(templateString: string) { + + const matches = templateString.match(VAR_REGEXP); + + const variablesNames = matches ? matches.map(match => match.slice(1, -1)) : []; + + return variablesNames; +} + +export function getStringChunks(template: string) { + return template.split(VAR_REGEXP); +} diff --git a/yarn.lock b/yarn.lock index af49821c77..ad97646b1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13950,6 +13950,13 @@ react-jazzicon@^1.0.4: dependencies: mersenne-twister "^1.1.0" +react-number-format@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.3.1.tgz#840c257da9cb4b248990d8db46e4d23e8bac67ff" + integrity sha512-qpYcQLauIeEhCZUZY9jXZnnroOtdy3jYaS1zQ3M1Sr6r/KMOBEIGNIb7eKT19g2N1wbYgFgvDzs19hw5TrB8XQ== + dependencies: + prop-types "^15.7.2" + react-redux@^8.1.2: version "8.1.3" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.3.tgz#4fdc0462d0acb59af29a13c27ffef6f49ab4df46"