Commit f685a9f0 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Call contract read methods from the client (#2032)

* display methods accordion

* add method_id to methods

* query read method via public client

* query read methods without inputs at accordion section open

* add custom abi tabs

* add proxy tabs

* custom wagmi config if wc is not configured

* remove ContractAbiItemConstant component

* display tuple properties in method outputs

* display read result

* clean up

* support nested arrays in the read result

* re-write test for contract methods

* change result view for address output

* refactoring

* fix tests

* remove web3 provider fallback

* fix tab loading

* refactoring

* add implementation contract selector

* add "copy to clipboard" for address, remove top-level square brackets

* add preview mode to contract method result

* tests for read result

* fix tests

* change styles for result preview

* review fix
parent 8f31d248
......@@ -18,7 +18,7 @@ NEXT_PUBLIC_NETWORK_RPC_URL=https://eth-sepolia.public.blastapi.io
NEXT_PUBLIC_IS_TESTNET=true
# api configuration
NEXT_PUBLIC_API_HOST=eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_API_HOST=eth-sepolia.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
# ui config
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.16 3A2.16 2.16 0 0 0 3 5.16v10.18a2.16 2.16 0 0 0 2.16 2.16h10.18a2.16 2.16 0 0 0 2.16-2.16v-3.272h-1.614v3.114c0 .389-.315.704-.704.704H5.318a.705.705 0 0 1-.704-.704V5.318c0-.389.315-.704.704-.704h3.114V3H5.159Zm6.135 0a.41.41 0 0 0-.41.41v.793c0 .226.184.409.41.409h3.453l-4.244 4.245a.409.409 0 0 0 0 .578l.56.561c.16.16.42.16.58 0l4.244-4.245v3.454c0 .226.183.41.41.41h.793a.41.41 0 0 0 .409-.41V3h-6.205Z" fill="currentColor"/>
</svg>
......@@ -46,8 +46,6 @@ import type { ChartMarketResponse, ChartSecondaryCoinPriceResponse, ChartTransac
import type { BackendVersionConfig } from 'types/api/configs';
import type {
SmartContract,
SmartContractReadMethod,
SmartContractWriteMethod,
SmartContractVerificationConfig,
SolidityscanReport,
SmartContractSecurityAudits,
......@@ -465,26 +463,6 @@ export const RESOURCES = {
path: '/api/v2/smart-contracts/:hash',
pathParams: [ 'hash' as const ],
},
contract_methods_read: {
path: '/api/v2/smart-contracts/:hash/methods-read',
pathParams: [ 'hash' as const ],
},
contract_methods_read_proxy: {
path: '/api/v2/smart-contracts/:hash/methods-read-proxy',
pathParams: [ 'hash' as const ],
},
contract_method_query: {
path: '/api/v2/smart-contracts/:hash/query-read-method',
pathParams: [ 'hash' as const ],
},
contract_methods_write: {
path: '/api/v2/smart-contracts/:hash/methods-write',
pathParams: [ 'hash' as const ],
},
contract_methods_write_proxy: {
path: '/api/v2/smart-contracts/:hash/methods-write-proxy',
pathParams: [ 'hash' as const ],
},
contract_verification_config: {
path: '/api/v2/smart-contracts/verification/config',
},
......@@ -1000,10 +978,6 @@ Q extends 'quick_search' ? Array<SearchResultItem> :
Q extends 'search' ? SearchResult :
Q extends 'search_check_redirect' ? SearchRedirectResult :
Q extends 'contract' ? SmartContract :
Q extends 'contract_methods_read' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_read_proxy' ? Array<SmartContractReadMethod> :
Q extends 'contract_methods_write' ? Array<SmartContractWriteMethod> :
Q extends 'contract_methods_write_proxy' ? Array<SmartContractWriteMethod> :
Q extends 'contract_solidityscan_report' ? SolidityscanReport :
Q extends 'verified_contracts' ? VerifiedContractsResponse :
Q extends 'verified_contracts_counters' ? VerifiedContractsCounters :
......
......@@ -4,19 +4,24 @@ import React from 'react';
import type { Address } from 'types/api/address';
import useApiQuery from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
import getQueryParamString from 'lib/router/getQueryParamString';
import useSocketChannel from 'lib/socket/useSocketChannel';
import * as stubs from 'stubs/contract';
import ContractCode from 'ui/address/contract/ContractCode';
import ContractRead from 'ui/address/contract/ContractRead';
import ContractWrite from 'ui/address/contract/ContractWrite';
import ContractMethodsCustom from 'ui/address/contract/methods/ContractMethodsCustom';
import ContractMethodsProxy from 'ui/address/contract/methods/ContractMethodsProxy';
import ContractMethodsRegular from 'ui/address/contract/methods/ContractMethodsRegular';
import { divideAbiIntoMethodTypes } from 'ui/address/contract/methods/utils';
const CONTRACT_TAB_IDS = [
'contract_code',
'read_contract',
'read_contract_rpc',
'read_proxy',
'read_custom_methods',
'write_contract',
'write_contract_rpc',
'write_proxy',
'write_custom_methods',
] as const;
......@@ -38,7 +43,7 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const isEnabled = Boolean(data?.hash) && !isPlaceholderData && CONTRACT_TAB_IDS.concat('contract' as never).includes(tab);
const isEnabled = Boolean(data?.hash) && data?.is_contract && !isPlaceholderData && CONTRACT_TAB_IDS.concat('contract' as never).includes(tab);
const enableQuery = React.useCallback(() => {
setIsQueryEnabled(true);
......@@ -53,6 +58,13 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
},
});
const customAbiQuery = useApiQuery('custom_abi', {
queryOptions: {
enabled: isEnabled && isQueryEnabled && Boolean(cookies.get(cookies.NAMES.API_TOKEN)),
refetchOnMount: false,
},
});
const channel = useSocketChannel({
topic: `addresses:${ data?.hash?.toLowerCase() }`,
isDisabled: !isEnabled,
......@@ -60,6 +72,20 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
onSocketError: enableQuery,
});
const methods = React.useMemo(() => divideAbiIntoMethodTypes(contractQuery.data?.abi ?? []), [ contractQuery.data?.abi ]);
const methodsCustomAbi = React.useMemo(() => {
return divideAbiIntoMethodTypes(
customAbiQuery.data
?.find((item) => data && item.contract_address_hash.toLowerCase() === data.hash.toLowerCase())
?.abi ??
[],
);
}, [ customAbiQuery.data, data ]);
const verifiedImplementations = React.useMemo(() => {
return data?.implementations?.filter(({ name, address }) => name && address && address !== data?.hash) || [];
}, [ data?.hash, data?.implementations ]);
return React.useMemo(() => {
return {
tabs: [
......@@ -68,26 +94,50 @@ export default function useContractTabs(data: Address | undefined, isPlaceholder
title: 'Code',
component: <ContractCode contractQuery={ contractQuery } channel={ channel } addressHash={ data?.hash }/>,
},
contractQuery.data?.has_methods_read ?
{ id: 'read_contract' as const, title: 'Read contract', component: <ContractRead isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_methods_read_proxy ?
{ id: 'read_proxy' as const, title: 'Read proxy', component: <ContractRead isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_custom_methods_read ?
{ id: 'read_custom_methods' as const, title: 'Read custom', component: <ContractRead isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_methods_write ?
{ id: 'write_contract' as const, title: 'Write contract', component: <ContractWrite isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_methods_write_proxy ?
{ id: 'write_proxy' as const, title: 'Write proxy', component: <ContractWrite isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
contractQuery.data?.has_custom_methods_write ?
{ id: 'write_custom_methods' as const, title: 'Write custom', component: <ContractWrite isLoading={ contractQuery.isPlaceholderData }/> } :
undefined,
methods.read.length > 0 && {
id: 'read_contract' as const,
title: 'Read contract',
component: <ContractMethodsRegular type="read" abi={ methods.read } isLoading={ contractQuery.isPlaceholderData }/>,
},
methodsCustomAbi.read.length > 0 && {
id: 'read_custom_methods' as const,
title: 'Read custom',
component: <ContractMethodsCustom type="read" abi={ methodsCustomAbi.read } isLoading={ contractQuery.isPlaceholderData }/>,
},
verifiedImplementations.length > 0 && {
id: 'read_proxy' as const,
title: 'Read proxy',
component: (
<ContractMethodsProxy
type="read"
implementations={ verifiedImplementations }
isLoading={ contractQuery.isPlaceholderData }
/>
),
},
methods.write.length > 0 && {
id: 'write_contract' as const,
title: 'Write contract',
component: <ContractMethodsRegular type="write" abi={ methods.write } isLoading={ contractQuery.isPlaceholderData }/>,
},
methodsCustomAbi.write.length > 0 && {
id: 'write_custom_methods' as const,
title: 'Write custom',
component: <ContractMethodsCustom type="write" abi={ methodsCustomAbi.write } isLoading={ contractQuery.isPlaceholderData }/>,
},
verifiedImplementations.length > 0 && {
id: 'write_proxy' as const,
title: 'Write proxy',
component: (
<ContractMethodsProxy
type="write"
implementations={ verifiedImplementations }
isLoading={ contractQuery.isPlaceholderData }
/>
),
},
].filter(Boolean),
isLoading: contractQuery.isPlaceholderData,
};
}, [ contractQuery, channel, data?.hash ]);
}, [ contractQuery, channel, data?.hash, verifiedImplementations, methods.read, methods.write, methodsCustomAbi.read, methodsCustomAbi.write ]);
}
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config';
import { http } from 'viem';
import type { CreateConfigParameters } from 'wagmi';
import { createConfig, type CreateConfigParameters } from 'wagmi';
import config from 'configs/app';
import currentChain from 'lib/web3/currentChain';
const feature = config.features.blockchainInteraction;
const wagmiConfig = (() => {
try {
if (!feature.isEnabled) {
throw new Error();
}
const chains: CreateConfigParameters['chains'] = [ currentChain ];
const chains: CreateConfigParameters['chains'] = [ currentChain ];
const wagmiConfig = defaultWagmiConfig({
chains,
multiInjectedProviderDiscovery: true,
if (!feature.isEnabled) {
const wagmiConfig = createConfig({
chains: [ currentChain ],
transports: {
[currentChain.id]: http(),
},
projectId: feature.walletConnect.projectId,
metadata: {
name: `${ config.chain.name } explorer`,
description: `${ config.chain.name } explorer`,
url: config.app.baseUrl,
icons: [ config.UI.navigation.icon.default ].filter(Boolean),
[currentChain.id]: http(config.chain.rpcUrl || `${ config.api.endpoint }/api/eth-rpc`),
},
enableEmail: true,
ssr: true,
batch: { multicall: { wait: 100 } },
});
return wagmiConfig;
} catch (error) {}
}
const wagmiConfig = defaultWagmiConfig({
chains,
multiInjectedProviderDiscovery: true,
transports: {
[currentChain.id]: http(),
},
projectId: feature.walletConnect.projectId,
metadata: {
name: `${ config.chain.name } explorer`,
description: `${ config.chain.name } explorer`,
url: config.app.baseUrl,
icons: [ config.UI.navigation.icon.default ].filter(Boolean),
},
enableEmail: true,
ssr: true,
batch: { multicall: { wait: 100 } },
});
return wagmiConfig;
})();
export default wagmiConfig;
......@@ -33,12 +33,6 @@ export const verified: SmartContract = {
],
language: 'solidity',
license_type: 'gnu_gpl_v3',
has_methods_read: true,
has_methods_read_proxy: false,
has_methods_write: true,
has_methods_write_proxy: false,
has_custom_methods_read: false,
has_custom_methods_write: false,
is_self_destructed: false,
is_verified_via_eth_bytecode_db: null,
is_changed_bytecode: null,
......@@ -120,12 +114,6 @@ export const nonVerified: SmartContract = {
verified_at: null,
is_verified_via_eth_bytecode_db: null,
is_changed_bytecode: null,
has_methods_read: false,
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_custom_methods_read: false,
has_custom_methods_write: false,
is_verified_via_sourcify: null,
is_fully_verified: null,
is_partially_verified: null,
......
import type {
SmartContractQueryMethodError,
SmartContractQueryMethodSuccess,
SmartContractReadMethod,
SmartContractWriteMethod,
} from 'types/api/contract';
import type { SmartContractMethodRead, SmartContractMethodWrite } from 'ui/address/contract/methods/types';
export const read: Array<SmartContractReadMethod> = [
export const read: Array<SmartContractMethodRead> = [
{
constant: true,
inputs: [
......@@ -26,94 +21,15 @@ export const read: Array<SmartContractReadMethod> = [
method_id: '06fdde03',
name: 'name',
outputs: [
{ internalType: 'string', name: '', type: 'string', value: 'Wrapped POA' },
{ internalType: 'string', name: '', type: 'string' },
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
inputs: [],
method_id: '18160ddd',
name: 'totalSupply',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256', value: '139905710421584994690047413' },
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
error: '(-32015) VM execution error. (revert)',
inputs: [],
method_id: 'df0ad3de',
name: 'upgradeabilityAdmin',
outputs: [
{ name: '', type: 'address', value: '' },
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
inputs: [],
method_id: '165ec2e2',
name: 'arianeeWhitelist',
outputs: [
{
name: '',
type: 'address',
value: '0xd3eee7f8e8021db24825c3457d5479f2b57f40ef',
},
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
inputs: [],
method_id: '69598efe',
name: 'totalPartitions',
constant: true,
payable: false,
outputs: [
{
type: 'bytes32[]',
name: 'bytes32[]',
value: [
'0x7265736572766564000000000000000000000000000000000000000000000000',
'0x6973737565640000000000000000000000000000000000000000000000000000',
],
},
],
stateMutability: 'view',
type: 'function',
},
];
export const readResultSuccess: SmartContractQueryMethodSuccess = {
is_error: false,
result: {
names: [ 'amount' ],
output: [
{ type: 'uint256', value: '42' },
],
},
};
export const readResultError: SmartContractQueryMethodError = {
is_error: true,
result: {
message: 'Some shit happened',
code: -32017,
raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72',
},
};
export const write: Array<SmartContractWriteMethod> = [
export const write: Array<SmartContractMethodWrite> = [
{
payable: true,
stateMutability: 'payable',
......
......@@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { http } from 'viem';
import { WagmiProvider, createConfig } from 'wagmi';
import { sepolia } from 'wagmi/chains';
import { mock } from 'wagmi/connectors';
import type { Props as PageProps } from 'nextjs/getServerSideProps';
......@@ -12,6 +11,7 @@ import type { Props as PageProps } from 'nextjs/getServerSideProps';
import config from 'configs/app';
import { AppContextProvider } from 'lib/contexts/app';
import { SocketProvider } from 'lib/socket/context';
import currentChain from 'lib/web3/currentChain';
import theme from 'theme';
import { port as socketPort } from './utils/socket';
......@@ -36,7 +36,7 @@ const defaultAppContext = {
};
const wagmiConfig = createConfig({
chains: [ sepolia ],
chains: [ currentChain ],
connectors: [
mock({
accounts: [
......@@ -45,24 +45,11 @@ const wagmiConfig = createConfig({
}),
],
transports: {
[sepolia.id]: http(),
[currentChain.id]: http(),
},
});
const WalletClientProvider = ({ children, withWalletClient }: { children: React.ReactNode; withWalletClient?: boolean }) => {
if (withWalletClient) {
return (
<WagmiProvider config={ wagmiConfig }>
{ children }
</WagmiProvider>
);
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
const TestApp = ({ children, withSocket, withWalletClient = true, appContext = defaultAppContext }: Props) => {
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
queries: {
......@@ -78,9 +65,9 @@ const TestApp = ({ children, withSocket, withWalletClient = true, appContext = d
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<GrowthBookProvider>
<WalletClientProvider withWalletClient={ withWalletClient }>
<WagmiProvider config={ wagmiConfig }>
{ children }
</WalletClientProvider>
</WagmiProvider>
</GrowthBookProvider>
</AppContextProvider>
</SocketProvider>
......
import type { TestFixture, Page } from '@playwright/test';
import _isEqual from 'lodash/isEqual';
import { encodeFunctionData, encodeFunctionResult, type AbiFunction } from 'viem';
import { getEnvValue } from 'configs/app/utils';
interface Params {
abiItem: AbiFunction;
args?: Array<unknown>;
address: string;
result: Array<unknown>;
}
export type MockContractReadResponseFixture = (params: Params) => Promise<void>;
const fixture: TestFixture<MockContractReadResponseFixture, { page: Page }> = async({ page }, use) => {
await use(async({ abiItem, args = [], address, result }) => {
const rpcUrl = getEnvValue('NEXT_PUBLIC_NETWORK_RPC_URL');
if (!rpcUrl) {
return;
}
await page.route(rpcUrl, (route) => {
const method = route.request().method();
if (method !== 'POST') {
route.continue();
return;
}
const json = route.request().postDataJSON();
const params = json?.params?.[0];
const id = json?.id;
const callParams = {
data: encodeFunctionData({
abi: [ abiItem ],
functionName: abiItem.name,
args,
}),
to: address,
};
if (_isEqual(params, callParams) && id) {
return route.fulfill({
status: 200,
body: JSON.stringify({
id,
jsonrpc: '2.0',
result: encodeFunctionResult({
abi: [ abiItem ],
functionName: abiItem.name,
result,
}),
}),
});
}
});
});
};
export default fixture;
......@@ -5,6 +5,7 @@ import * as injectMetaMaskProvider from './fixtures/injectMetaMaskProvider';
import * as mockApiResponse from './fixtures/mockApiResponse';
import * as mockAssetResponse from './fixtures/mockAssetResponse';
import * as mockConfigResponse from './fixtures/mockConfigResponse';
import * as mockContractReadResponse from './fixtures/mockContractReadResponse';
import * as mockEnvs from './fixtures/mockEnvs';
import * as mockFeatures from './fixtures/mockFeatures';
import * as mockTextAd from './fixtures/mockTextAd';
......@@ -16,6 +17,7 @@ interface Fixtures {
mockApiResponse: mockApiResponse.MockApiResponseFixture;
mockAssetResponse: mockAssetResponse.MockAssetResponseFixture;
mockConfigResponse: mockConfigResponse.MockConfigResponseFixture;
mockContractReadResponse: mockContractReadResponse.MockContractReadResponseFixture;
mockEnvs: mockEnvs.MockEnvsFixture;
mockFeatures: mockFeatures.MockFeaturesFixture;
createSocket: socketServer.CreateSocketFixture;
......@@ -28,6 +30,7 @@ const test = base.extend<Fixtures>({
mockApiResponse: mockApiResponse.default,
mockAssetResponse: mockAssetResponse.default,
mockConfigResponse: mockConfigResponse.default,
mockContractReadResponse: mockContractReadResponse.default,
mockEnvs: mockEnvs.default,
mockFeatures: mockFeatures.default,
// FIXME: for some reason Playwright does not intercept requests to text ad provider when running multiple tests in parallel
......
......@@ -89,6 +89,7 @@
| "networks/icon-placeholder"
| "networks/logo-placeholder"
| "nft_shield"
| "open-link"
| "output_roots"
| "payment_link"
| "plus"
......
......@@ -25,7 +25,7 @@ export const ADDRESS_INFO: Address = {
has_tokens: false,
has_validated_blocks: false,
hash: ADDRESS_HASH,
implementations: null,
implementations: [ { address: ADDRESS_HASH, name: 'Proxy' } ],
is_contract: true,
is_verified: true,
name: 'ChainLink Token (goerli)',
......
......@@ -7,16 +7,25 @@ export const CONTRACT_CODE_UNVERIFIED = {
creation_bytecode: '0x60806040526e',
deployed_bytecode: '0x608060405233',
is_self_destructed: false,
has_methods_read: true,
has_methods_read_proxy: true,
has_methods_write: true,
has_methods_write_proxy: true,
has_custom_methods_read: true,
has_custom_methods_write: true,
} as SmartContract;
export const CONTRACT_CODE_VERIFIED = {
abi: [],
abi: [
{
inputs: [],
name: 'symbol',
outputs: [ { internalType: 'string', name: '', type: 'string' } ],
stateMutability: 'view',
type: 'function',
},
{
inputs: [ { internalType: 'address', name: 'newOwner', type: 'address' } ],
name: 'transferOwnership',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
],
additional_sources: [],
can_be_visualized_via_sol2uml: true,
compiler_settings: {
......@@ -47,12 +56,6 @@ export const CONTRACT_CODE_VERIFIED = {
source_code: 'source_code',
verified_at: '2023-02-21T14:39:16.906760Z',
license_type: 'mit',
has_methods_read: true,
has_methods_read_proxy: true,
has_methods_write: true,
has_methods_write_proxy: true,
has_custom_methods_read: true,
has_custom_methods_write: true,
} as unknown as SmartContract;
export const VERIFIED_CONTRACT_INFO: VerifiedContract = {
......
import type { Abi, AbiType, AbiFallback, AbiFunction, AbiReceive } from 'abitype';
import type { Abi, AbiType } from 'abitype';
export type SmartContractMethodArgType = AbiType;
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
......@@ -35,13 +35,6 @@ export interface SmartContract {
is_verified_via_eth_bytecode_db: boolean | null;
is_changed_bytecode: boolean | null;
has_methods_read: boolean;
has_methods_read_proxy: boolean;
has_methods_write: boolean;
has_methods_write_proxy: boolean;
has_custom_methods_read: boolean;
has_custom_methods_write: boolean;
// sourcify info >>>
is_verified_via_sourcify: boolean | null;
is_fully_verified: boolean | null;
......@@ -80,47 +73,6 @@ export interface SmartContractExternalLibrary {
name: string;
}
export type SmartContractMethodOutputValue = string | boolean | object;
export type SmartContractMethodOutput = AbiFunction['outputs'][number] & { value?: SmartContractMethodOutputValue };
export type SmartContractMethodBase = Omit<AbiFunction, 'outputs'> & {
method_id: string;
outputs: Array<SmartContractMethodOutput>;
constant?: boolean;
error?: string;
};
export type SmartContractReadMethod = SmartContractMethodBase;
export type SmartContractWriteMethod = SmartContractMethodBase | AbiFallback | AbiReceive;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractQueryMethodSuccess {
is_error: false;
result: {
names: Array<string | [ string, Array<string> ]>;
output: Array<{
type: string;
value: string | Array<unknown>;
}>;
};
}
export interface SmartContractQueryMethodError {
is_error: true;
result: {
code: number;
message: string;
} | {
error: string;
} | {
raw: string;
} | {
method_call: string;
method_id: string;
parameters: Array<{ 'name': string; 'type': string; 'value': string }>;
};
}
export type SmartContractQueryMethod = SmartContractQueryMethodSuccess | SmartContractQueryMethodError;
// VERIFICATION
export type SmartContractVerificationMethod = 'flattened-code' | 'standard-input' | 'sourcify' | 'multi-part'
......
......@@ -13,9 +13,11 @@ const hash = addressMock.contract.hash;
test.beforeEach(async({ mockApiResponse }) => {
await mockApiResponse('address', addressMock.contract, { pathParams: { hash } });
await mockApiResponse('contract', contractInfoMock.verified, { pathParams: { hash } });
await mockApiResponse('contract_methods_read', contractMethodsMock.read, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
await mockApiResponse('contract_methods_write', contractMethodsMock.write, { pathParams: { hash }, queryParams: { is_custom_abi: 'false' } });
await mockApiResponse(
'contract',
{ ...contractInfoMock.verified, abi: [ ...contractMethodsMock.read, ...contractMethodsMock.write ] },
{ pathParams: { hash } },
);
});
test.describe('ABI functionality', () => {
......@@ -41,7 +43,7 @@ test.describe('ABI functionality', () => {
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
......@@ -78,7 +80,7 @@ test.describe('ABI functionality', () => {
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
......
import { Checkbox, Flex, chakra } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import type { ChangeEvent } from 'react';
import React from 'react';
import { getAddress } from 'viem';
import type { ContractAbiItemOutput } from './types';
import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { matchInt } from './form/utils';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
switch (typeof value) {
case 'string':
return value;
case 'boolean':
return String(value);
case 'undefined':
return '';
case 'number':
return value.toLocaleString(undefined, { useGrouping: false });
case 'bigint':
return value.toString();
case 'object':
return JSON.stringify(value, undefined, 2);
}
}
interface Props {
data: ContractAbiItemOutput;
}
const ContractAbiItemConstant = ({ data }: Props) => {
const [ value, setValue ] = React.useState<string>(castValueToString(data.value));
const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase());
const intMatch = matchInt(data.type);
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const initialValue = castValueToString(data.value);
if (event.target.checked) {
setValue(BigNumber(initialValue).div(WEI).toFixed());
setLabel(currencyUnits.ether.toUpperCase());
} else {
setValue(BigNumber(initialValue).toFixed());
setLabel(currencyUnits.wei.toUpperCase());
}
}, [ data.value ]);
const content = (() => {
if (typeof data.value === 'string' && data.type === 'address' && data.value) {
return (
<AddressEntity
address={{ hash: getAddress(data.value) }}
noIcon
/>
);
}
return <chakra.span wordBreak="break-all" whiteSpace="pre-wrap">({ data.type }): { String(value) }</chakra.span>;
})();
return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
{ content }
{ Number(intMatch?.power) >= 128 && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex>
);
};
export default ContractAbiItemConstant;
import { Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { AbiFunction } from 'viem';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: AbiFunction['outputs'];
}
const ContractMethodOutputs = ({ data }: Props) => {
if (data.length === 0) {
return null;
}
return (
<Flex mt={ 3 } fontSize="sm">
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
<p>
{ data.map(({ type, name }, index) => {
return (
<React.Fragment key={ index }>
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
<span>{ name ? `(${ type })` : type }</span>
{ index < data.length - 1 && <span>, </span> }
</React.Fragment>
);
}) }
</p>
</Flex>
);
};
export default React.memo(ContractMethodOutputs);
import React from 'react';
import type { FormSubmitResult, ContractAbiItem } from '../types';
import ContractMethodResultApi from './ContractMethodResultApi';
import ContractMethodResultWalletClient from './ContractMethodResultWalletClient';
interface Props {
abiItem: ContractAbiItem;
result: FormSubmitResult;
onSettle: () => void;
}
const ContractMethodResult = ({ result, abiItem, onSettle }: Props) => {
switch (result.source) {
case 'api':
return <ContractMethodResultApi item={ abiItem } result={ result.result } onSettle={ onSettle }/>;
case 'wallet_client':
return <ContractMethodResultWalletClient result={ result.result } onSettle={ onSettle }/>;
default: {
return null;
}
}
};
export default React.memo(ContractMethodResult);
import React from 'react';
import type { FormSubmitResultApi } from '../types';
import * as contractMethodsMock from 'mocks/contract/methods';
import { test, expect } from 'playwright/lib';
import ContractMethodResultApi from './ContractMethodResultApi';
const item = contractMethodsMock.read[0];
const onSettle = () => Promise.resolve();
test.use({ viewport: { width: 500, height: 500 } });
test('default error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
error: 'I am an error',
},
};
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('error with code', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
message: 'I am an error',
code: -32017,
},
};
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('raw error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72',
},
};
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
method_call: 'SomeCustomError(address addr, uint256 balance)',
method_id: '50289a9f',
parameters: [
{ name: 'addr', type: 'address', value: '0x850e73b42f48e91ebaedf8f00a74f6147e485c5a' },
{ name: 'balance', type: 'uint256', value: '14' },
],
},
};
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: false,
result: {
names: [ 'address' ],
output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ],
},
};
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
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 render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractAbiItem, FormSubmitResultApi } from '../types';
import hexToUtf8 from 'lib/hexToUtf8';
import ContractMethodResultApiError from './ContractMethodResultApiError';
import ContractMethodResultApiItem from './ContractMethodResultApiItem';
interface Props {
item: ContractAbiItem;
result: FormSubmitResultApi['result'];
onSettle: () => void;
}
const ContractMethodResultApi = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractMethodResultApiError>{ result.statusText }</ContractMethodResultApiError>;
}
if (result instanceof Error) {
return <ContractMethodResultApiError>{ result.message }</ContractMethodResultApiError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractMethodResultApiError>{ result.result.error }</ContractMethodResultApiError>;
}
if ('message' in result.result) {
return <ContractMethodResultApiError>[{ result.result.code }] { result.result.message }</ContractMethodResultApiError>;
}
if ('raw' in result.result) {
return <ContractMethodResultApiError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractMethodResultApiError>;
}
if ('method_id' in result.result) {
return <ContractMethodResultApiError>{ JSON.stringify(result.result, undefined, 2) }</ContractMethodResultApiError>;
}
return <ContractMethodResultApiError>Something went wrong.</ContractMethodResultApiError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractMethodResultApiItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractMethodResultApi);
import { Alert } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const ContractMethodResultApiError = ({ children }: Props) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</Alert>
);
};
export default React.memo(ContractMethodResultApiError);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractQueryMethodSuccess } from 'types/api/contract';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
interface Props {
output: SmartContractQueryMethodSuccess['result']['output'][0];
name: SmartContractQueryMethodSuccess['result']['names'][0];
}
const ContractMethodResultApiItem = ({ output, name }: Props) => {
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>
);
};
export default React.memo(ContractMethodResultApiItem);
import type { AbiFunction } from 'abitype';
import type { SmartContractMethod, SmartContractMethodOutput, SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
export type ContractAbiItemInput = AbiFunction['inputs'][number] & { fieldType?: 'native_coin' };
export type ContractAbiItemOutput = SmartContractMethodOutput;
export type ContractAbiItem = SmartContractMethod;
export type ContractAbi = Array<ContractAbiItem>;
export type MethodType = 'read' | 'write';
export type MethodCallStrategy = 'api' | 'wallet_client';
export interface FormSubmitResultApi {
source: 'api';
result: SmartContractQueryMethod | ResourceError | Error;
}
export interface FormSubmitResultWalletClient {
source: 'wallet_client';
result: Error | { hash: `0x${ string }` | undefined } | undefined;
}
export type FormSubmitResult = FormSubmitResultApi | FormSubmitResultWalletClient;
export type FormSubmitHandler = (item: ContractAbiItem, args: Array<unknown>, submitType: MethodCallStrategy | undefined) => Promise<FormSubmitResult>;
import React from 'react';
import type { FormSubmitResult } from './types';
import type { SmartContractQueryMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
import useApiFetch from 'lib/api/useApiFetch';
import useAccount from 'lib/web3/useAccount';
interface Params {
methodId: string;
args: Array<unknown>;
isProxy: boolean;
isCustomAbi: boolean;
addressHash: string;
}
export default function useCallMethodApi(): (params: Params) => Promise<FormSubmitResult> {
const apiFetch = useApiFetch();
const { address } = useAccount();
return React.useCallback(async({ addressHash, isCustomAbi, isProxy, args, methodId }) => {
try {
const response = await apiFetch<'contract_method_query', SmartContractQueryMethod>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: methodId,
contract_type: isProxy ? 'proxy' : 'regular',
from: address,
},
},
});
return {
source: 'api',
result: response,
};
} catch (error) {
return {
source: 'api',
result: error as (Error | ResourceError),
};
}
}, [ address, apiFetch ]);
}
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
import { Box, Flex } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { Address as TAddress } from 'types/api/address';
import { getResourceKey } from 'lib/api/useApiQuery';
import ContainerWithScrollY from 'ui/shared/ContainerWithScrollY';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
interface Props {
hash: string | undefined;
}
const ContractImplementationAddress = ({ hash }: Props) => {
const queryClient = useQueryClient();
const data = queryClient.getQueryData<TAddress>(getResourceKey('address', {
pathParams: { hash },
}));
if (!data?.implementations || data.implementations.length === 0) {
return null;
}
const label = data.implementations.length > 1 ? 'Implementation addresses:' : 'Implementation address:';
return (
<Flex mb={ 6 } flexWrap="wrap" columnGap={ 2 } rowGap={ 2 }>
<span>{ label }</span>
<Box position="relative" maxW="100%">
<ContainerWithScrollY
gradientHeight={ 24 }
rowGap={ 2 }
maxH="150px"
>
{ data.implementations.map((item) => (
<AddressEntity
key={ item.address }
address={{ hash: item.address, is_contract: true }}
noIcon
noCopy
/>
)) }
</ContainerWithScrollY>
</Box>
</Flex>
);
};
export default React.memo(ContractImplementationAddress);
import React from 'react';
import buildUrl from 'lib/api/buildUrl';
import * as contractMethodsMock from 'mocks/contract/methods';
import { test, expect } from 'playwright/lib';
import ContractRead from './ContractRead';
const addressHash = 'hash';
const hooksConfig = {
router: {
query: { hash: addressHash },
},
};
test('base view +@mobile +@dark-mode', async({ render, mockApiResponse, page }) => {
await mockApiResponse(
'contract_methods_read',
contractMethodsMock.read,
{ pathParams: { hash: addressHash }, queryParams: { is_custom_abi: false } },
);
const CONTRACT_QUERY_METHOD_API_URL = buildUrl('contract_method_query', { hash: addressHash }, { is_custom_abi: false });
await page.route(CONTRACT_QUERY_METHOD_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMethodsMock.readResultSuccess),
}));
const component = await render(<ContractRead/>, { hooksConfig });
await component.getByText(/expand all/i).click();
await expect(component).toHaveScreenshot();
await component.getByPlaceholder(/address/i).fill('0xa113Ce24919C08a26C952E81681dAc861d6a2466');
await component.getByText(/read/i).click();
await component.getByText(/wei/i).click();
await expect(component).toHaveScreenshot();
});
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import useAccount from 'lib/web3/useAccount';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
interface Props {
isLoading?: boolean;
}
const ContractRead = ({ isLoading }: Props) => {
const { address } = useAccount();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
const isProxy = tab === 'read_proxy';
const isCustomAbi = tab === 'read_custom_methods';
const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
from: address,
},
queryOptions: {
enabled: !isLoading,
},
});
if (isError) {
return <DataFetchAlert/>;
}
if (isPending) {
return <ContentLoader/>;
}
if (data.length === 0 && !isProxy) {
return <span>No public read functions were found for this contract.</span>;
}
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="read"/>
</>
);
};
export default React.memo(ContractRead);
import React from 'react';
import * as contractMethodsMock from 'mocks/contract/methods';
import { test, expect } from 'playwright/lib';
import ContractWrite from './ContractWrite';
const addressHash = 'hash';
const hooksConfig = {
router: {
query: { hash: addressHash },
},
};
test('base view +@mobile', async({ render, mockApiResponse }) => {
await mockApiResponse(
'contract_methods_write',
contractMethodsMock.write,
{ pathParams: { hash: addressHash }, queryParams: { is_custom_abi: false } },
);
const component = await render(<ContractWrite/>, { hooksConfig });
await component.getByText(/expand all/i).click();
await expect(component).toHaveScreenshot();
});
import { useRouter } from 'next/router';
import React from 'react';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
interface Props {
isLoading?: boolean;
}
const ContractWrite = ({ isLoading }: Props) => {
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
const isProxy = tab === 'write_proxy';
const isCustomAbi = tab === 'write_custom_methods';
const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
queryOptions: {
enabled: !isLoading,
refetchOnMount: false,
},
});
if (isError) {
return <DataFetchAlert/>;
}
if (isPending) {
return <ContentLoader/>;
}
if (data.length === 0 && !isProxy) {
return <span>No public write functions were found for this contract.</span>;
}
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="write"/>
</>
);
};
export default React.memo(ContractWrite);
......@@ -2,64 +2,59 @@ import { Accordion, Box, Flex, Link } from '@chakra-ui/react';
import _range from 'lodash/range';
import React from 'react';
import type { MethodType, ContractAbi as TContractAbi } from './types';
import type { SmartContractMethod } from './types';
import ContractAbiItem from './ContractAbiItem';
import useFormSubmit from './useFormSubmit';
import useScrollToMethod from './useScrollToMethod';
interface Props {
data: TContractAbi;
abi: Array<SmartContractMethod>;
addressHash: string;
tab: string;
methodType: MethodType;
}
const ContractAbi = ({ data, addressHash, tab, methodType }: Props) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
const ContractAbi = ({ abi, addressHash, tab }: Props) => {
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(abi.length === 1 ? [ 0 ] : []);
const [ id, setId ] = React.useState(0);
useScrollToMethod(data, setExpandedSections);
useScrollToMethod(abi, setExpandedSections);
const handleFormSubmit = useFormSubmit({ addressHash, tab });
const handleFormSubmit = useFormSubmit({ addressHash });
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue);
}, []);
const handleExpandAll = React.useCallback(() => {
if (!data) {
if (!abi) {
return;
}
if (expandedSections.length < data.length) {
setExpandedSections(_range(0, data.length));
if (expandedSections.length < abi.length) {
setExpandedSections(_range(0, abi.length));
} else {
setExpandedSections([]);
}
}, [ data, expandedSections.length ]);
}, [ abi, expandedSections.length ]);
const handleReset = React.useCallback(() => {
setId((id) => id + 1);
}, []);
if (data.length === 0) {
return null;
}
return (
<>
<Flex mb={ 3 }>
<Box fontWeight={ 500 } mr="auto">Contract information</Box>
{ data.length > 1 && (
{ abi.length > 1 && (
<Link onClick={ handleExpandAll }>
{ expandedSections.length === data.length ? 'Collapse' : 'Expand' } all
{ expandedSections.length === abi.length ? 'Collapse' : 'Expand' } all
</Link>
) }
<Link onClick={ handleReset } ml={ 3 }>Reset</Link>
</Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => (
{ abi.map((item, index) => (
<ContractAbiItem
key={ index }
data={ item }
......@@ -68,7 +63,6 @@ const ContractAbi = ({ data, addressHash, tab, methodType }: Props) => {
addressHash={ addressHash }
tab={ tab }
onSubmit={ handleFormSubmit }
methodType={ methodType }
/>
)) }
</Accordion>
......
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box, Flex, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import { Element } from 'react-scroll';
import type { FormSubmitHandler, MethodType, ContractAbiItem as TContractAbiItem } from './types';
import type { FormSubmitHandler, SmartContractMethod } from './types';
import { route } from 'nextjs-routes';
......@@ -12,21 +12,21 @@ import CopyToClipboard from 'ui/shared/CopyToClipboard';
import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg';
import ContractAbiItemConstant from './ContractAbiItemConstant';
import ContractMethodForm from './form/ContractMethodForm';
import { getElementName } from './useScrollToMethod';
interface Props {
data: TContractAbiItem;
data: SmartContractMethod;
index: number;
id: number;
addressHash: string;
tab: string;
onSubmit: FormSubmitHandler;
methodType: MethodType;
}
const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodType }: Props) => {
const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit }: Props) => {
const [ attempt, setAttempt ] = React.useState(0);
const url = React.useMemo(() => {
if (!('method_id' in data)) {
return '';
......@@ -43,7 +43,7 @@ const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodTy
}, [ addressHash, data, tab ]);
const { hasCopied, onCopy } = useClipboard(url, 1000);
const { isOpen, onOpen, onClose } = useDisclosure();
const methodIdTooltip = useDisclosure();
const handleCopyLinkClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
......@@ -54,30 +54,9 @@ const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodTy
event.stopPropagation();
}, []);
const content = (() => {
if ('error' in data && data.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ data.error }</Alert>;
}
const hasConstantOutputs = 'outputs' in data && data.outputs.some(({ value }) => value !== undefined && value !== null);
if (hasConstantOutputs) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ data.outputs.map((output, index) => <ContractAbiItemConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ data }
onSubmit={ onSubmit }
methodType={ methodType }
/>
);
})();
const handleReset = React.useCallback(() => {
setAttempt((prev) => prev + 1);
}, []);
return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
......@@ -86,15 +65,15 @@ const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodTy
<Element as="h2" name={ 'method_id' in data ? getElementName(data.method_id) : '' }>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
{ 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ methodIdTooltip.isOpen || hasCopied } onClose={ methodIdTooltip.onClose }>
<Box
boxSize={ 5 }
color="text_secondary"
_hover={{ color: 'link_hovered' }}
mr={ 2 }
onClick={ handleCopyLinkClick }
onMouseEnter={ onOpen }
onMouseLeave={ onClose }
onMouseEnter={ methodIdTooltip.onOpen }
onMouseLeave={ methodIdTooltip.onClose }
>
<IconSvg name="link" boxSize={ 5 }/>
</Box>
......@@ -131,7 +110,14 @@ const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodTy
</AccordionButton>
</Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ content }
<ContractMethodForm
key={ id + '_' + index + '_' + attempt }
data={ data }
attempt={ attempt }
onSubmit={ onSubmit }
onReset={ handleReset }
isOpen={ isExpanded }
/>
</AccordionPanel>
</>
) }
......
import { Alert, Button, Flex } from '@chakra-ui/react';
import { Alert, Button, Flex, Skeleton } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import useWallet from 'ui/snippets/walletMenu/useWallet';
const ContractConnectWallet = () => {
interface Props {
isLoading?: boolean;
}
const ContractConnectWallet = ({ isLoading }: Props) => {
const { isModalOpening, isModalOpen, connect, disconnect, address, isWalletConnected } = useWallet({ source: 'Smart contracts' });
const isMobile = useIsMobile();
......@@ -44,7 +49,15 @@ const ContractConnectWallet = () => {
);
})();
return <Alert mb={ 6 } status={ address ? 'success' : 'warning' }>{ content }</Alert>;
return (
<Skeleton isLoaded={ !isLoading } mb={ 6 }>
<Alert status={ address ? 'success' : 'warning' }>
{ content }
</Alert>
</Skeleton>
);
};
export default ContractConnectWallet;
const Fallback = () => null;
export default config.features.blockchainInteraction.isEnabled ? ContractConnectWallet : Fallback;
import { Alert } from '@chakra-ui/react';
import { Alert, Skeleton } from '@chakra-ui/react';
import React from 'react';
const ContractCustomAbiAlert = () => {
interface Props {
isLoading?: boolean;
}
const ContractCustomAbiAlert = ({ isLoading }: Props) => {
return (
<Alert status="warning" mb={ 4 }>
<Skeleton isLoaded={ !isLoading }>
<Alert status="warning" mb={ 6 }>
Note: Contract with custom ABI is only meant for debugging purpose and it is the user’s responsibility to ensure that the provided ABI
matches the contract, otherwise errors may occur or results returned may be incorrect.
Blockscout is not responsible for any losses that arise from the use of Read & Write contract.
</Alert>
</Alert>
</Skeleton>
);
};
......
import { Flex, Select, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { AddressImplementation } from 'types/api/addressParams';
import { route } from 'nextjs-routes';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import LinkNewTab from 'ui/shared/links/LinkNewTab';
interface Props {
selectedItem: AddressImplementation;
onItemSelect: (event: React.ChangeEvent<HTMLSelectElement>) => void;
implementations: Array<AddressImplementation>;
isLoading?: boolean;
}
const ContractImplementationAddress = ({ selectedItem, onItemSelect, implementations, isLoading }: Props) => {
if (isLoading) {
return <Skeleton mb={ 6 } h={ 6 } w={{ base: '300px', lg: '500px' }}/>;
}
if (implementations.length === 0) {
return null;
}
if (implementations.length === 1) {
return (
<Flex mb={ 6 } flexWrap="wrap" columnGap={ 2 } rowGap={ 2 }>
<span>Implementation address:</span>
<AddressEntity
address={{ hash: implementations[0].address, is_contract: true, is_verified: true }}
/>
</Flex>
);
}
return (
<Flex mb={ 6 } flexWrap="wrap" columnGap={ 2 } rowGap={ 2 } alignItems="center">
<span>Implementation address:</span>
<Select
size="xs"
value={ selectedItem.address }
onChange={ onItemSelect }
w="auto"
fontWeight={ 600 }
borderRadius="base"
>
{ implementations.map((implementation) => (
<option key={ implementation.address } value={ implementation.address }>
{ implementation.name }
</option>
)) }
</Select>
<CopyToClipboard text={ selectedItem.address } ml={ 1 }/>
<LinkNewTab
label="Open contract details page in new tab"
href={ route({ pathname: '/address/[hash]', query: { hash: selectedItem.address, tab: 'contract' } }) }
/>
</Flex>
);
};
export default React.memo(ContractImplementationAddress);
import { useRouter } from 'next/router';
import React from 'react';
import type { MethodType, SmartContractMethod } from './types';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ContractAbi';
interface Props {
abi: Array<SmartContractMethod>;
isLoading?: boolean;
isError?: boolean;
type: MethodType;
}
const ContractMethods = ({ abi, isLoading, isError, type }: Props) => {
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
const addressHash = getQueryParamString(router.query.hash);
if (isLoading) {
return <ContentLoader/>;
}
if (isError) {
return <DataFetchAlert/>;
}
if (abi.length === 0) {
return <span>No public { type } functions were found for this contract.</span>;
}
return <ContractAbi abi={ abi } tab={ tab } addressHash={ addressHash }/>;
};
export default React.memo(ContractMethods);
import React from 'react';
import type { MethodType, SmartContractMethod } from './types';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractMethods from './ContractMethods';
interface Props {
abi: Array<SmartContractMethod>;
isLoading?: boolean;
type: MethodType;
}
const ContractMethodsCustom = ({ abi, isLoading, type }: Props) => {
return (
<>
<ContractConnectWallet isLoading={ isLoading }/>
<ContractCustomAbiAlert isLoading={ isLoading }/>
<ContractMethods abi={ abi } isLoading={ isLoading } type={ type }/>
</>
);
};
export default React.memo(ContractMethodsCustom);
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractMock from 'mocks/contract/info';
import * as methodsMock from 'mocks/contract/methods';
import { test, expect } from 'playwright/lib';
import ContractMethodsProxy from './ContractMethodsProxy';
const addressHash = addressMock.hash;
test('with one implementation +@mobile', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { hash: addressHash, tab: 'read_proxy' },
},
};
const implementations = [
{ address: '0x2F4F4A52295940C576417d29F22EEb92B440eC89', name: 'HomeBridge' },
];
await mockApiResponse('contract', { ...contractMock.verified, abi: methodsMock.read }, { pathParams: { hash: implementations[0].address } });
const component = await render(<ContractMethodsProxy implementations={ implementations } type="read"/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
test('with multiple implementations +@mobile', async({ render, mockApiResponse }) => {
const hooksConfig = {
router: {
query: { hash: addressHash, tab: 'read_proxy' },
},
};
const implementations = [
{ address: '0x2F4F4A52295940C576417d29F22EEb92B440eC89', name: 'HomeBridge' },
{ address: '0xc9e91eDeA9DC16604022e4E5b437Df9c64EdB05A', name: 'Diamond' },
];
await mockApiResponse('contract', { ...contractMock.verified, abi: methodsMock.read }, { pathParams: { hash: implementations[0].address } });
const component = await render(<ContractMethodsProxy implementations={ implementations } type="read"/>, { hooksConfig });
await expect(component).toHaveScreenshot();
});
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { MethodType } from './types';
import type { AddressImplementation } from 'types/api/addressParams';
import useApiQuery from 'lib/api/useApiQuery';
import ContractConnectWallet from './ContractConnectWallet';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethods from './ContractMethods';
import { isReadMethod, isWriteMethod } from './utils';
interface Props {
type: MethodType;
implementations: Array<AddressImplementation>;
isLoading?: boolean;
}
const ContractMethodsProxy = ({ type, implementations, isLoading: isInitialLoading }: Props) => {
const [ selectedItem, setSelectedItem ] = React.useState(implementations[0]);
const contractQuery = useApiQuery('contract', {
pathParams: { hash: selectedItem.address },
queryOptions: {
enabled: Boolean(selectedItem.address),
refetchOnMount: false,
},
});
const handleItemSelect = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
const nextOption = implementations.find(({ address }) => address === event.target.value);
if (nextOption) {
setSelectedItem(nextOption);
}
}, [ implementations ]);
const abi = contractQuery.data?.abi?.filter(type === 'read' ? isReadMethod : isWriteMethod) || [];
return (
<Box>
<ContractConnectWallet isLoading={ isInitialLoading }/>
<ContractImplementationAddress
implementations={ implementations }
selectedItem={ selectedItem }
onItemSelect={ handleItemSelect }
isLoading={ isInitialLoading }
/>
<ContractMethods
key={ selectedItem.address }
abi={ abi }
isLoading={ isInitialLoading || contractQuery.isPending }
isError={ contractQuery.isError }
type={ type }
/>
</Box>
);
};
export default React.memo(ContractMethodsProxy);
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as methodsMock from 'mocks/contract/methods';
import { test, expect } from 'playwright/lib';
import ContractMethodsRegular from './ContractMethodsRegular';
const addressHash = addressMock.hash;
test('read methods', async({ render, mockContractReadResponse }) => {
// for some reason it takes a long time for "wagmi" library to parse response result in the test environment
// so I had to increase the test timeout
test.slow();
const hooksConfig = {
router: {
query: { hash: addressHash, tab: 'read_contract' },
},
};
await mockContractReadResponse({
abiItem: methodsMock.read[1],
address: addressHash,
result: [ 'USDC' ],
});
const component = await render(<ContractMethodsRegular abi={ methodsMock.read } type="read"/>, { hooksConfig });
await component.getByText(/expand all/i).click();
await expect(component.getByText('USDC')).toBeVisible({ timeout: 20_000 });
await expect(component).toHaveScreenshot();
});
test('write methods +@dark-mode +@mobile', async({ render }) => {
const hooksConfig = {
router: {
query: { hash: addressHash, tab: 'write_contract' },
},
};
const component = await render(<ContractMethodsRegular abi={ methodsMock.write } type="write"/>, { hooksConfig });
await component.getByText(/expand all/i).click();
await expect(component).toHaveScreenshot();
});
import React from 'react';
import type { MethodType, SmartContractMethod } from './types';
import ContractConnectWallet from './ContractConnectWallet';
import ContractMethods from './ContractMethods';
interface Props {
abi: Array<SmartContractMethod>;
isLoading?: boolean;
type: MethodType;
}
const ContractMethodsRegular = ({ abi, isLoading, type }: Props) => {
return (
<>
<ContractConnectWallet isLoading={ isLoading }/>
<ContractMethods abi={ abi } isLoading={ isLoading } type={ type }/>
</>
);
};
export default React.memo(ContractMethodsRegular);
import React from 'react';
import type { ContractAbiItem } from '../types';
import type { FormSubmitHandler, SmartContractMethod } from '../types';
import { test, expect } from 'playwright/lib';
import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ source: 'wallet_client' as const, result: { hash: '0x0000' as `0x${ string }` } });
const onSubmit: FormSubmitHandler = () => Promise.resolve({ source: 'wallet_client' as const, data: { hash: '0x0000' as `0x${ string }` } });
const onReset = () => {};
const data: ContractAbiItem = {
const data: SmartContractMethod = {
inputs: [
// TUPLE
{
......@@ -102,7 +103,9 @@ test('base view +@mobile +@dark-mode', async({ render }) => {
<ContractMethodForm
data={ data }
onSubmit={ onSubmit }
methodType="write"
onReset={ onReset }
isOpen
attempt={ 0 }
/>,
);
......
import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react';
import _mapValues from 'lodash/mapValues';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { AbiFunction } from 'viem';
import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, MethodType, ContractAbiItem } from '../types';
import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, SmartContractMethod } from '../types';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import IconSvg from 'ui/shared/IconSvg';
import { isReadMethod } from '../utils';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodOutputs from './ContractMethodOutputs';
import ContractMethodResult from './ContractMethodResult';
import ContractMethodResultPublicClient from './ContractMethodResultPublicClient';
import ContractMethodResultWalletClient from './ContractMethodResultWalletClient';
import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils';
import type { ContractMethodFormFields } from './utils';
// eslint-disable-next-line max-len
const NO_WALLET_CLIENT_TEXT = 'Blockchain interaction is not available at the moment since WalletConnect is not configured for this application. Please contact the service maintainer to make necessary changes in the service configuration.';
interface Props {
data: ContractAbiItem;
data: SmartContractMethod;
attempt: number;
onSubmit: FormSubmitHandler;
methodType: MethodType;
isOpen: boolean;
onReset: () => void;
}
const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
const ContractMethodForm = ({ data, attempt, onSubmit, onReset, isOpen }: Props) => {
const [ result, setResult ] = React.useState<FormSubmitResult>();
const [ isLoading, setLoading ] = React.useState(false);
......@@ -43,12 +49,10 @@ const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
callStrategyRef.current = callStrategy as MethodCallStrategy;
}, []);
const methodType = isReadMethod(data) ? 'read' : 'write';
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
// The API used for reading from contracts expects all values to be strings.
const formattedData = callStrategyRef.current === 'api' ?
_mapValues(formData, (value) => value !== undefined ? String(value) : undefined) :
formData;
const args = transformFormDataToMethodArgs(formattedData);
const args = transformFormDataToMethodArgs(formData);
setResult(undefined);
setLoading(true);
......@@ -57,10 +61,10 @@ const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
.then((result) => {
setResult(result);
})
.catch((error) => {
.catch((error: Error) => {
setResult({
source: callStrategyRef.current ?? 'wallet_client',
result: error?.error || error?.data || (error?.reason && { message: error.reason }) || error,
source: callStrategyRef.current === 'write' ? 'wallet_client' : 'public_client',
data: error,
});
setLoading(false);
})
......@@ -72,7 +76,18 @@ const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
});
}, [ data, methodType, onSubmit ]);
const handleTxSettle = React.useCallback(() => {
React.useEffect(() => {
if (isOpen && !callStrategyRef.current && attempt === 0) {
const hasConstantOutputs = isReadMethod(data) && data.inputs.length === 0;
if (hasConstantOutputs) {
setCallStrategy('read');
callStrategyRef.current = 'read';
onFormSubmit({});
}
}
}, [ data, isOpen, onFormSubmit, attempt ]);
const handleResultSettle = React.useCallback(() => {
setLoading(false);
}, []);
......@@ -92,29 +107,64 @@ const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
];
}, [ data ]);
const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
const callStrategies = (() => {
switch (methodType) {
case 'read': {
return { primary: 'api', secondary: undefined };
}
const primaryButton = (() => {
const isDisabled = !config.features.blockchainInteraction.isEnabled && methodType === 'write';
const text = methodType === 'write' ? 'Write' : 'Read';
const buttonCallStrategy = methodType === 'write' ? 'write' : 'read';
return (
<Tooltip label={ isDisabled ? NO_WALLET_CLIENT_TEXT : undefined } maxW="300px">
<Button
isLoading={ callStrategy === buttonCallStrategy && isLoading }
isDisabled={ isLoading || isDisabled }
onClick={ handleButtonClick }
loadingText={ text }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
data-call-strategy={ buttonCallStrategy }
>
{ text }
</Button>
</Tooltip>
);
})();
case 'write': {
return {
primary: config.features.blockchainInteraction.isEnabled ? 'wallet_client' : undefined,
secondary: 'outputs' in data && Boolean(data.outputs?.length) ? 'api' : undefined,
};
}
const secondaryButton = (() => {
if (methodType === 'read') {
return null;
}
default: {
return { primary: undefined, secondary: undefined };
}
const hasOutputs = 'outputs' in data && data.outputs.length > 0;
if (!hasOutputs) {
return null;
}
})();
// eslint-disable-next-line max-len
const noWalletClientText = 'Blockchain interaction is not available at the moment since WalletConnect is not configured for this application. Please contact the service maintainer to make necessary changes in the service configuration.';
const text = 'Simulate';
const buttonCallStrategy = 'simulate';
return (
<Button
isLoading={ callStrategy === buttonCallStrategy && isLoading }
isDisabled={ isLoading }
onClick={ handleButtonClick }
loadingText={ text }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
mr={ 3 }
type="submit"
data-call-strategy={ buttonCallStrategy }
>
{ text }
</Button>
);
})();
return (
<Box>
......@@ -162,45 +212,36 @@ const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
return <ContractMethodFieldInput key={ index } { ...props } path={ `${ index }` }/>;
}) }
</Flex>
{ callStrategies.secondary && (
{ secondaryButton }
{ primaryButton }
{ result && !isLoading && (
<Button
isLoading={ callStrategy === callStrategies.secondary && isLoading }
isDisabled={ isLoading }
onClick={ handleButtonClick }
loadingText="Simulate"
variant="outline"
variant="simple"
colorScheme="blue"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
mr={ 3 }
type="submit"
data-call-strategy={ callStrategies.secondary }
onClick={ onReset }
ml={ 1 }
>
Simulate
<IconSvg name="repeat_arrow" boxSize={ 5 } mr={ 1 }/>
Reset
</Button>
) }
<Tooltip label={ !callStrategies.primary ? noWalletClientText : undefined } maxW="300px">
<Button
isLoading={ callStrategy === callStrategies.primary && isLoading }
isDisabled={ isLoading || !callStrategies.primary }
onClick={ handleButtonClick }
loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
data-call-strategy={ callStrategies.primary }
>
{ methodType === 'write' ? 'Write' : 'Read' }
</Button>
</Tooltip>
</chakra.form>
</FormProvider>
{ 'outputs' in data && Boolean(data.outputs?.length) && <ContractMethodOutputs data={ outputs }/> }
{ result && <ContractMethodResult abiItem={ data } result={ result } onSettle={ handleTxSettle }/> }
{ result && result.source === 'wallet_client' && (
<ContractMethodResultWalletClient
data={ result.data }
onSettle={ handleResultSettle }
/>
) }
{ 'outputs' in data && data.outputs.length > 0 && (
<ContractMethodResultPublicClient
data={ result && result.source === 'public_client' ? result.data : undefined }
onSettle={ handleResultSettle }
abiItem={ data }
mode={ result && result.source === 'public_client' ? 'result' : 'preview' }
/>
) }
</Box>
);
};
......
import React from 'react';
import type { AbiFunction } from 'viem';
import { test, expect } from 'playwright/lib';
import ContractMethodResultPublicClient from './ContractMethodResultPublicClient';
import { ErrorStory } from './ContractMethodResultPublicClient.pwstory';
const abiItem: AbiFunction = {
inputs: [],
name: 'testAbiMethod',
stateMutability: 'view',
type: 'function',
outputs: [
// PRIMITIVES
{ internalType: 'address', name: 'owner', type: 'address' },
{ internalType: 'uint256', name: 'validatorsCount', type: 'uint256' },
{ internalType: 'bool', name: '', type: 'bool' },
// ARRAY OF PRIMITIVES
{ internalType: 'address[]', name: 'interChainClients', type: 'address[]' },
// NESTED ARRAY
{ internalType: 'uint256[2][]', name: 'chainIds', type: 'uint256[2][]' },
// TUPLE
{
components: [
{
components: [
{ internalType: 'bool', name: 'executed', type: 'bool' },
{ internalType: 'uint56', name: 'snapshotId', type: 'uint56' },
{ internalType: 'string', name: 'descriptionURL', type: 'string' },
],
internalType: 'struct IGovValidators.ProposalCore',
name: 'core',
type: 'tuple',
},
],
internalType: 'struct IGovValidators.ExternalProposal',
name: '',
type: 'tuple',
},
// ARRAY OF TUPLE
{
components: [
{
components: [
{ internalType: 'enum IGovValidators.ProposalType', name: 'proposalType', type: 'uint8' },
{ internalType: 'bytes', name: 'data', type: 'bytes' },
{
components: [
{ internalType: 'bool', name: 'executed', type: 'bool' },
{ internalType: 'uint56', name: 'snapshotId', type: 'uint56' },
],
internalType: 'struct IGovValidators.ProposalCore',
name: 'core',
type: 'tuple',
},
],
internalType: 'struct IGovValidators.InternalProposal',
name: 'proposal',
type: 'tuple',
},
{ internalType: 'enum IGovValidators.ProposalState', name: 'proposalState', type: 'uint8' },
],
internalType: 'struct IGovValidators.InternalProposalView[]',
name: 'internalProposals',
type: 'tuple[]',
},
],
};
const result = [
'0x0000000000000000000000000000000000000000',
BigInt(42),
false,
[
'0x92a309C640c3f6AF4F84FE40120fD02b58E3Aa96',
'0x588c7Bda9366EEf83EdF67049a1C45f737aFFe0F',
],
[
[ BigInt(11_000), BigInt(12_000), BigInt(13_000) ],
[ BigInt(21_000), BigInt(22_000) ],
],
{
core: {
executed: true,
snapshotId: BigInt(77),
descriptionURL: '',
},
},
[
{
proposalState: 1,
proposal: {
proposalType: 100,
data: '0x000100',
core: {
executed: true,
snapshotId: 111,
},
},
},
{
proposalState: 3,
proposal: {
proposalType: 300,
data: '0x000300',
core: {
executed: true,
snapshotId: 333,
},
},
},
],
];
const onSettle = () => {};
test('preview mode', async({ render }) => {
const component = await render(
<ContractMethodResultPublicClient
abiItem={ abiItem }
data={ undefined }
mode="preview"
onSettle={ onSettle }
/>,
);
await expect(component).toHaveScreenshot();
});
test('result mode', async({ render }) => {
const component = await render(
<ContractMethodResultPublicClient
abiItem={ abiItem }
data={ result }
mode="result"
onSettle={ onSettle }
/>,
);
await expect(component).toHaveScreenshot();
});
test('error', async({ render }) => {
const component = await render(
<ErrorStory
abiItem={ abiItem }
mode="result"
onSettle={ onSettle }
/>,
);
await expect(component).toHaveScreenshot();
});
test('single output', async({ render }) => {
const component = await render(
<ContractMethodResultPublicClient
abiItem={{ ...abiItem, outputs: abiItem.outputs.slice(3, 4) }}
data={ result[3] }
mode="result"
onSettle={ onSettle }
/>,
);
await expect(component).toHaveScreenshot();
});
import React from 'react';
import type { Props } from './ContractMethodResultPublicClient';
import ContractMethodResultPublicClient from './ContractMethodResultPublicClient';
export const ErrorStory = (props: Omit<Props, 'data'>) => {
const result = new Error('Something went wrong');
return <ContractMethodResultPublicClient { ...props } data={ result }/>;
};
import { Alert, Flex, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { AbiFunction } from 'viem';
import type { FormSubmitResultPublicClient, ResultViewMode } from '../types';
import ResultItem from './resultPublicClient/Item';
export interface Props {
data: FormSubmitResultPublicClient['data'];
abiItem: AbiFunction;
onSettle: () => void;
mode: ResultViewMode;
}
const ContractMethodResultPublicClient = ({ data, abiItem, onSettle, mode: modeProps }: Props) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
if (modeProps === 'result') {
onSettle();
}
}, [ onSettle, modeProps ]);
const formattedData = (() => {
return abiItem.outputs.length > 1 && Array.isArray(data) ? data : [ data ];
})();
const isError = data instanceof Error;
const mode = isError ? 'preview' : modeProps;
return (
<>
{ isError && (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ 'shortMessage' in data && typeof data.shortMessage === 'string' ? data.shortMessage : data.message }
</Alert>
) }
<Flex
flexDir="column"
rowGap={ 2 }
mt={ 3 }
p={ 4 }
borderRadius="md"
bgColor={ bgColor }
color={ mode === 'preview' ? 'gray.500' : undefined }
fontSize="sm"
lineHeight="20px"
whiteSpace="break-spaces"
wordBreak="break-all"
>
{ abiItem.outputs.map((output, index) => (
<ResultItem
key={ index }
abiParameter={ output }
data={ isError ? undefined : formattedData[index] }
mode={ mode }
/>
)) }
</Flex>
</>
);
};
export default React.memo(ContractMethodResultPublicClient);
......@@ -11,7 +11,7 @@ test('loading', async({ render }) => {
status: 'pending' as const,
error: null,
} as PropsDumb['txInfo'],
result: {
data: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
......@@ -27,7 +27,7 @@ test('success', async({ render }) => {
status: 'success' as const,
error: null,
} as PropsDumb['txInfo'],
result: {
data: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
......@@ -46,7 +46,7 @@ test('error +@mobile', async({ render }) => {
message: 'missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ]',
} as Error,
} as PropsDumb['txInfo'],
result: {
data: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
......@@ -62,7 +62,7 @@ test('error in result', async({ render }) => {
status: 'idle' as const,
error: null,
} as unknown as PropsDumb['txInfo'],
result: {
data: {
message: 'wallet is not connected',
} as Error,
onSettle: () => {},
......
import { chakra, Spinner, Box } from '@chakra-ui/react';
import { chakra, Spinner, Box, Alert } from '@chakra-ui/react';
import React from 'react';
import type { UseWaitForTransactionReceiptReturnType } from 'wagmi';
import { useWaitForTransactionReceipt } from 'wagmi';
......@@ -10,27 +10,27 @@ import { route } from 'nextjs-routes';
import LinkInternal from 'ui/shared/links/LinkInternal';
interface Props {
result: FormSubmitResultWalletClient['result'];
data: FormSubmitResultWalletClient['data'];
onSettle: () => void;
}
const ContractMethodResultWalletClient = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const ContractMethodResultWalletClient = ({ data, onSettle }: Props) => {
const txHash = data && 'hash' in data ? data.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractMethodResultWalletClientDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>;
return <ContractMethodResultWalletClientDumb data={ data } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export interface PropsDumb {
result: FormSubmitResultWalletClient['result'];
data: FormSubmitResultWalletClient['data'];
onSettle: () => void;
txInfo: UseWaitForTransactionReceiptReturnType;
}
export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo }: PropsDumb) => {
const txHash = result && 'hash' in result ? result.hash : undefined;
export const ContractMethodResultWalletClientDumb = ({ data, onSettle, txInfo }: PropsDumb) => {
const txHash = data && 'hash' in data ? data.hash : undefined;
React.useEffect(() => {
if (txInfo.status !== 'pending') {
......@@ -38,11 +38,11 @@ export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo
}
}, [ onSettle, txInfo.status ]);
if (!result) {
if (!data) {
return null;
}
const isErrorResult = 'message' in result;
const isErrorResult = 'message' in data;
const txLink = txHash ? (
<LinkInternal href={ route({ pathname: '/tx/[hash]', query: { hash: txHash } }) }>View transaction details</LinkInternal>
......@@ -51,10 +51,9 @@ export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo
const content = (() => {
if (isErrorResult) {
return (
<>
<span>Error: </span>
<span>{ result.message }</span>
</>
<Alert status="error">
{ data.message }
</Alert>
);
}
......@@ -82,11 +81,9 @@ export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo
case 'error': {
return (
<>
<span>Error: </span>
<span>{ txInfo.error ? txInfo.error.message : 'Something went wrong' } </span>
{ txLink }
</>
<Alert status="error" flexDir="column" alignItems="flex-start" rowGap={ 1 }>
Error: { txInfo.error ? txInfo.error.message : 'Something went wrong' } { txLink }
</Alert>
);
}
}
......
import React from 'react';
import type { AbiParameter } from 'viem';
import type { ResultViewMode } from '../../types';
import { matchArray } from '../utils';
import ItemArray from './ItemArray';
import ItemPrimitive from './ItemPrimitive';
import ItemTuple from './ItemTuple';
interface Props {
abiParameter: AbiParameter;
data: unknown;
mode: ResultViewMode;
level?: number;
}
const Item = ({ abiParameter, data, mode, level = 0 }: Props) => {
const arrayMatch = React.useMemo(() => matchArray(abiParameter.type), [ abiParameter.type ]);
if (arrayMatch) {
if (!Array.isArray(data)) {
if (arrayMatch.itemType === 'tuple' && mode === 'preview' && 'components' in abiParameter) {
const previewData = Object.fromEntries(abiParameter.components.map(({ name }) => ([ name ?? '', undefined ])));
return <ItemArray abiParameter={ abiParameter } data={ [ previewData ] } mode={ mode } level={ level } arrayMatch={ arrayMatch }/>;
}
return <ItemPrimitive abiParameter={ abiParameter } data={ data || (mode === 'preview' ? undefined : '[ ]') } level={ level }/>;
}
return <ItemArray abiParameter={ abiParameter } data={ data } level={ level } mode={ mode } arrayMatch={ arrayMatch }/>;
}
const tupleMatch = abiParameter.type.includes('tuple');
if (tupleMatch) {
return <ItemTuple abiParameter={ abiParameter } data={ data } mode={ mode } level={ level }/>;
}
return <ItemPrimitive abiParameter={ abiParameter } data={ data } level={ level }/>;
};
export default React.memo(Item);
import React from 'react';
import type { AbiParameter } from 'viem';
import type { ResultViewMode } from '../../types';
import { matchArray, type MatchArray } from '../utils';
import ItemLabel from './ItemLabel';
import ItemPrimitive from './ItemPrimitive';
import ItemTuple from './ItemTuple';
import { printRowOffset } from './utils';
interface Props {
abiParameter: AbiParameter;
data: Array<unknown>;
mode: ResultViewMode;
level: number;
arrayMatch: MatchArray;
}
const ItemArray = ({ abiParameter, data, level, arrayMatch, mode }: Props) => {
const itemAbiParameter = React.useMemo(() => {
const type = arrayMatch.itemType;
const internalType = matchArray(abiParameter.internalType || '')?.itemType;
return { ...abiParameter, type, internalType };
}, [ abiParameter, arrayMatch.itemType ]);
const content = (() => {
if (arrayMatch.isNested && data.every(Array.isArray)) {
const itemArrayMatch = matchArray(arrayMatch.itemType);
if (itemArrayMatch) {
return data.map((item, index) => (
<ItemArray
key={ index }
abiParameter={ itemAbiParameter }
data={ item }
mode={ mode }
arrayMatch={ itemArrayMatch }
level={ level + 1 }
/>
));
}
}
if (arrayMatch.itemType === 'tuple') {
return data.map((item, index) => (
<ItemTuple
key={ index }
abiParameter={ itemAbiParameter }
data={ item }
mode={ mode }
level={ level + 1 }
/>
));
}
return data.map((item, index) => (
<ItemPrimitive
key={ index }
abiParameter={ itemAbiParameter }
data={ item }
level={ level + 1 }
hideLabel
/>
));
})();
return (
<p>
<p>
<span>{ printRowOffset(level) }</span>
<ItemLabel abiParameter={ abiParameter }/>
<span>[{ data.length === 0 ? ' ]' : '' }</span>
</p>
{ content }
{ data.length > 0 && <p>{ printRowOffset(level) }]</p> }
</p>
);
};
export default React.memo(ItemArray);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { AbiParameter } from 'viem';
interface Props {
abiParameter: AbiParameter;
}
const ItemLabel = ({ abiParameter }: Props) => {
return (
<>
{ abiParameter.name && <chakra.span fontWeight={ 500 }>{ abiParameter.name } </chakra.span> }
<span>({ abiParameter.type }) : </span>
</>
);
};
export default React.memo(ItemLabel);
import { Tooltip } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AbiParameter } from 'viem';
import { route } from 'nextjs-routes';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import LinkInternal from 'ui/shared/links/LinkInternal';
import { matchInt } from '../utils';
import ItemLabel from './ItemLabel';
import { printRowOffset } from './utils';
function castValueToString(value: unknown): string {
switch (typeof value) {
case 'string':
return value === '' ? `""` : value;
case 'undefined':
return '';
case 'number':
return value.toLocaleString(undefined, { useGrouping: false });
case 'bigint':
return value.toString();
case 'boolean':
default:
return String(value);
}
}
const INT_TOOLTIP_THRESHOLD = 10 ** 9;
interface Props {
abiParameter: AbiParameter;
data: unknown;
level: number;
hideLabel?: boolean;
}
const ItemPrimitive = ({ abiParameter, data, level, hideLabel }: Props) => {
const value = (() => {
if (abiParameter.type === 'address' && typeof data === 'string') {
return (
<>
<LinkInternal href={ route({ pathname: '/address/[hash]', query: { hash: data } }) }>{ data }</LinkInternal>
<CopyToClipboard text={ data } size={ 4 } verticalAlign="sub"/>
</>
);
}
const intMatch = matchInt(abiParameter.type);
if (intMatch && typeof data === 'bigint' && intMatch.max > INT_TOOLTIP_THRESHOLD && data > INT_TOOLTIP_THRESHOLD) {
const dividedValue = BigNumber(data.toString()).div(BigNumber(INT_TOOLTIP_THRESHOLD));
return (
<Tooltip label={ dividedValue.toLocaleString() + ' ETH' }>
<span>{ castValueToString(data) }</span>
</Tooltip>
);
}
return <span>{ castValueToString(data) }</span>;
})();
return (
<p>
<span>{ printRowOffset(level) }</span>
{ !hideLabel && <ItemLabel abiParameter={ abiParameter }/> }
{ value }
</p>
);
};
export default React.memo(ItemPrimitive);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { AbiParameter } from 'viem';
import type { ResultViewMode } from '../../types';
import Item from './Item';
import { printRowOffset } from './utils';
interface Props {
abiParameter: AbiParameter;
data: unknown;
level: number;
mode: ResultViewMode;
}
const ItemTuple = ({ abiParameter, data, mode, level }: Props) => {
return (
<p>
<p>
<span>{ printRowOffset(level) }</span>
<chakra.span fontWeight={ 500 }>{ abiParameter.name || abiParameter.internalType }</chakra.span>
<span> { '{' }</span>
</p>
{ 'components' in abiParameter && abiParameter.components.map((component, index) => {
const dataObj = typeof data === 'object' && data !== null ? data : undefined;
const itemData = dataObj && component.name && component.name in dataObj ? dataObj[component.name as keyof typeof dataObj] : undefined;
return (
<Item
key={ index }
abiParameter={ component }
data={ itemData }
mode={ mode }
level={ level + 1 }
/>
);
}) }
<p>{ printRowOffset(level) }{ '}' }</p>
</p>
);
};
export default React.memo(ItemTuple);
const TAB_SIZE = 2;
export const printRowOffset = (level: number) => ' '.repeat(level * TAB_SIZE);
This diff is collapsed.
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