Commit 4d719e8a authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into tom2drum/issue-1376

parents a90b72a9 47566b89
import type { Feature } from './types'; import type { Feature } from './types';
import type { Provider } from 'types/client/txInterpretation';
import { PROVIDERS } from 'types/client/txInterpretation';
import { getEnvValue } from '../utils'; import { getEnvValue } from '../utils';
const title = 'Transaction interpretation'; const title = 'Transaction interpretation';
const provider = getEnvValue('NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER') || 'none'; const provider: Provider = (() => {
const value = getEnvValue('NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER');
const config: Feature<{ provider: string }> = (() => { if (value && PROVIDERS.includes(value as Provider)) {
return value as Provider;
}
return 'none';
})();
const config: Feature<{ provider: Provider }> = (() => {
if (provider !== 'none') { if (provider !== 'none') {
return Object.freeze({ return Object.freeze({
title, title,
......
...@@ -15,6 +15,7 @@ import type { MarketplaceAppOverview } from '../../../types/client/marketplace'; ...@@ -15,6 +15,7 @@ import type { MarketplaceAppOverview } from '../../../types/client/marketplace';
import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items'; import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items';
import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items'; import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items';
import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token'; 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 type { WalletType } from '../../../types/client/wallets';
import { SUPPORTED_WALLETS } from '../../../types/client/wallets'; import { SUPPORTED_WALLETS } from '../../../types/client/wallets';
import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks'; import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks';
...@@ -439,7 +440,7 @@ const schema = yup ...@@ -439,7 +440,7 @@ const schema = yup
return isNoneSchema.isValidSync(data) || isArrayOfWalletsSchema.isValidSync(data); return isNoneSchema.isValidSync(data) || isArrayOfWalletsSchema.isValidSync(data);
}), }),
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: yup.boolean(), NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: yup.boolean(),
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: yup.string().oneOf([ 'blockscout', 'none' ]), NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: yup.string().oneOf(TX_INTERPRETATION_PROVIDERS),
NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string<AdTextProviders>().oneOf(SUPPORTED_AD_TEXT_PROVIDERS), NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string<AdTextProviders>().oneOf(SUPPORTED_AD_TEXT_PROVIDERS),
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(), NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(),
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
......
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { TextEncoder, TextDecoder } from 'util';
import fetchMock from 'jest-fetch-mock'; import fetchMock from 'jest-fetch-mock';
...@@ -6,6 +7,8 @@ fetchMock.enableMocks(); ...@@ -6,6 +7,8 @@ fetchMock.enableMocks();
const envs = dotenv.config({ path: './configs/envs/.env.jest' }); const envs = dotenv.config({ path: './configs/envs/.env.jest' });
Object.assign(global, { TextDecoder, TextEncoder });
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: jest.fn().mockImplementation(query => ({ value: jest.fn().mockImplementation(query => ({
......
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import _omit from 'lodash/omit';
import _pickBy from 'lodash/pickBy'; import _pickBy from 'lodash/pickBy';
import React from 'react'; import React from 'react';
...@@ -19,7 +20,7 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources' ...@@ -19,7 +20,7 @@ import type { ApiResource, ResourceName, ResourcePathParams } from './resources'
export interface Params<R extends ResourceName> { export interface Params<R extends ResourceName> {
pathParams?: ResourcePathParams<R>; pathParams?: ResourcePathParams<R>;
queryParams?: Record<string, string | Array<string> | number | boolean | undefined>; queryParams?: Record<string, string | Array<string> | number | boolean | undefined>;
fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal'>; fetchParams?: Pick<FetchParams, 'body' | 'method' | 'signal' | 'headers'>;
} }
export default function useApiFetch() { export default function useApiFetch() {
...@@ -40,6 +41,7 @@ export default function useApiFetch() { ...@@ -40,6 +41,7 @@ export default function useApiFetch() {
'x-endpoint': resource.endpoint && isNeedProxy() ? resource.endpoint : undefined, 'x-endpoint': resource.endpoint && isNeedProxy() ? resource.endpoint : undefined,
Authorization: resource.endpoint && resource.needAuth ? apiToken : undefined, Authorization: resource.endpoint && resource.needAuth ? apiToken : undefined,
'x-csrf-token': withBody && csrfToken ? csrfToken : undefined, 'x-csrf-token': withBody && csrfToken ? csrfToken : undefined,
...fetchParams?.headers,
}, Boolean) as HeadersInit; }, Boolean) as HeadersInit;
return fetch<SuccessType, ErrorType>( return fetch<SuccessType, ErrorType>(
...@@ -51,7 +53,7 @@ export default function useApiFetch() { ...@@ -51,7 +53,7 @@ export default function useApiFetch() {
// change condition here if something is changed // change condition here if something is changed
credentials: config.features.account.isEnabled ? 'include' : 'same-origin', credentials: config.features.account.isEnabled ? 'include' : 'same-origin',
headers, headers,
...fetchParams, ..._omit(fetchParams, 'headers'),
}, },
{ {
resource: resource.path, resource: resource.path,
......
...@@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) { ...@@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) {
// { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } : // { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
// undefined, // undefined,
data?.has_methods_read ? data?.has_methods_read ?
{ id: 'read_contract', title: 'Read contract', component: <ContractRead addressHash={ data?.hash }/> } : { id: 'read_contract', title: 'Read contract', component: <ContractRead/> } :
undefined, undefined,
data?.has_methods_read_proxy ? data?.has_methods_read_proxy ?
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead addressHash={ data?.hash } isProxy/> } : { id: 'read_proxy', title: 'Read proxy', component: <ContractRead/> } :
undefined, undefined,
data?.has_custom_methods_read ? data?.has_custom_methods_read ?
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead addressHash={ data?.hash } isCustomAbi/> } : { id: 'read_custom_methods', title: 'Read custom', component: <ContractRead/> } :
undefined, undefined,
data?.has_methods_write ? data?.has_methods_write ?
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite addressHash={ data?.hash }/> } : { id: 'write_contract', title: 'Write contract', component: <ContractWrite/> } :
undefined, undefined,
data?.has_methods_write_proxy ? data?.has_methods_write_proxy ?
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite addressHash={ data?.hash } isProxy/> } : { id: 'write_proxy', title: 'Write proxy', component: <ContractWrite/> } :
undefined, undefined,
data?.has_custom_methods_write ? data?.has_custom_methods_write ?
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite addressHash={ data?.hash } isCustomAbi/> } : { id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite/> } :
undefined, undefined,
].filter(Boolean); ].filter(Boolean);
}, [ data ]); }, [ data ]);
......
...@@ -179,7 +179,6 @@ export default function useNavItems(): ReturnType { ...@@ -179,7 +179,6 @@ export default function useNavItems(): ReturnType {
{ {
text: 'Verify contract', text: 'Verify contract',
nextRoute: { pathname: '/contract-verification' as const }, nextRoute: { pathname: '/contract-verification' as const },
icon: 'verify-contract',
isActive: pathname.startsWith('/contract-verification'), isActive: pathname.startsWith('/contract-verification'),
}, },
...config.UI.sidebar.otherLinks, ...config.UI.sidebar.otherLinks,
......
...@@ -3,17 +3,20 @@ import type { WalletType } from 'types/client/wallets'; ...@@ -3,17 +3,20 @@ import type { WalletType } from 'types/client/wallets';
export enum EventTypes { export enum EventTypes {
PAGE_VIEW = 'Page view', PAGE_VIEW = 'Page view',
SEARCH_QUERY = 'Search query', SEARCH_QUERY = 'Search query',
LOCAL_SEARCH = 'Local search',
ADD_TO_WALLET = 'Add to wallet', ADD_TO_WALLET = 'Add to wallet',
ACCOUNT_ACCESS = 'Account access', ACCOUNT_ACCESS = 'Account access',
PRIVATE_TAG = 'Private tag', PRIVATE_TAG = 'Private tag',
VERIFY_ADDRESS = 'Verify address', VERIFY_ADDRESS = 'Verify address',
VERIFY_TOKEN = 'Verify token', VERIFY_TOKEN = 'Verify token',
WALLET_CONNECT = 'Wallet connect', WALLET_CONNECT = 'Wallet connect',
WALLET_ACTION = 'Wallet action',
CONTRACT_INTERACTION = 'Contract interaction', CONTRACT_INTERACTION = 'Contract interaction',
CONTRACT_VERIFICATION = 'Contract verification', CONTRACT_VERIFICATION = 'Contract verification',
QR_CODE = 'QR code', QR_CODE = 'QR code',
PAGE_WIDGET = 'Page widget', PAGE_WIDGET = 'Page widget',
TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction' TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction',
FILTERS = 'Filters'
} }
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
...@@ -30,6 +33,10 @@ Type extends EventTypes.SEARCH_QUERY ? { ...@@ -30,6 +33,10 @@ Type extends EventTypes.SEARCH_QUERY ? {
'Source page type': string; 'Source page type': string;
'Result URL': string; 'Result URL': string;
} : } :
Type extends EventTypes.LOCAL_SEARCH ? {
'Search query': string;
'Source': 'Marketplace';
} :
Type extends EventTypes.ADD_TO_WALLET ? ( Type extends EventTypes.ADD_TO_WALLET ? (
{ {
'Wallet': WalletType; 'Wallet': WalletType;
...@@ -65,6 +72,9 @@ Type extends EventTypes.WALLET_CONNECT ? { ...@@ -65,6 +72,9 @@ Type extends EventTypes.WALLET_CONNECT ? {
'Source': 'Header' | 'Smart contracts'; 'Source': 'Header' | 'Smart contracts';
'Status': 'Started' | 'Connected'; 'Status': 'Started' | 'Connected';
} : } :
Type extends EventTypes.WALLET_ACTION ? {
'Action': 'Open' | 'Address click';
} :
Type extends EventTypes.CONTRACT_INTERACTION ? { Type extends EventTypes.CONTRACT_INTERACTION ? {
'Method type': 'Read' | 'Write'; 'Method type': 'Read' | 'Write';
'Method name': string; 'Method name': string;
...@@ -76,11 +86,20 @@ Type extends EventTypes.CONTRACT_VERIFICATION ? { ...@@ -76,11 +86,20 @@ Type extends EventTypes.CONTRACT_VERIFICATION ? {
Type extends EventTypes.QR_CODE ? { Type extends EventTypes.QR_CODE ? {
'Page type': string; 'Page type': string;
} : } :
Type extends EventTypes.PAGE_WIDGET ? { Type extends EventTypes.PAGE_WIDGET ? (
'Type': 'Tokens dropdown' | 'Tokens show all (icon)' | 'Add to watchlist' | 'Address actions (more button)'; {
} : 'Type': 'Tokens dropdown' | 'Tokens show all (icon)' | 'Add to watchlist' | 'Address actions (more button)';
} | {
'Type': 'Favorite app' | 'More button';
'Info': string;
}
) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
'Type': 'Address click' | 'Token click'; 'Type': 'Address click' | 'Token click';
} : } :
Type extends EventTypes.FILTERS ? {
'Source': 'Marketplace';
'Filter name': string;
} :
undefined; undefined;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -56,6 +56,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => { ...@@ -56,6 +56,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
'Script error.', 'Script error.',
// Relay and WalletConnect errors // Relay and WalletConnect errors
'The quota has been exceeded',
'Attempt to connect to relay via', 'Attempt to connect to relay via',
'WebSocket connection failed for URL: wss://relay.walletconnect.com', 'WebSocket connection failed for URL: wss://relay.walletconnect.com',
], ],
...@@ -67,9 +68,11 @@ export const config: Sentry.BrowserOptions | undefined = (() => { ...@@ -67,9 +68,11 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
// Woopra flakiness // Woopra flakiness
/eatdifferent\.com\.woopra-ns\.com/i, /eatdifferent\.com\.woopra-ns\.com/i,
/static\.woopra\.com\/js\/woopra\.js/i, /static\.woopra\.com\/js\/woopra\.js/i,
// Chrome extensions // Chrome and other extensions
/extensions\//i, /extensions\//i,
/^chrome:\/\//i, /^chrome:\/\//i,
/^chrome-extension:\/\//i,
/^moz-extension:\/\//i,
// Other plugins // Other plugins
/127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb /127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
/webappstoolbarba\.texthelp\.com\//i, /webappstoolbarba\.texthelp\.com\//i,
......
...@@ -9,12 +9,12 @@ export const read: Array<SmartContractReadMethod> = [ ...@@ -9,12 +9,12 @@ export const read: Array<SmartContractReadMethod> = [
{ {
constant: true, constant: true,
inputs: [ inputs: [
{ internalType: 'address', name: '', type: 'address' }, { internalType: 'address', name: 'wallet', type: 'address' },
], ],
method_id: '70a08231', method_id: '70a08231',
name: 'FLASHLOAN_PREMIUM_TOTAL', name: 'FLASHLOAN_PREMIUM_TOTAL',
outputs: [ outputs: [
{ internalType: 'uint256', name: '', type: 'uint256' }, { internalType: 'uint256', name: 'amount', type: 'uint256' },
], ],
payable: false, payable: false,
stateMutability: 'view', stateMutability: 'view',
...@@ -97,7 +97,7 @@ export const read: Array<SmartContractReadMethod> = [ ...@@ -97,7 +97,7 @@ export const read: Array<SmartContractReadMethod> = [
export const readResultSuccess: SmartContractQueryMethodReadSuccess = { export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
is_error: false, is_error: false,
result: { result: {
names: [ 'uint256' ], names: [ 'amount' ],
output: [ output: [
{ type: 'uint256', value: '42' }, { type: 'uint256', value: '42' },
], ],
......
...@@ -3,11 +3,26 @@ import type { HomeStats } from 'types/api/stats'; ...@@ -3,11 +3,26 @@ import type { HomeStats } from 'types/api/stats';
export const base: HomeStats = { export const base: HomeStats = {
average_block_time: 6212.0, average_block_time: 6212.0,
coin_price: '0.00199678', coin_price: '0.00199678',
coin_price_change_percentage: -7.42,
gas_prices: { gas_prices: {
average: 48.0, average: {
fast: 67.5, fiat_price: '1.01',
slow: 48.0, price: 20.41,
time: 12283,
},
fast: {
fiat_price: '1.26',
price: 25.47,
time: 9321,
},
slow: {
fiat_price: '0.97',
price: 19.55,
time: 24543,
},
}, },
gas_price_updated_at: '2022-11-11T11:09:49.051171Z',
gas_prices_update_in: 300000,
gas_used_today: '4108680603', gas_used_today: '4108680603',
market_cap: '330809.96443288102524', market_cap: '330809.96443288102524',
network_utilization_percentage: 1.55372064, network_utilization_percentage: 1.55372064,
......
...@@ -9,11 +9,11 @@ export function ad(): CspDev.DirectiveDescriptor { ...@@ -9,11 +9,11 @@ export function ad(): CspDev.DirectiveDescriptor {
'connect-src': [ 'connect-src': [
'coinzilla.com', 'coinzilla.com',
'*.coinzilla.com', '*.coinzilla.com',
'request-global.czilladx.com', 'https://request-global.czilladx.com',
'*.slise.xyz', '*.slise.xyz',
], ],
'frame-src': [ 'frame-src': [
'request-global.czilladx.com', 'https://request-global.czilladx.com',
], ],
'script-src': [ 'script-src': [
'coinzillatag.com', 'coinzillatag.com',
...@@ -27,7 +27,7 @@ export function ad(): CspDev.DirectiveDescriptor { ...@@ -27,7 +27,7 @@ export function ad(): CspDev.DirectiveDescriptor {
'cdn.coinzilla.io', 'cdn.coinzilla.io',
], ],
'font-src': [ 'font-src': [
'request-global.czilladx.com', 'https://request-global.czilladx.com',
], ],
}; };
} }
...@@ -9,7 +9,6 @@ import { KEY_WORDS } from '../utils'; ...@@ -9,7 +9,6 @@ import { KEY_WORDS } from '../utils';
const MAIN_DOMAINS = [ const MAIN_DOMAINS = [
`*.${ config.app.host }`, `*.${ config.app.host }`,
config.app.host, config.app.host,
getFeaturePayload(config.features.sol2uml)?.api.endpoint,
].filter(Boolean); ].filter(Boolean);
const getCspReportUrl = () => { const getCspReportUrl = () => {
...@@ -113,6 +112,7 @@ export function app(): CspDev.DirectiveDescriptor { ...@@ -113,6 +112,7 @@ export function app(): CspDev.DirectiveDescriptor {
'font-src': [ 'font-src': [
KEY_WORDS.DATA, KEY_WORDS.DATA,
...MAIN_DOMAINS,
], ],
'object-src': [ 'object-src': [
......
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
import _pick from 'lodash/pick';
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
import type { NextApiRequestCookies } from 'next/dist/server/api-utils'; import type { NextApiRequestCookies } from 'next/dist/server/api-utils';
import type { RequestInit, Response } from 'node-fetch'; import type { RequestInit, Response } from 'node-fetch';
...@@ -14,16 +15,18 @@ export default function fetchFactory( ...@@ -14,16 +15,18 @@ export default function fetchFactory(
// first arg can be only a string // first arg can be only a string
// FIXME migrate to RequestInfo later if needed // FIXME migrate to RequestInfo later if needed
return function fetch(url: string, init?: RequestInit): Promise<Response> { return function fetch(url: string, init?: RequestInit): Promise<Response> {
const csrfToken = _req.headers['x-csrf-token'];
const authToken = _req.headers['Authorization'];
const apiToken = _req.cookies[cookies.NAMES.API_TOKEN]; const apiToken = _req.cookies[cookies.NAMES.API_TOKEN];
const headers = { const headers = {
accept: _req.headers['accept'] || 'application/json', accept: _req.headers['accept'] || 'application/json',
'content-type': _req.headers['content-type'] || 'application/json', 'content-type': _req.headers['content-type'] || 'application/json',
cookie: apiToken ? `${ cookies.NAMES.API_TOKEN }=${ apiToken }` : '', cookie: apiToken ? `${ cookies.NAMES.API_TOKEN }=${ apiToken }` : '',
...(csrfToken ? { 'x-csrf-token': String(csrfToken) } : {}), ..._pick(_req.headers, [
...(authToken ? { Authorization: String(authToken) } : {}), 'x-csrf-token',
'Authorization',
// feature flags
'updated-gas-oracle',
]) as Record<string, string | undefined>,
}; };
httpLogger.logger.info({ httpLogger.logger.info({
......
...@@ -29,6 +29,9 @@ export const featureEnvs = { ...@@ -29,6 +29,9 @@ export const featureEnvs = {
value: '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]', 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: [ zkRollup: [
{ name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' }, { name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' },
{ name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' }, { name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' },
......
...@@ -3,11 +3,26 @@ import type { Counter, HomeStats, StatsChartsSection } from 'types/api/stats'; ...@@ -3,11 +3,26 @@ import type { Counter, HomeStats, StatsChartsSection } from 'types/api/stats';
export const HOMEPAGE_STATS: HomeStats = { export const HOMEPAGE_STATS: HomeStats = {
average_block_time: 14346, average_block_time: 14346,
coin_price: '1807.68', coin_price: '1807.68',
coin_price_change_percentage: 42,
gas_prices: { gas_prices: {
average: 0.1, average: {
fast: 0.11, fiat_price: '1.01',
slow: 0.1, price: 20.41,
time: 12283,
},
fast: {
fiat_price: '1.26',
price: 25.47,
time: 9321,
},
slow: {
fiat_price: '0.97',
price: 19.55,
time: 24543,
},
}, },
gas_price_updated_at: '2022-11-11T11:09:49.051171Z',
gas_prices_update_in: 300000,
gas_used_today: '0', gas_used_today: '0',
market_cap: '0', market_cap: '0',
network_utilization_percentage: 22.56, network_utilization_percentage: 22.56,
......
...@@ -13,7 +13,7 @@ function getBg(props: StyleFunctionProps) { ...@@ -13,7 +13,7 @@ function getBg(props: StyleFunctionProps) {
const { theme, colorScheme: c } = props; const { theme, colorScheme: c } = props;
const darkBg = transparentize(`${ c }.200`, 0.16)(theme); const darkBg = transparentize(`${ c }.200`, 0.16)(theme);
return { return {
light: `colors.${ c }.100`, light: `colors.${ c }.${ c === 'red' ? '50' : '100' }`,
dark: darkBg, dark: darkBg,
}; };
} }
......
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 type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
export interface SmartContract { export interface SmartContract {
...@@ -88,6 +88,8 @@ export interface SmartContractMethodInput { ...@@ -88,6 +88,8 @@ export interface SmartContractMethodInput {
internalType?: SmartContractMethodArgType; internalType?: SmartContractMethodArgType;
name: string; name: string;
type: SmartContractMethodArgType; type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>;
fieldType?: 'native_coin';
} }
export interface SmartContractMethodOutput extends SmartContractMethodInput { export interface SmartContractMethodOutput extends SmartContractMethodInput {
...@@ -97,10 +99,10 @@ export interface SmartContractMethodOutput extends SmartContractMethodInput { ...@@ -97,10 +99,10 @@ export interface SmartContractMethodOutput extends SmartContractMethodInput {
export interface SmartContractQueryMethodReadSuccess { export interface SmartContractQueryMethodReadSuccess {
is_error: false; is_error: false;
result: { result: {
names: Array<string>; names: Array<string | [ string, Array<string> ]>;
output: Array<{ output: Array<{
type: string; type: string;
value: string; value: string | Array<unknown>;
}>; }>;
}; };
} }
......
...@@ -4,10 +4,13 @@ export type HomeStats = { ...@@ -4,10 +4,13 @@ export type HomeStats = {
total_transactions: string; total_transactions: string;
average_block_time: number; average_block_time: number;
coin_price: string | null; coin_price: string | null;
coin_price_change_percentage: number | null; // e.g -6.22
total_gas_used: string; total_gas_used: string;
transactions_today: string; transactions_today: string;
gas_used_today: string; gas_used_today: string;
gas_prices: GasPrices | null; gas_prices: GasPrices | null;
gas_price_updated_at: string | null;
gas_prices_update_in: number;
static_gas_price: string | null; static_gas_price: string | null;
market_cap: string; market_cap: string;
network_utilization_percentage: number; network_utilization_percentage: number;
...@@ -16,9 +19,15 @@ export type HomeStats = { ...@@ -16,9 +19,15 @@ export type HomeStats = {
} }
export type GasPrices = { export type GasPrices = {
average: number; average: GasPriceInfo | null;
fast: number; fast: GasPriceInfo | null;
slow: number; slow: GasPriceInfo | null;
}
export interface GasPriceInfo {
fiat_price: string | null;
price: number | null;
time: number | null;
} }
export type Counters = { export type Counters = {
......
...@@ -14,6 +14,12 @@ export type TransactionRevertReason = { ...@@ -14,6 +14,12 @@ export type TransactionRevertReason = {
type WrappedTransactionFields = 'decoded_input' | 'fee' | 'gas_limit' | 'gas_price' | 'hash' | 'max_fee_per_gas' | type WrappedTransactionFields = 'decoded_input' | 'fee' | 'gas_limit' | 'gas_price' | 'hash' | 'max_fee_per_gas' |
'max_priority_fee_per_gas' | 'method' | 'nonce' | 'raw_input' | 'to' | 'type' | 'value'; 'max_priority_fee_per_gas' | 'method' | 'nonce' | 'raw_input' | 'to' | 'type' | 'value';
export interface OpWithdrawal {
l1_transaction_hash: string;
nonce: number;
status: L2WithdrawalStatus;
}
export type Transaction = { export type Transaction = {
to: AddressParam | null; to: AddressParam | null;
created_contract: AddressParam | null; created_contract: AddressParam | null;
...@@ -54,8 +60,7 @@ export type Transaction = { ...@@ -54,8 +60,7 @@ export type Transaction = {
l1_gas_used?: string; l1_gas_used?: string;
has_error_in_internal_txs: boolean | null; has_error_in_internal_txs: boolean | null;
// optimism fields // optimism fields
op_withdrawal_status?: L2WithdrawalStatus; op_withdrawals?: Array<OpWithdrawal>;
op_l1_transaction_hash?: string;
// SUAVE fields // SUAVE fields
execution_node?: AddressParam | null; execution_node?: AddressParam | null;
allowed_peekers?: Array<string>; allowed_peekers?: Array<string>;
......
import type { ArrayElement } from 'types/utils';
export const PROVIDERS = [
'blockscout',
'none',
] as const;
export type Provider = ArrayElement<typeof PROVIDERS>;
...@@ -84,6 +84,10 @@ const SolidityscanReport = ({ className, hash }: Props) => { ...@@ -84,6 +84,10 @@ const SolidityscanReport = ({ className, hash }: Props) => {
const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500'); const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500');
const popoverBgColor = useColorModeValue('white', 'gray.900'); 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) { if (isError || !score) {
return null; return null;
} }
...@@ -91,13 +95,13 @@ const SolidityscanReport = ({ className, hash }: Props) => { ...@@ -91,13 +95,13 @@ const SolidityscanReport = ({ className, hash }: Props) => {
let scoreColor; let scoreColor;
let scoreLevel; let scoreLevel;
if (score >= 80) { if (score >= 80) {
scoreColor = 'green.600'; scoreColor = greatScoreColor;
scoreLevel = 'GREAT'; scoreLevel = 'GREAT';
} else if (score >= 30) { } else if (score >= 30) {
scoreColor = 'orange.600'; scoreColor = averageScoreColor;
scoreLevel = 'AVERAGE'; scoreLevel = 'AVERAGE';
} else { } else {
scoreColor = 'red.600'; scoreColor = lowScoreColor;
scoreLevel = 'LOW'; scoreLevel = 'LOW';
} }
...@@ -112,7 +116,6 @@ const SolidityscanReport = ({ className, hash }: Props) => { ...@@ -112,7 +116,6 @@ const SolidityscanReport = ({ className, hash }: Props) => {
<Button <Button
className={ className } className={ className }
color={ scoreColor } color={ scoreColor }
borderColor={ scoreColor }
size="sm" size="sm"
variant="outline" variant="outline"
colorScheme="gray" colorScheme="gray"
......
import { Box, Button, chakra, Flex, Text } from '@chakra-ui/react'; import { Box, Button, chakra, Flex } from '@chakra-ui/react';
import _fromPairs from 'lodash/fromPairs';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { MethodFormFields, ContractMethodCallResult } from './types'; import type { MethodFormFields, ContractMethodCallResult } from './types';
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract'; import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract';
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 IconSvg from 'ui/shared/IconSvg';
import ContractMethodField from './ContractMethodField'; import ContractMethodCallableRow from './ContractMethodCallableRow';
import { formatFieldValues, transformFieldsToArgs } from './utils';
interface ResultComponentProps<T extends SmartContractMethod> { interface ResultComponentProps<T extends SmartContractMethod> {
item: T; item: T;
...@@ -25,42 +26,9 @@ interface Props<T extends SmartContractMethod> { ...@@ -25,42 +26,9 @@ interface Props<T extends SmartContractMethod> {
isWrite?: boolean; isWrite?: boolean;
} }
const getFieldName = (name: string | undefined, index: number): string => name || String(index); // groupName%groupIndex:inputName%inputIndex
const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) =>
const sortFields = (data: Array<SmartContractMethodInput>) => ([ a ]: [string, string], [ b ]: [string, string]): 1 | -1 | 0 => { `${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`;
const fieldNames = data.map(({ name }, index) => getFieldName(name, index));
const indexA = fieldNames.indexOf(a);
const indexB = fieldNames.indexOf(b);
if (indexA > indexB) {
return 1;
}
if (indexA < indexB) {
return -1;
}
return 0;
};
const castFieldValue = (data: Array<SmartContractMethodInput>) => ([ key, value ]: [ string, string ], index: number) => {
if (data[index].type.includes('[')) {
return [ key, parseArrayValue(value) ];
}
return [ key, value ];
};
const parseArrayValue = (value: string) => {
try {
const parsedResult = JSON.parse(value);
if (Array.isArray(parsedResult)) {
return parsedResult;
}
throw new Error('Not an array');
} catch (error) {
return '';
}
};
const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => { const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => {
...@@ -71,15 +39,16 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -71,15 +39,16 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
return [ return [
...('inputs' in data ? data.inputs : []), ...('inputs' in data ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ { ...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: 'value', name: `Send native ${ config.chain.currency.symbol }`,
type: 'uint256' as const, type: 'uint256' as const,
internalType: 'uint256' as const, internalType: 'uint256' as const,
fieldType: 'native_coin' as const,
} ] : []), } ] : []),
]; ];
}, [ data ]); }, [ data ]);
const { control, handleSubmit, setValue, getValues } = useForm<MethodFormFields>({ const formApi = useForm<MethodFormFields>({
defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])), mode: 'onBlur',
}); });
const handleTxSettle = React.useCallback(() => { const handleTxSettle = React.useCallback(() => {
...@@ -91,10 +60,8 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -91,10 +60,8 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
}, [ result ]); }, [ result ]);
const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => { const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const args = Object.entries(formData) const formattedData = formatFieldValues(formData, inputs);
.sort(sortFields(inputs)) const args = transformFieldsToArgs(formattedData);
.map(castFieldValue(inputs))
.map(([ , value ]) => value);
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
...@@ -117,48 +84,99 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ...@@ -117,48 +84,99 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
return ( return (
<Box> <Box>
<chakra.form <FormProvider { ...formApi }>
noValidate <chakra.form
display="flex" noValidate
columnGap={ 3 } onSubmit={ formApi.handleSubmit(onFormSubmit) }
flexDir={{ base: 'column', lg: 'row' }} onChange={ handleFormChange }
rowGap={ 2 }
alignItems={{ base: 'flex-start', lg: 'center' }}
onSubmit={ handleSubmit(onFormSubmit) }
flexWrap="wrap"
onChange={ handleFormChange }
>
{ inputs.map(({ type, name }, index) => {
const fieldName = getFieldName(name, index);
return (
<ContractMethodField
key={ fieldName }
name={ fieldName }
valueType={ type }
placeholder={ `${ name }(${ type })` }
control={ control }
setValue={ setValue }
getValues={ getValues }
isDisabled={ isLoading }
onChange={ handleFormChange }
/>
);
}) }
<Button
isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Query' }
variant="outline"
size="sm"
flexShrink={ 0 }
type="submit"
> >
{ isWrite ? 'Write' : 'Query' } <Flex
</Button> flexDir="column"
</chakra.form> rowGap={ 3 }
mb={ 3 }
_empty={{ display: 'none' }}
>
{ inputs.map((input, index) => {
const fieldName = getFormFieldName({ name: input.name, index });
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 (
<ContractMethodCallableRow
key={ fieldName }
fieldName={ fieldName }
fieldType={ input.fieldType }
argName={ input.name }
argType={ input.type }
isDisabled={ isLoading }
isOptional={ input.fieldType === 'native_coin' && inputs.length > 1 }
onChange={ handleFormChange }
/>
);
}) }
</Flex>
<Button
isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Read' }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
>
{ isWrite ? 'Write' : 'Read' }
</Button>
</chakra.form>
</FormProvider>
{ 'outputs' in data && !isWrite && data.outputs.length > 0 && ( { 'outputs' in data && !isWrite && data.outputs.length > 0 && (
<Flex mt={ 3 }> <Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/> <IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<Text>{ data.outputs.map(({ type }) => type).join(', ') }</Text> <p>
{ data.outputs.map(({ type, name }, index) => {
return (
<>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < data.outputs.length - 1 && <span>, </span> }
</>
);
}) }
</p>
</Flex> </Flex>
) } ) }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> } { result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
......
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 { import {
Box,
FormControl, FormControl,
Input, Input,
InputGroup, InputGroup,
InputRightElement, InputRightElement,
useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import React from '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 { Controller } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import { isAddress, isHex, getAddress } from 'viem';
import type { MethodFormFields } from './types'; import type { MethodFormFields } from './types';
import type { SmartContractMethodArgType } from 'types/api/contract'; import type { SmartContractMethodArgType } from 'types/api/contract';
...@@ -14,21 +18,25 @@ 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 ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldZeroes from './ContractMethodFieldZeroes'; import ContractMethodFieldZeroes from './ContractMethodFieldZeroes';
import { addZeroesAllowed } from './utils'; import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils';
interface Props { interface Props {
name: string;
index?: number;
groupName?: string;
placeholder: string;
argType: SmartContractMethodArgType;
control: Control<MethodFormFields>; control: Control<MethodFormFields>;
setValue: UseFormSetValue<MethodFormFields>; setValue: UseFormSetValue<MethodFormFields>;
getValues: UseFormGetValues<MethodFormFields>; getValues: UseFormGetValues<MethodFormFields>;
placeholder: string;
name: string;
valueType: SmartContractMethodArgType;
isDisabled: boolean; isDisabled: boolean;
isOptional?: boolean;
onChange: () => void; 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<HTMLInputElement>(null); const ref = React.useRef<HTMLInputElement>(null);
const bgColor = useColorModeValue('white', 'black');
const handleClear = React.useCallback(() => { const handleClear = React.useCallback(() => {
setValue(name, ''); setValue(name, '');
...@@ -37,47 +45,147 @@ const ContractMethodField = ({ control, name, valueType, placeholder, setValue, ...@@ -37,47 +45,147 @@ const ContractMethodField = ({ control, name, valueType, placeholder, setValue,
}, [ name, onChange, setValue ]); }, [ name, onChange, setValue ]);
const handleAddZeroesClick = React.useCallback((power: number) => { 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 zeroes = Array(power).fill('0').join('');
const newValue = value ? value + zeroes : '1' + zeroes; const newValue = value ? value + zeroes : '1' + zeroes;
setValue(name, newValue); setValue(name, newValue);
onChange(); 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<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;
const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps<MethodFormFields> }) => {
return ( return (
<FormControl <Box w="100%">
id={ name } <FormControl
flexBasis={{ base: '100%', lg: 'calc((100% - 24px) / 3 - 65px)' }} id={ name }
w={{ base: '100%', lg: 'auto' }} isDisabled={ isDisabled }
flexGrow={ 1 } >
isDisabled={ isDisabled } <InputGroup size="xs">
> <Input
<InputGroup size="xs"> { ...field }
<Input { ...(intMatch ? {
{ ...field } as: NumericFormat,
ref={ ref } thousandSeparator: ' ',
placeholder={ placeholder } decimalScale: 0,
paddingRight={ hasZerosControl ? '120px' : '40px' } allowNegative: !intMatch.isUnsigned,
/> } : {}) }
<InputRightElement w="auto" right={ 1 }> ref={ ref }
{ field.value && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> } isInvalid={ Boolean(error) }
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> } required={ !isOptional }
</InputRightElement> placeholder={ placeholder }
</InputGroup> paddingRight={ hasZerosControl ? '120px' : '40px' }
</FormControl> 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>
); );
}, [ name, isDisabled, placeholder, hasZerosControl, handleClear, handleAddZeroesClick ]); }, [ 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 ( return (
<Controller <Controller
name={ name } name={ name }
control={ control } control={ control }
render={ renderInput } render={ renderInput }
rules={{ required: isOptional ? false : 'Field is required', validate }}
/> />
); );
}; };
......
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);
...@@ -11,9 +11,10 @@ interface Props<T extends SmartContractMethod> { ...@@ -11,9 +11,10 @@ interface Props<T extends SmartContractMethod> {
data: Array<T>; data: Array<T>;
addressHash?: string; addressHash?: string;
renderItemContent: (item: T, index: number, id: number) => React.ReactNode; renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string;
} }
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent }: Props<T>) => { const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []); const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
const [ id, setId ] = React.useState(0); const [ id, setId ] = React.useState(0);
...@@ -79,6 +80,7 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address ...@@ -79,6 +80,7 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
index={ index } index={ index }
addressHash={ addressHash } addressHash={ addressHash }
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode } renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
tab={ tab }
/> />
)) } )) }
</Accordion> </Accordion>
......
...@@ -16,9 +16,10 @@ interface Props<T extends SmartContractMethod> { ...@@ -16,9 +16,10 @@ interface Props<T extends SmartContractMethod> {
id: number; id: number;
addressHash?: string; addressHash?: string;
renderContent: (item: T, index: number, id: number) => React.ReactNode; renderContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string;
} }
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent }: Props<T>) => { const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => {
const url = React.useMemo(() => { const url = React.useMemo(() => {
if (!('method_id' in data)) { if (!('method_id' in data)) {
return ''; return '';
...@@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
pathname: '/address/[hash]', pathname: '/address/[hash]',
query: { query: {
hash: addressHash ?? '', hash: addressHash ?? '',
tab: 'read_contract', tab,
}, },
hash: data.method_id, hash: data.method_id,
}); });
}, [ addressHash, data ]); }, [ addressHash, data, tab ]);
const { hasCopied, onCopy } = useClipboard(url, 1000); const { hasCopied, onCopy } = useClipboard(url, 1000);
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
...@@ -85,7 +86,7 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -85,7 +86,7 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
<AccordionIcon/> <AccordionIcon/>
</AccordionButton> </AccordionButton>
</Element> </Element>
<AccordionPanel pb={ 4 } px={ 0 }> <AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) } { renderContent(data, index, id) }
</AccordionPanel> </AccordionPanel>
</AccordionItem> </AccordionItem>
......
...@@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<ContractRead addressHash={ addressHash }/> <ContractRead/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
...@@ -37,8 +37,8 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -37,8 +37,8 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
await component.getByPlaceholder(/address/i).type('address-hash'); await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466');
await component.getByText(/query/i).click(); await component.getByText(/read/i).click();
await component.getByText(/wei/i).click(); await component.getByText(/wei/i).click();
......
import { Alert, Flex } from '@chakra-ui/react'; import { Alert, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract'; import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract';
import useApiFetch from 'lib/api/useApiFetch'; import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
...@@ -17,15 +19,15 @@ import ContractMethodConstant from './ContractMethodConstant'; ...@@ -17,15 +19,15 @@ import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult'; import ContractReadResult from './ContractReadResult';
import useWatchAccount from './useWatchAccount'; import useWatchAccount from './useWatchAccount';
interface Props { const ContractRead = () => {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => {
const apiFetch = useApiFetch(); const apiFetch = useApiFetch();
const account = useWatchAccount(); 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', { const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
...@@ -96,7 +98,7 @@ const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => { ...@@ -96,7 +98,7 @@ const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => {
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
{ account && <ContractConnectWallet/> } { account && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/> <ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
</> </>
); );
}; };
......
...@@ -99,3 +99,34 @@ test('success', async({ mount }) => { ...@@ -99,3 +99,34 @@ test('success', async({ mount }) => {
await expect(component).toHaveScreenshot(); 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(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
...@@ -2,17 +2,55 @@ import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react'; ...@@ -2,17 +2,55 @@ import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { ContractMethodReadResult } from './types'; 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'; import hexToUtf8 from 'lib/hexToUtf8';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
const ContractReadResultError = ({ children }: {children: React.ReactNode}) => { const ContractReadResultError = ({ children }: {children: React.ReactNode}) => {
return ( return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap"> <Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children } { children }
</Alert> </Alert>
); );
};
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 (
<>
<p>
<chakra.span fontWeight={ 500 }> { structName }</chakra.span>
<span> ({ output.type }) :</span>
</p>
{ argNames.map((argName, argIndex) => {
return (
<p key={ argName }>
<chakra.span fontWeight={ 500 }> { argName }</chakra.span>
<span>{ argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) }</span>
</p>
);
}) }
</>
);
}
return (
<p>
<span> </span>
{ name && <chakra.span fontWeight={ 500 }>{ name } </chakra.span> }
<span>({ output.type }) : { String(output.value) }</span>
</p>
);
}; };
interface Props { interface Props {
...@@ -53,14 +91,12 @@ const ContractReadResult = ({ item, result, onSettle }: Props) => { ...@@ -53,14 +91,12 @@ const ContractReadResult = ({ item, result, onSettle }: Props) => {
} }
return ( return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm"> <Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p> <p>
[ <chakra.span fontWeight={ 600 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ] [ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p> </p>
<p>[</p> <p>[</p>
{ result.result.output.map(({ type, value }, index) => ( { result.result.output.map((output, index) => <ContractReadResultItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<chakra.p key={ index } whiteSpace="break-spaces" wordBreak="break-all"> { type }: { String(value) }</chakra.p>
)) }
<p>]</p> <p>]</p>
</Box> </Box>
); );
......
...@@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => { ...@@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<ContractWrite addressHash={ addressHash }/> <ContractWrite/>
</TestApp>, </TestApp>,
{ hooksConfig }, { hooksConfig },
); );
......
import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { useAccount, useWalletClient, useNetwork, useSwitchNetwork } from 'wagmi'; import { useAccount, useWalletClient, useNetwork, useSwitchNetwork } from 'wagmi';
...@@ -5,6 +6,7 @@ import type { SmartContractWriteMethod } from 'types/api/contract'; ...@@ -5,6 +6,7 @@ import type { SmartContractWriteMethod } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
...@@ -17,18 +19,19 @@ import ContractWriteResult from './ContractWriteResult'; ...@@ -17,18 +19,19 @@ import ContractWriteResult from './ContractWriteResult';
import useContractAbi from './useContractAbi'; import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils'; import { getNativeCoinValue, prepareAbi } from './utils';
interface Props { const ContractWrite = () => {
addressHash?: string;
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
const { data: walletClient } = useWalletClient(); const { data: walletClient } = useWalletClient();
const { isConnected } = useAccount(); const { isConnected } = useAccount();
const { chain } = useNetwork(); const { chain } = useNetwork();
const { switchNetworkAsync } = useSwitchNetwork(); 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', { const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
queryParams: { queryParams: {
...@@ -112,7 +115,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => { ...@@ -112,7 +115,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/> <ContractConnectWallet/>
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/> <ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
</> </>
); );
}; };
......
...@@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { ...@@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
return ( return (
<Box <Box
fontSize="sm" fontSize="sm"
pl={ 3 }
mt={ 3 } mt={ 3 }
alignItems="center" alignItems="center"
whiteSpace="pre-wrap" whiteSpace="pre-wrap"
......
...@@ -2,7 +2,10 @@ import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/ap ...@@ -2,7 +2,10 @@ import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/ap
import type { ResourceError } from 'lib/api/resources'; import type { ResourceError } from 'lib/api/resources';
export type MethodFormFields = Record<string, string>; export type MethodFormFields = Record<string, string | Array<string>>;
export type MethodFormFieldsFormatted = Record<string, MethodArgType>;
export type MethodArgType = string | boolean | Array<MethodArgType>;
export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError; export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError;
......
import { prepareAbi } from './utils'; import type { SmartContractMethodInput } from 'types/api/contract';
import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils';
describe('function prepareAbi()', () => { describe('function prepareAbi()', () => {
const commonAbi = [ const commonAbi = [
...@@ -98,3 +100,100 @@ describe('function prepareAbi()', () => { ...@@ -98,3 +100,100 @@ 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 { 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<unknown>) => { export const INT_REGEXP = /^(u)?int(\d+)?$/i;
const _value = Array.isArray(value) ? value[0] : value;
if (typeof _value !== 'string') { export const BYTES_REGEXP = /^bytes(\d+)?$/i;
return BigInt(0);
}
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) => { export const formatBooleanValue = (value: string) => {
if (valueType.includes('[')) { const formattedValue = value.toLowerCase();
return false;
} 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); export const getNativeCoinValue = (value: string | Array<unknown>) => {
const power = match?.[1]; const _value = Array.isArray(value) ? value[0] : value;
if (power) { if (typeof _value !== 'string') {
// show control for all inputs which allows to insert 10^18 or greater numbers return BigInt(0);
return Number(power) >= 64;
} }
return false; return BigInt(_value);
}; };
interface ExtendedError extends Error { interface ExtendedError extends Error {
...@@ -75,3 +91,106 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi { ...@@ -75,3 +91,106 @@ 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;
if (isNestedArray) {
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;
}
...@@ -5,6 +5,7 @@ import React from 'react'; ...@@ -5,6 +5,7 @@ import React from 'react';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import TokenEntity from 'ui/shared/entities/token/TokenEntity'; import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import LinkInternal from 'ui/shared/LinkInternal';
import TruncatedValue from 'ui/shared/TruncatedValue'; import TruncatedValue from 'ui/shared/TruncatedValue';
import type { TokenEnhancedData } from '../utils/tokenUtils'; import type { TokenEnhancedData } from '../utils/tokenUtils';
...@@ -47,11 +48,10 @@ const TokenSelectItem = ({ data }: Props) => { ...@@ -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 } }); const url = route({ pathname: '/token/[hash]', query: { hash: data.token.address } });
return ( return (
<Flex <LinkInternal
px={ 1 } px={ 1 }
py="10px" py="10px"
display="flex" display="flex"
...@@ -62,9 +62,8 @@ const TokenSelectItem = ({ data }: Props) => { ...@@ -62,9 +62,8 @@ const TokenSelectItem = ({ data }: Props) => {
_hover={{ _hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'), bgColor: useColorModeValue('blue.50', 'gray.800'),
}} }}
color="initial"
fontSize="sm" fontSize="sm"
cursor="pointer"
as="a"
href={ url } href={ url }
> >
<Flex alignItems="center" w="100%" overflow="hidden"> <Flex alignItems="center" w="100%" overflow="hidden">
...@@ -80,7 +79,7 @@ const TokenSelectItem = ({ data }: Props) => { ...@@ -80,7 +79,7 @@ const TokenSelectItem = ({ data }: Props) => {
<Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap"> <Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap">
{ secondRow } { secondRow }
</Flex> </Flex>
</Flex> </LinkInternal>
); );
}; };
......
...@@ -14,6 +14,11 @@ interface Props { ...@@ -14,6 +14,11 @@ interface Props {
const BlocksTabSlot = ({ pagination }: Props) => { const BlocksTabSlot = ({ pagination }: Props) => {
const statsQuery = useApiQuery('homepage_stats', { const statsQuery = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
}, },
......
...@@ -20,18 +20,19 @@ const GraphQL = () => { ...@@ -20,18 +20,19 @@ const GraphQL = () => {
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const graphqlTheme = window.localStorage.getItem('graphiql:theme');
// colorModeState used as a key to re-render GraphiQL conponent after color mode change // 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(() => { React.useEffect(() => {
if (isBrowser()) { if (isBrowser()) {
const graphqlTheme = window.localStorage.getItem('graphiql:theme');
if (graphqlTheme !== colorMode) { if (graphqlTheme !== colorMode) {
window.localStorage.setItem('graphiql:theme', colorMode); window.localStorage.setItem('graphiql:theme', colorMode);
setColorModeState(colorMode); setColorModeState(colorMode);
} }
} }
}, [ colorMode ]); }, [ colorMode, graphqlTheme ]);
if (!feature.isEnabled) { if (!feature.isEnabled) {
return null; return null;
......
...@@ -37,7 +37,13 @@ const LatestBlocks = () => { ...@@ -37,7 +37,13 @@ const LatestBlocks = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const statsQueryResult = useApiQuery('homepage_stats', { const statsQueryResult = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
refetchOnMount: false,
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
}, },
}); });
......
...@@ -16,8 +16,14 @@ const hasGasTracker = config.UI.homepage.showGasTracker; ...@@ -16,8 +16,14 @@ const hasGasTracker = config.UI.homepage.showGasTracker;
const hasAvgBlockTime = config.UI.homepage.showAvgBlockTime; const hasAvgBlockTime = config.UI.homepage.showAvgBlockTime;
const Stats = () => { const Stats = () => {
const { data, isPlaceholderData, isError } = useApiQuery('homepage_stats', { const { data, isPlaceholderData, isError, dataUpdatedAt } = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
refetchOnMount: false,
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
}, },
}); });
...@@ -45,7 +51,19 @@ const Stats = () => { ...@@ -45,7 +51,19 @@ const Stats = () => {
!data.gas_prices && itemsCount--; !data.gas_prices && itemsCount--;
data.rootstock_locked_btc && itemsCount++; data.rootstock_locked_btc && itemsCount++;
const isOdd = Boolean(itemsCount % 2); const isOdd = Boolean(itemsCount % 2);
const gasLabel = hasGasTracker && data.gas_prices ? <GasInfoTooltipContent gasPrices={ data.gas_prices }/> : null; const gasLabel = hasGasTracker && data.gas_prices ? <GasInfoTooltipContent data={ data } dataUpdatedAt={ dataUpdatedAt }/> : null;
const gasPriceText = (() => {
if (data.gas_prices?.average?.fiat_price) {
return `$${ data.gas_prices.average.fiat_price }`;
}
if (data.gas_prices?.average?.price) {
return `${ data.gas_prices.average.price.toLocaleString() } Gwei`;
}
return 'N/A';
})();
content = ( content = (
<> <>
...@@ -92,7 +110,7 @@ const Stats = () => { ...@@ -92,7 +110,7 @@ const Stats = () => {
<StatsItem <StatsItem
icon="gas" icon="gas"
title="Gas tracker" title="Gas tracker"
value={ `${ Number(data.gas_prices.average).toLocaleString() } Gwei` } value={ gasPriceText }
_last={ isOdd ? lastItemTouchStyle : undefined } _last={ isOdd ? lastItemTouchStyle : undefined }
tooltipLabel={ gasLabel } tooltipLabel={ gasLabel }
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
......
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { HOMEPAGE_STATS } from 'stubs/stats';
import Hint from 'ui/shared/Hint'; import Hint from 'ui/shared/Hint';
import ChainIndicatorChartContainer from './ChainIndicatorChartContainer'; import ChainIndicatorChartContainer from './ChainIndicatorChartContainer';
...@@ -29,7 +30,17 @@ const ChainIndicators = () => { ...@@ -29,7 +30,17 @@ const ChainIndicators = () => {
const indicator = indicators.find(({ id }) => id === selectedIndicator); const indicator = indicators.find(({ id }) => id === selectedIndicator);
const queryResult = useFetchChartData(indicator); const queryResult = useFetchChartData(indicator);
const statsQueryResult = useApiQuery('homepage_stats'); const statsQueryResult = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: {
refetchOnMount: false,
placeholderData: HOMEPAGE_STATS,
},
});
const bgColorDesktop = useColorModeValue('white', 'gray.900'); const bgColorDesktop = useColorModeValue('white', 'gray.900');
const bgColorMobile = useColorModeValue('white', 'black'); const bgColorMobile = useColorModeValue('white', 'black');
......
...@@ -4,6 +4,7 @@ import React, { useCallback } from 'react'; ...@@ -4,6 +4,7 @@ import React, { useCallback } from 'react';
import type { MarketplaceAppPreview } from 'types/client/marketplace'; import type { MarketplaceAppPreview } from 'types/client/marketplace';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import MarketplaceAppCardLink from './MarketplaceAppCardLink'; import MarketplaceAppCardLink from './MarketplaceAppCardLink';
...@@ -43,6 +44,7 @@ const MarketplaceAppCard = ({ ...@@ -43,6 +44,7 @@ const MarketplaceAppCard = ({
const handleInfoClick = useCallback((event: MouseEvent) => { const handleInfoClick = useCallback((event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'More button', Info: id });
onInfoClick(id); onInfoClick(id);
}, [ onInfoClick, id ]); }, [ onInfoClick, id ]);
......
...@@ -6,6 +6,7 @@ import React from 'react'; ...@@ -6,6 +6,7 @@ import React from 'react';
import { MarketplaceCategory } from 'types/client/marketplace'; import { MarketplaceCategory } from 'types/client/marketplace';
import useDebounce from 'lib/hooks/useDebounce'; import useDebounce from 'lib/hooks/useDebounce';
import * as mixpanel from 'lib/mixpanel/index';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import useMarketplaceApps from './useMarketplaceApps'; import useMarketplaceApps from './useMarketplaceApps';
...@@ -33,6 +34,8 @@ export default function useMarketplace() { ...@@ -33,6 +34,8 @@ export default function useMarketplace() {
const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false); const [ isDisclaimerModalOpen, setIsDisclaimerModalOpen ] = React.useState<boolean>(false);
const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => { const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => {
mixpanel.logEvent(mixpanel.EventTypes.PAGE_WIDGET, { Type: 'Favorite app', Info: id });
const favoriteApps = getFavoriteApps(); const favoriteApps = getFavoriteApps();
if (isFavorite) { if (isFavorite) {
...@@ -64,6 +67,7 @@ export default function useMarketplace() { ...@@ -64,6 +67,7 @@ export default function useMarketplace() {
}, []); }, []);
const handleCategoryChange = React.useCallback((newCategory: string) => { const handleCategoryChange = React.useCallback((newCategory: string) => {
mixpanel.logEvent(mixpanel.EventTypes.FILTERS, { Source: 'Marketplace', 'Filter name': newCategory });
setSelectedCategoryId(newCategory); setSelectedCategoryId(newCategory);
}, []); }, []);
...@@ -91,6 +95,11 @@ export default function useMarketplace() { ...@@ -91,6 +95,11 @@ export default function useMarketplace() {
category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId, category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId,
filter: debouncedFilterQuery, filter: debouncedFilterQuery,
}, Boolean); }, Boolean);
if (debouncedFilterQuery.length > 0) {
mixpanel.logEvent(mixpanel.EventTypes.LOCAL_SEARCH, { Source: 'Marketplace', 'Search query': debouncedFilterQuery });
}
router.replace( router.replace(
{ pathname: '/apps', query }, { pathname: '/apps', query },
undefined, undefined,
......
import { GridItem, chakra } from '@chakra-ui/react';
import React from 'react';
import type { GasPriceInfo } from 'types/api/stats';
import { asymp, space } from 'lib/html-entities';
interface Props {
name: string;
info: GasPriceInfo | null;
}
const GasInfoRow = ({ name, info }: Props) => {
const content = (() => {
if (!info || info.price === null) {
return 'N/A';
}
return (
<>
<span>{ info.fiat_price ? `$${ info.fiat_price }` : `${ info.price } Gwei` }</span>
{ info.time && (
<chakra.span color="text_secondary">
{ space }per tx { asymp } { (info.time / 1000).toLocaleString(undefined, { maximumFractionDigits: 1 }) }s
</chakra.span>
) }
</>
);
})();
return (
<>
<GridItem color="blue.100">{ name }</GridItem>
<GridItem color="text" textAlign="right">{ content }</GridItem>
</>
);
};
export default React.memo(GasInfoRow);
import { Grid, GridItem, useColorModeValue } from '@chakra-ui/react'; import { DarkMode, Grid, GridItem } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { GasPrices } from 'types/api/stats'; import type { HomeStats } from 'types/api/stats';
const GasInfoTooltipContent = ({ gasPrices }: {gasPrices: GasPrices}) => { import dayjs from 'lib/date/dayjs';
const nameStyleProps = {
color: useColorModeValue('blue.100', 'blue.600'), import GasInfoRow from './GasInfoRow';
}; import GasInfoUpdateTimer from './GasInfoUpdateTimer';
interface Props {
data: HomeStats;
dataUpdatedAt: number;
}
const GasInfoTooltipContent = ({ data, dataUpdatedAt }: Props) => {
if (!data.gas_prices) {
return null;
}
return ( return (
<Grid templateColumns="repeat(2, max-content)" rowGap={ 2 } columnGap={ 4 } padding={ 4 } fontSize="xs"> <DarkMode>
<GridItem { ...nameStyleProps }>Slow</GridItem> <Grid templateColumns="repeat(2, max-content)" rowGap={ 2 } columnGap={ 4 } padding={ 4 } fontSize="xs" lineHeight={ 4 }>
<GridItem>{ `${ gasPrices.slow } Gwei` }</GridItem> { data.gas_price_updated_at && (
<GridItem { ...nameStyleProps }>Average</GridItem> <>
<GridItem>{ `${ gasPrices.average } Gwei` }</GridItem> <GridItem color="text_secondary">Last update</GridItem>
<GridItem { ...nameStyleProps }>Fast</GridItem> <GridItem color="text_secondary" display="flex" justifyContent="flex-end" columnGap={ 2 }>
<GridItem>{ `${ gasPrices.fast } Gwei` }</GridItem> { dayjs(data.gas_price_updated_at).format('MMM DD, HH:mm:ss') }
</Grid> { data.gas_prices_update_in !== 0 &&
<GasInfoUpdateTimer key={ dataUpdatedAt } startTime={ dataUpdatedAt } duration={ data.gas_prices_update_in }/> }
</GridItem>
</>
) }
<GasInfoRow name="Slow" info={ data.gas_prices.slow }/>
<GasInfoRow name="Normal" info={ data.gas_prices.average }/>
<GasInfoRow name="Fast" info={ data.gas_prices.fast }/>
</Grid>
</DarkMode>
); );
}; };
......
import { CircularProgress } from '@chakra-ui/react';
import React from 'react';
import dayjs from 'lib/date/dayjs';
interface Props {
startTime: number;
duration: number;
}
const getValue = (startDate: dayjs.Dayjs, duration: number) => {
const now = dayjs();
const diff = now.diff(startDate, 'ms');
const value = diff / duration * 100;
if (value >= 99) {
return 99;
}
return value;
};
const GasInfoUpdateTimer = ({ startTime, duration }: Props) => {
const [ value, setValue ] = React.useState(getValue(dayjs(startTime), duration));
React.useEffect(() => {
const startDate = dayjs(startTime);
const intervalId = window.setInterval(() => {
const nextValue = getValue(startDate, duration);
setValue(nextValue);
if (nextValue === 99) {
window.clearInterval(intervalId);
}
}, 100);
return () => {
window.clearInterval(intervalId);
};
}, [ startTime, duration ]);
return <CircularProgress value={ value } trackColor="whiteAlpha.100" size={ 4 }/>;
};
export default React.memo(GasInfoUpdateTimer);
...@@ -5,7 +5,6 @@ import NextLink from 'next/link'; ...@@ -5,7 +5,6 @@ import NextLink from 'next/link';
import type { LegacyRef } from 'react'; import type { LegacyRef } from 'react';
import React 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<HTMLAnchorElement>) => { const LinkInternal = ({ isLoading, ...props }: LinkProps & { isLoading?: boolean }, ref: LegacyRef<HTMLAnchorElement>) => {
if (isLoading) { if (isLoading) {
return <Flex alignItems="center" { ...props as FlexProps }>{ props.children }</Flex>; return <Flex alignItems="center" { ...props as FlexProps }>{ props.children }</Flex>;
......
...@@ -11,6 +11,8 @@ import React, { useEffect, useRef, useState } from 'react'; ...@@ -11,6 +11,8 @@ import React, { useEffect, useRef, useState } from 'react';
import type { TabItem } from './types'; import type { TabItem } from './types';
import isBrowser from 'lib/isBrowser';
import AdaptiveTabsList from './AdaptiveTabsList'; import AdaptiveTabsList from './AdaptiveTabsList';
import { menuButton } from './utils'; import { menuButton } from './utils';
...@@ -39,7 +41,7 @@ const TabsWithScroll = ({ ...@@ -39,7 +41,7 @@ const TabsWithScroll = ({
...themeProps ...themeProps
}: Props) => { }: Props) => {
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(defaultTabIndex || 0); const [ activeTabIndex, setActiveTabIndex ] = useState<number>(defaultTabIndex || 0);
const [ screenWidth, setScreenWidth ] = React.useState(0); const [ screenWidth, setScreenWidth ] = React.useState(isBrowser() ? window.innerWidth : 0);
const tabsRef = useRef<HTMLDivElement>(null); const tabsRef = useRef<HTMLDivElement>(null);
......
...@@ -55,7 +55,6 @@ const TokenTransferFilter = ({ ...@@ -55,7 +55,6 @@ const TokenTransferFilter = ({
</RadioGroup> </RadioGroup>
</> </>
) } ) }
<Text variant="secondary" fontWeight={ 600 }>Type</Text>
<TokenTypeFilter<TokenType> onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters } nftOnly={ false }/> <TokenTypeFilter<TokenType> onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters } nftOnly={ false }/>
</PopoverFilter> </PopoverFilter>
); );
......
...@@ -3,7 +3,6 @@ import React from 'react'; ...@@ -3,7 +3,6 @@ import React from 'react';
import type { Props } from './types'; import type { Props } from './types';
import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary'; import AppErrorBoundary from 'ui/shared/AppError/AppErrorBoundary';
import HeaderAlert from 'ui/snippets/header/HeaderAlert';
import HeaderDesktop from 'ui/snippets/header/HeaderDesktop'; import HeaderDesktop from 'ui/snippets/header/HeaderDesktop';
import HeaderMobile from 'ui/snippets/header/HeaderMobile'; import HeaderMobile from 'ui/snippets/header/HeaderMobile';
...@@ -19,7 +18,6 @@ const LayoutDefault = ({ children }: Props) => { ...@@ -19,7 +18,6 @@ const LayoutDefault = ({ children }: Props) => {
paddingTop={{ base: 16, lg: 6 }} paddingTop={{ base: 16, lg: 6 }}
paddingX={{ base: 4, lg: 6 }} paddingX={{ base: 4, lg: 6 }}
> >
<HeaderAlert/>
<HeaderDesktop isMarketplaceAppPage/> <HeaderDesktop isMarketplaceAppPage/>
<AppErrorBoundary> <AppErrorBoundary>
<Layout.Content pt={{ base: 0, lg: 6 }}> <Layout.Content pt={{ base: 0, lg: 6 }}>
......
import { Text, HStack } from '@chakra-ui/react'; import { HStack, Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { Step } from './types'; import type { Step } from './types';
...@@ -17,7 +17,7 @@ const VerificationStep = ({ step, isLast, isPassed }: Props) => { ...@@ -17,7 +17,7 @@ const VerificationStep = ({ step, isLast, isPassed }: Props) => {
return ( return (
<HStack gap={ 2 } color={ stepColor }> <HStack gap={ 2 } color={ stepColor }>
<IconSvg name={ isPassed ? 'finalized' : 'unfinalized' } boxSize={ 5 }/> <IconSvg name={ isPassed ? 'finalized' : 'unfinalized' } boxSize={ 5 }/>
<Text color={ stepColor }>{ typeof step === 'string' ? step : step.content }</Text> <Box color={ stepColor }>{ typeof step === 'string' ? step : step.content }</Box>
{ !isLast && <IconSvg name="arrows/east" boxSize={ 5 }/> } { !isLast && <IconSvg name="arrows/east" boxSize={ 5 }/> }
</HStack> </HStack>
); );
......
...@@ -30,7 +30,7 @@ const VerificationSteps = ({ currentStep, steps, isLoading, rightSlot, className ...@@ -30,7 +30,7 @@ const VerificationSteps = ({ currentStep, steps, isLoading, rightSlot, className
> >
{ steps.map((step, index) => ( { steps.map((step, index) => (
<VerificationStep <VerificationStep
key={ currentStep } key={ index }
step={ step } step={ step }
isLast={ index === steps.length - 1 && !rightSlot } isLast={ index === steps.length - 1 && !rightSlot }
isPassed={ index <= currentStepIndex } isPassed={ index <= currentStepIndex }
......
...@@ -35,6 +35,7 @@ const ColorModeSwitch = () => { ...@@ -35,6 +35,7 @@ const ColorModeSwitch = () => {
window.document.documentElement.style.setProperty(varName, hex); window.document.documentElement.style.setProperty(varName, hex);
cookies.set(cookies.NAMES.COLOR_MODE_HEX, hex); cookies.set(cookies.NAMES.COLOR_MODE_HEX, hex);
window.localStorage.setItem(cookies.NAMES.COLOR_MODE, nextTheme.colorMode);
}, [ setColorMode ]); }, [ setColorMode ]);
React.useEffect(() => { React.useEffect(() => {
......
...@@ -19,7 +19,7 @@ test('default view +@dark-mode +@mobile', async({ mount, page }) => { ...@@ -19,7 +19,7 @@ test('default view +@dark-mode +@mobile', async({ mount, page }) => {
</TestApp>, </TestApp>,
); );
await component.getByText(/gwei/i).hover(); await component.getByText(/\$1\.01/).hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 220 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1500, height: 220 } });
await component.getByLabel('color mode switch').click(); await component.getByLabel('color mode switch').click();
......
import { Flex, LightMode, Link, Skeleton, Tooltip, chakra, useDisclosure } from '@chakra-ui/react'; import { Flex, Link, Skeleton, Tooltip, chakra, useDisclosure } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import dayjs from 'lib/date/dayjs';
import { HOMEPAGE_STATS } from 'stubs/stats'; import { HOMEPAGE_STATS } from 'stubs/stats';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent'; import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
...@@ -16,13 +17,39 @@ const TopBarStats = () => { ...@@ -16,13 +17,39 @@ const TopBarStats = () => {
onToggle(); onToggle();
}, [ onToggle ]); }, [ onToggle ]);
const { data, isPlaceholderData, isError } = useApiQuery('homepage_stats', { const { data, isPlaceholderData, isError, refetch, dataUpdatedAt } = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: { queryOptions: {
placeholderData: HOMEPAGE_STATS, placeholderData: HOMEPAGE_STATS,
refetchOnMount: false, refetchOnMount: false,
}, },
}); });
React.useEffect(() => {
if (isPlaceholderData || !data?.gas_price_updated_at) {
return;
}
const endDate = dayjs(dataUpdatedAt).add(data.gas_prices_update_in, 'ms');
const timeout = endDate.diff(dayjs(), 'ms');
if (timeout <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
refetch();
}, timeout);
return () => {
window.clearTimeout(timeoutId);
};
}, [ isPlaceholderData, data?.gas_price_updated_at, dataUpdatedAt, data?.gas_prices_update_in, refetch ]);
if (isError) { if (isError) {
return <div/>; return <div/>;
} }
...@@ -34,35 +61,42 @@ const TopBarStats = () => { ...@@ -34,35 +61,42 @@ const TopBarStats = () => {
fontWeight={ 500 } fontWeight={ 500 }
> >
{ data?.coin_price && ( { data?.coin_price && (
<Skeleton isLoaded={ !isPlaceholderData }> <Flex columnGap={ 1 }>
<chakra.span color="text_secondary">{ config.chain.governanceToken.symbol || config.chain.currency.symbol } </chakra.span> <Skeleton isLoaded={ !isPlaceholderData }>
<span>${ Number(data.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }) }</span> <chakra.span color="text_secondary">{ config.chain.governanceToken.symbol || config.chain.currency.symbol } </chakra.span>
</Skeleton> <span>${ Number(data.coin_price).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 }) }</span>
</Skeleton>
{ data.coin_price_change_percentage && (
<Skeleton isLoaded={ !isPlaceholderData }>
<chakra.span color={ Number(data.coin_price_change_percentage) >= 0 ? 'green.500' : 'red.500' }>
{ Number(data.coin_price_change_percentage).toFixed(2) }%
</chakra.span>
</Skeleton>
) }
</Flex>
) } ) }
{ data?.coin_price && config.UI.homepage.showGasTracker && <TextSeparator color="divider"/> } { data?.coin_price && config.UI.homepage.showGasTracker && <TextSeparator color="divider"/> }
{ data?.gas_prices && config.UI.homepage.showGasTracker && ( { data?.gas_prices && data.gas_prices.average !== null && config.UI.homepage.showGasTracker && (
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
<chakra.span color="text_secondary">Gas </chakra.span> <chakra.span color="text_secondary">Gas </chakra.span>
<LightMode> <Tooltip
<Tooltip label={ <GasInfoTooltipContent data={ data } dataUpdatedAt={ dataUpdatedAt }/> }
label={ <GasInfoTooltipContent gasPrices={ data.gas_prices }/> } hasArrow={ false }
hasArrow={ false } borderRadius="md"
borderRadius="md" offset={ [ 0, 16 ] }
offset={ [ 0, 16 ] } bgColor="blackAlpha.900"
bgColor="blackAlpha.900" p={ 0 }
p={ 0 } isOpen={ isOpen }
isOpen={ isOpen } >
<Link
_hover={{ textDecoration: 'none', color: 'link_hovered' }}
onClick={ handleClick }
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
> >
<Link { data.gas_prices.average.fiat_price ? `$${ data.gas_prices.average.fiat_price }` : `${ data.gas_prices.average.price } Gwei` }
_hover={{ textDecoration: 'none', color: 'link_hovered' }} </Link>
onClick={ handleClick } </Tooltip>
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
>
{ data.gas_prices.average } Gwei
</Link>
</Tooltip>
</LightMode>
</Skeleton> </Skeleton>
) } ) }
</Flex> </Flex>
......
import { Box, Button, Text } from '@chakra-ui/react'; import { Box, Button, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import * as mixpanel from 'lib/mixpanel/index';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps'; import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
...@@ -9,38 +10,45 @@ type Props = { ...@@ -9,38 +10,45 @@ type Props = {
disconnect?: () => void; disconnect?: () => void;
}; };
const WalletMenuContent = ({ address, disconnect }: Props) => ( const WalletMenuContent = ({ address, disconnect }: Props) => {
<Box> const onAddressClick = React.useCallback(() => {
<Text mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Address click' });
fontSize="sm" }, []);
fontWeight={ 600 }
mb={ 1 } return (
{ ...getDefaultTransitionProps() } <Box>
> <Text
My wallet fontSize="sm"
</Text> fontWeight={ 600 }
<Text mb={ 1 }
fontSize="sm" { ...getDefaultTransitionProps() }
mb={ 5 } >
fontWeight={ 400 } My wallet
color="text_secondary" </Text>
{ ...getDefaultTransitionProps() } <Text
> fontSize="sm"
Your wallet is used to interact with apps and contracts in the explorer. mb={ 5 }
</Text> fontWeight={ 400 }
<AddressEntity color="text_secondary"
address={{ hash: address }} { ...getDefaultTransitionProps() }
noTooltip >
truncation="dynamic" Your wallet is used to interact with apps and contracts in the explorer.
fontSize="sm" </Text>
fontWeight={ 700 } <AddressEntity
color="text" address={{ hash: address }}
mb={ 6 } noTooltip
/> truncation="dynamic"
<Button size="sm" width="full" variant="outline" onClick={ disconnect }> fontSize="sm"
Disconnect fontWeight={ 700 }
</Button> color="text"
</Box> mb={ 6 }
); onClick={ onAddressClick }
/>
<Button size="sm" width="full" variant="outline" onClick={ disconnect }>
Disconnect
</Button>
</Box>
);
};
export default WalletMenuContent; export default WalletMenuContent;
...@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button, Box, useB ...@@ -3,6 +3,7 @@ import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button, Box, useB
import React from 'react'; import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon'; import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon';
import HashStringShorten from 'ui/shared/HashStringShorten'; import HashStringShorten from 'ui/shared/HashStringShorten';
import useWallet from 'ui/snippets/walletMenu/useWallet'; import useWallet from 'ui/snippets/walletMenu/useWallet';
...@@ -48,6 +49,11 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => { ...@@ -48,6 +49,11 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => {
}; };
} }
const openPopover = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Open' });
setIsPopoverOpen.on();
}, [ setIsPopoverOpen ]);
return ( return (
<Popover <Popover
openDelay={ 300 } openDelay={ 300 }
...@@ -66,7 +72,7 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => { ...@@ -66,7 +72,7 @@ const WalletMenuDesktop = ({ isHomePage }: Props) => {
flexShrink={ 0 } flexShrink={ 0 }
isLoading={ isModalOpening || isModalOpen } isLoading={ isModalOpening || isModalOpen }
loadingText="Connect wallet" loadingText="Connect wallet"
onClick={ isWalletConnected ? setIsPopoverOpen.on : connect } onClick={ isWalletConnected ? openPopover : connect }
fontSize="sm" fontSize="sm"
{ ...buttonStyles } { ...buttonStyles }
> >
......
...@@ -2,6 +2,7 @@ import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconBu ...@@ -2,6 +2,7 @@ import { Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure, IconBu
import React from 'react'; import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import * as mixpanel from 'lib/mixpanel/index';
import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon'; import AddressIdenticon from 'ui/shared/entities/address/AddressIdenticon';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
import useWallet from 'ui/snippets/walletMenu/useWallet'; import useWallet from 'ui/snippets/walletMenu/useWallet';
...@@ -16,6 +17,11 @@ const WalletMenuMobile = () => { ...@@ -16,6 +17,11 @@ const WalletMenuMobile = () => {
const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors(); const { themedBackground, themedBorderColor, themedColor } = useMenuButtonColors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const openPopover = React.useCallback(() => {
mixpanel.logEvent(mixpanel.EventTypes.WALLET_ACTION, { Action: 'Open' });
onOpen();
}, [ onOpen ]);
return ( return (
<> <>
<WalletTooltip isDisabled={ isWalletConnected || isMobile === undefined || !isMobile } isMobile> <WalletTooltip isDisabled={ isWalletConnected || isMobile === undefined || !isMobile } isMobile>
...@@ -32,7 +38,7 @@ const WalletMenuMobile = () => { ...@@ -32,7 +38,7 @@ const WalletMenuMobile = () => {
bg={ isWalletConnected ? themedBackground : undefined } bg={ isWalletConnected ? themedBackground : undefined }
color={ themedColor } color={ themedColor }
borderColor={ !isWalletConnected ? themedBorderColor : undefined } borderColor={ !isWalletConnected ? themedBorderColor : undefined }
onClick={ isWalletConnected ? onOpen : connect } onClick={ isWalletConnected ? openPopover : connect }
isLoading={ isModalOpening || isModalOpen } isLoading={ isModalOpening || isModalOpen }
/> />
</WalletTooltip> </WalletTooltip>
......
...@@ -2,6 +2,8 @@ import { Tooltip, useBoolean, useOutsideClick } from '@chakra-ui/react'; ...@@ -2,6 +2,8 @@ import { Tooltip, useBoolean, useOutsideClick } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { SECOND } from 'lib/consts';
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
isDisabled?: boolean; isDisabled?: boolean;
...@@ -26,12 +28,15 @@ const WalletTooltip = ({ children, isDisabled, isMobile }: Props) => { ...@@ -26,12 +28,15 @@ const WalletTooltip = ({ children, isDisabled, isMobile }: Props) => {
React.useEffect(() => { React.useEffect(() => {
const wasShown = window.localStorage.getItem(localStorageKey); const wasShown = window.localStorage.getItem(localStorageKey);
if (!isDisabled && !wasShown) { const isMarketplacePage = [ '/apps', '/apps/[id]' ].includes(router.pathname);
setIsTooltipShown.on(); if (!isDisabled && !wasShown && isMarketplacePage) {
window.localStorage.setItem(localStorageKey, 'true'); setTimeout(() => {
setTimeout(() => setIsTooltipShown.off(), 3000); setIsTooltipShown.on();
window.localStorage.setItem(localStorageKey, 'true');
setTimeout(() => setIsTooltipShown.off(), 5 * SECOND);
}, SECOND);
} }
}, [ setIsTooltipShown, localStorageKey, isDisabled ]); }, [ setIsTooltipShown, localStorageKey, isDisabled, router.pathname ]);
return ( return (
<Tooltip <Tooltip
......
import type { TooltipProps } from '@chakra-ui/react';
import { Box, Grid, Heading, List, ListItem, Skeleton } from '@chakra-ui/react'; import { Box, Grid, Heading, List, ListItem, Skeleton } from '@chakra-ui/react';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import type { StatsChartsSection } from 'types/api/stats'; import type { StatsChartsSection } from 'types/api/stats';
import type { StatsIntervalIds } from 'types/client/stats'; import type { StatsIntervalIds } from 'types/client/stats';
import useApiQuery from 'lib/api/useApiQuery';
import { apos } from 'lib/html-entities'; import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/shared/EmptySearchResult'; import EmptySearchResult from 'ui/shared/EmptySearchResult';
import GasInfoTooltipContent from 'ui/shared/GasInfoTooltipContent/GasInfoTooltipContent';
import Hint from 'ui/shared/Hint';
import ChartsLoadingErrorAlert from './ChartsLoadingErrorAlert'; import ChartsLoadingErrorAlert from './ChartsLoadingErrorAlert';
import ChartWidgetContainer from './ChartWidgetContainer'; import ChartWidgetContainer from './ChartWidgetContainer';
const GAS_TOOLTIP_PROPS: Partial<TooltipProps> = {
borderRadius: 'md',
hasArrow: false,
padding: 0,
};
type Props = { type Props = {
filterQuery: string; filterQuery: string;
isError: boolean; isError: boolean;
...@@ -23,6 +33,17 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in ...@@ -23,6 +33,17 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in
const isAnyChartDisplayed = charts?.some((section) => section.charts.length > 0); const isAnyChartDisplayed = charts?.some((section) => section.charts.length > 0);
const isEmptyChartList = Boolean(filterQuery) && !isAnyChartDisplayed; const isEmptyChartList = Boolean(filterQuery) && !isAnyChartDisplayed;
const homeStatsQuery = useApiQuery('homepage_stats', {
fetchParams: {
headers: {
'updated-gas-oracle': 'true',
},
},
queryOptions: {
refetchOnMount: false,
},
});
const handleChartLoadingError = useCallback( const handleChartLoadingError = useCallback(
() => setIsSomeChartLoadingError(true), () => setIsSomeChartLoadingError(true),
[ setIsSomeChartLoadingError ]); [ setIsSomeChartLoadingError ]);
...@@ -51,10 +72,16 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in ...@@ -51,10 +72,16 @@ const ChartsWidgetsList = ({ filterQuery, isError, isPlaceholderData, charts, in
marginBottom: 0, marginBottom: 0,
}} }}
> >
<Skeleton isLoaded={ !isPlaceholderData } mb={ 4 } display="inline-block"> <Skeleton isLoaded={ !isPlaceholderData } mb={ 4 } display="inline-flex" alignItems="center" columnGap={ 2 }>
<Heading size="md" > <Heading size="md" >
{ section.title } { section.title }
</Heading> </Heading>
{ section.id === 'gas' && homeStatsQuery.data && homeStatsQuery.data.gas_prices && (
<Hint
label={ <GasInfoTooltipContent data={ homeStatsQuery.data } dataUpdatedAt={ homeStatsQuery.dataUpdatedAt }/> }
tooltipProps={ GAS_TOOLTIP_PROPS }
/>
) }
</Skeleton> </Skeleton>
<Grid <Grid
......
...@@ -150,10 +150,27 @@ const TxDetails = () => { ...@@ -150,10 +150,27 @@ const TxDetails = () => {
</Tag> </Tag>
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
<TxDetailsWithdrawalStatus { config.features.optimisticRollup.isEnabled && data.op_withdrawals && data.op_withdrawals.length > 0 && (
status={ data.op_withdrawal_status } <DetailsInfoItem
l1TxHash={ data.op_l1_transaction_hash } title="Withdrawal status"
/> hint="Detailed status progress of the transaction"
>
<Flex flexDir="column" rowGap={ 2 }>
{ data.op_withdrawals.map((withdrawal) => (
<Box key={ withdrawal.nonce }>
<Box mb={ 2 }>
<span>Nonce: </span>
<chakra.span fontWeight={ 600 }>{ withdrawal.nonce }</chakra.span>
</Box>
<TxDetailsWithdrawalStatus
status={ withdrawal.status }
l1TxHash={ withdrawal.l1_transaction_hash }
/>
</Box>
)) }
</Flex>
</DetailsInfoItem>
) }
{ data.zkevm_status && ( { data.zkevm_status && (
<DetailsInfoItem <DetailsInfoItem
title="Confirmation status" title="Confirmation status"
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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