Commit f5c890f9 authored by Max Alekseenko's avatar Max Alekseenko

merge main

parents e6975fa4 65e6b205
## 🚀 New Features
- Description of the new feature 1.
- Description of the new feature 2.
## 🐛 Bug Fixes
- Description of the bug fix 1.
- Description of the bug fix 2.
## ⚡ Performance Improvements
- Description of the performance improvement 1.
- Description of the performance improvement 2.
## 📦 Dependencies updates
- Updated dependency: PackageName 1 to version x.x.x.
- Updated dependency: PackageName 2 to version x.x.x.
## ✨ Other Changes
- Another minor change 1.
- Another minor change 2.
## 🚨 Changes in ENV variables
- Added new environment variable: ENV_VARIABLE_NAME with value.
- Updated existing environment variable: ENV_VARIABLE_NAME to new value.
**Full list of the ENV variables**: [v1.2.3](https://github.com/blockscout/frontend/blob/v1.2.3/docs/ENVS.md)
## 🦄 New Contributors
- @contributor1 made their first contribution in https://github.com/blockscout/frontend/pull/1
- @contributor2 made their first contribution in https://github.com/blockscout/frontend/pull/2
---
**Full Changelog**: https://github.com/blockscout/frontend/compare/v1.2.2...v1.2.3
...@@ -349,12 +349,20 @@ const accountSchema = yup ...@@ -349,12 +349,20 @@ const accountSchema = yup
then: (schema) => schema.test(urlTest).required(), then: (schema) => schema.test(urlTest).required(),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_LOGOUT_URL cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'), otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_LOGOUT_URL cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'),
}), }),
});
const adminServiceSchema = yup
.object()
.shape({
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup
.string() .string()
.when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', { .when([ 'NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', 'NEXT_PUBLIC_MARKETPLACE_ENABLED' ], {
is: (value: boolean) => value, is: (value1: boolean, value2: boolean) => value1 || value2,
then: (schema) => schema.test(urlTest), then: (schema) => schema.test(urlTest),
otherwise: (schema) => schema.max(-1, 'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED is not set to "true"'), otherwise: (schema) => schema.max(
-1,
'NEXT_PUBLIC_ADMIN_SERVICE_API_HOST cannot not be used if NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED or NEXT_PUBLIC_MARKETPLACE_ENABLED is not set to "true"',
),
}), }),
}); });
...@@ -631,6 +639,7 @@ const schema = yup ...@@ -631,6 +639,7 @@ const schema = yup
.concat(rollupSchema) .concat(rollupSchema)
.concat(beaconChainSchema) .concat(beaconChainSchema)
.concat(bridgedTokensSchema) .concat(bridgedTokensSchema)
.concat(sentrySchema); .concat(sentrySchema)
.concat(adminServiceSchema);
export default schema; export default schema;
#!/bin/bash #!/bin/bash
secrets_file=".env.secrets"
test_folder="./test" test_folder="./test"
common_file="${test_folder}/.env.common" common_file="${test_folder}/.env.common"
...@@ -8,7 +7,6 @@ common_file="${test_folder}/.env.common" ...@@ -8,7 +7,6 @@ common_file="${test_folder}/.env.common"
export NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD) export NEXT_PUBLIC_GIT_COMMIT_SHA=$(git rev-parse --short HEAD)
export NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) export NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0)
../../scripts/collect_envs.sh ../../../docs/ENVS.md ../../scripts/collect_envs.sh ../../../docs/ENVS.md
cp ../../../.env.example ${secrets_file}
# Copy test assets # Copy test assets
mkdir -p "./public/assets" mkdir -p "./public/assets"
...@@ -26,7 +24,6 @@ validate_file() { ...@@ -26,7 +24,6 @@ validate_file() {
dotenv \ dotenv \
-e $test_file \ -e $test_file \
-e $common_file \ -e $common_file \
-e $secrets_file \
yarn run validate -- --silent yarn run validate -- --silent
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
......
NEXT_PUBLIC_SENTRY_DSN=https://sentry.io
NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID=UA-XXXXXX-X
NEXT_PUBLIC_MIXPANEL_PROJECT_TOKEN=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AUTH0_CLIENT_ID=xxx
FAVICON_GENERATOR_API_KEY=xxx
NEXT_PUBLIC_GROWTH_BOOK_CLIENT_KEY=xxx
NEXT_PUBLIC_AD_TEXT_PROVIDER=coinzilla NEXT_PUBLIC_AD_TEXT_PROVIDER=coinzilla
NEXT_PUBLIC_AD_BANNER_PROVIDER=slise NEXT_PUBLIC_AD_BANNER_PROVIDER=slise
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
......
NEXT_PUBLIC_API_HOST=blockscout.com NEXT_PUBLIC_API_HOST=blockscout.com
NEXT_PUBLIC_APP_HOST=localhost NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_AUTH_URL=https://example.com
NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED=true
NEXT_PUBLIC_LOGOUT_URL=https://example.com
NEXT_PUBLIC_NETWORK_ID=1 NEXT_PUBLIC_NETWORK_ID=1
NEXT_PUBLIC_NETWORK_NAME=Testnet NEXT_PUBLIC_NETWORK_NAME=Testnet
...@@ -50,11 +50,11 @@ frontend: ...@@ -50,11 +50,11 @@ frontend:
NEXT_PUBLIC_APP_ENV: development NEXT_PUBLIC_APP_ENV: development
NEXT_PUBLIC_APP_INSTANCE: review NEXT_PUBLIC_APP_INSTANCE: review
NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: validation NEXT_PUBLIC_NETWORK_VERIFICATION_TYPE: validation
NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-goerli.json NEXT_PUBLIC_FEATURED_NETWORKS: https://raw.githubusercontent.com/blockscout/frontend-configs/dev/configs/featured-networks/eth-sepolia.json
NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg NEXT_PUBLIC_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/sepolia.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/sepolia.png
NEXT_PUBLIC_API_HOST: eth-sepolia.blockscout.com NEXT_PUBLIC_API_HOST: eth-sepolia.k8s-dev.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/ NEXT_PUBLIC_STATS_API_HOST: https://stats-sepolia.k8s-dev.blockscout.com/
NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/ NEXT_PUBLIC_VISUALIZE_API_HOST: http://visualizer-svc.visualizer-testing.svc.cluster.local/
NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com NEXT_PUBLIC_CONTRACT_INFO_API_HOST: https://contracts-info-test.k8s-dev.blockscout.com
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: https://admin-rs-test.k8s-dev.blockscout.com
......
...@@ -200,6 +200,8 @@ Settings for meta tags, OG tags and SEO ...@@ -200,6 +200,8 @@ Settings for meta tags, OG tags and SEO
| `total_reward` | Total block reward | | `total_reward` | Total block reward |
| `nonce` | Block nonce | | `nonce` | Block nonce |
| `miner` | Address of block's miner or validator | | `miner` | Address of block's miner or validator |
| `L1_status` | Short interpretation of the batch lifecycle (applicable for Rollup chains) |
| `batch` | Batch index (applicable for Rollup chains) |
   
...@@ -234,6 +236,8 @@ Settings for meta tags, OG tags and SEO ...@@ -234,6 +236,8 @@ Settings for meta tags, OG tags and SEO
| `tx_fee` | Total transaction fee | | `tx_fee` | Total transaction fee |
| `gas_fees` | Gas fees breakdown | | `gas_fees` | Gas fees breakdown |
| `burnt_fees` | Amount of native coin burnt for transaction | | `burnt_fees` | Amount of native coin burnt for transaction |
| `L1_status` | Short interpretation of the batch lifecycle (applicable for Rollup chains) |
| `batch` | Batch index (applicable for Rollup chains) |
##### Transaction additional fields list ##### Transaction additional fields list
| Id | Description | | Id | Description |
......
This diff is collapsed.
...@@ -20,6 +20,7 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr ...@@ -20,6 +20,7 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr
'appID', 'appID',
'logoURL', 'logoURL',
'text', 'text',
'tagUrl',
'tooltipIcon', 'tooltipIcon',
'tooltipTitle', 'tooltipTitle',
'tooltipDescription', 'tooltipDescription',
......
...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts'; ...@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures { export interface GrowthBookFeatures {
test_value: string; test_value: string;
security_score_exp: boolean;
action_button_exp: boolean; action_button_exp: boolean;
} }
......
export default function makePrettyLink(url: string | undefined): { url: string; domain: string } | undefined {
try {
const urlObj = new URL(url ?? '');
return {
url: urlObj.href,
domain: urlObj.hostname,
};
} catch (error) {}
}
...@@ -109,6 +109,10 @@ Type extends EventTypes.PAGE_WIDGET ? ( ...@@ -109,6 +109,10 @@ Type extends EventTypes.PAGE_WIDGET ? (
'Type': 'Action button'; 'Type': 'Action button';
'Info': string; 'Info': string;
'Source': 'Txn' | 'NFT collection' | 'NFT item'; 'Source': 'Txn' | 'NFT collection' | 'NFT item';
} | {
'Type': 'Address tag';
'Info': string;
'URL': string;
} }
) : ) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? { Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
...@@ -28,6 +28,7 @@ SocketMessage.AddressTxs | ...@@ -28,6 +28,7 @@ SocketMessage.AddressTxs |
SocketMessage.AddressTxsPending | SocketMessage.AddressTxsPending |
SocketMessage.AddressTokenTransfer | SocketMessage.AddressTokenTransfer |
SocketMessage.AddressChangedBytecode | SocketMessage.AddressChangedBytecode |
SocketMessage.AddressFetchedBytecode |
SocketMessage.SmartContractWasVerified | SocketMessage.SmartContractWasVerified |
SocketMessage.TokenTransfers | SocketMessage.TokenTransfers |
SocketMessage.TokenTotalSupply | SocketMessage.TokenTotalSupply |
...@@ -64,6 +65,7 @@ export namespace SocketMessage { ...@@ -64,6 +65,7 @@ export namespace SocketMessage {
export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transactions: Array<Transaction> }>; export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transactions: Array<Transaction> }>;
export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfers: Array<TokenTransfer> }>; export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfers: Array<TokenTransfer> }>;
export type AddressChangedBytecode = SocketMessageParamsGeneric<'changed_bytecode', Record<string, never>>; export type AddressChangedBytecode = SocketMessageParamsGeneric<'changed_bytecode', Record<string, never>>;
export type AddressFetchedBytecode = SocketMessageParamsGeneric<'fetched_bytecode', { fetched_bytecode: string }>;
export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record<string, never>>; export type SmartContractWasVerified = SocketMessageParamsGeneric<'smart_contract_was_verified', Record<string, never>>;
export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>; export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>;
export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>; export type TokenTotalSupply = SocketMessageParamsGeneric<'total_supply', {total_supply: number }>;
......
import type { UseAccountReturnType } from 'wagmi';
import { useAccount } from 'wagmi';
import config from 'configs/app';
function useAccountFallback(): UseAccountReturnType {
return {
address: undefined,
addresses: undefined,
chain: undefined,
chainId: undefined,
connector: undefined,
isConnected: false,
isConnecting: false,
isDisconnected: true,
isReconnecting: false,
status: 'disconnected',
};
}
const hook = config.features.blockchainInteraction.isEnabled ? useAccount : useAccountFallback;
export default hook;
...@@ -30,6 +30,24 @@ export const withEns: AddressParam = { ...@@ -30,6 +30,24 @@ export const withEns: AddressParam = {
ens_domain_name: 'kitty.kitty.kitty.cat.eth', ens_domain_name: 'kitty.kitty.kitty.cat.eth',
}; };
export const withNameTag: AddressParam = {
hash: hash,
implementation_name: null,
is_contract: false,
is_verified: null,
name: 'ArianeeStore',
private_tags: [],
watchlist_names: [],
public_tags: [],
ens_domain_name: 'kitty.kitty.kitty.cat.eth',
metadata: {
reputation: null,
tags: [
{ tagType: 'name', name: 'Mrs. Duckie', slug: 'mrs-duckie', ordinal: 0, meta: null },
],
},
};
export const withoutName: AddressParam = { export const withoutName: AddressParam = {
hash: hash, hash: hash,
implementation_name: null, implementation_name: null,
......
import type { AddressTabsCounters } from 'types/api/address';
export const base: AddressTabsCounters = {
internal_txs_count: 13,
logs_count: 51,
token_balances_count: 3,
token_transfers_count: 3,
transactions_count: 51,
validations_count: 42,
withdrawals_count: 11,
};
import type { import type {
SmartContractQueryMethodReadError, SmartContractQueryMethodError,
SmartContractQueryMethodReadSuccess, SmartContractQueryMethodSuccess,
SmartContractReadMethod, SmartContractReadMethod,
SmartContractWriteMethod, SmartContractWriteMethod,
} from 'types/api/contract'; } from 'types/api/contract';
...@@ -94,7 +94,7 @@ export const read: Array<SmartContractReadMethod> = [ ...@@ -94,7 +94,7 @@ export const read: Array<SmartContractReadMethod> = [
}, },
]; ];
export const readResultSuccess: SmartContractQueryMethodReadSuccess = { export const readResultSuccess: SmartContractQueryMethodSuccess = {
is_error: false, is_error: false,
result: { result: {
names: [ 'amount' ], names: [ 'amount' ],
...@@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = { ...@@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
}, },
}; };
export const readResultError: SmartContractQueryMethodReadError = { export const readResultError: SmartContractQueryMethodError = {
is_error: true, is_error: true,
result: { result: {
message: 'Some shit happened', message: 'Some shit happened',
......
export const data = { import type { OptimisticL2WithdrawalsResponse } from 'types/api/optimisticL2';
export const data: OptimisticL2WithdrawalsResponse = {
items: [ items: [
{ {
challenge_period_end: null, challenge_period_end: null,
...@@ -11,12 +13,12 @@ export const data = { ...@@ -11,12 +13,12 @@ export const data = {
private_tags: [], private_tags: [],
public_tags: [], public_tags: [],
watchlist_names: [], watchlist_names: [],
ens_domain_name: null,
}, },
l1_tx_hash: '0x1a235bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136dca684', l1_tx_hash: '0x1a235bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136dca684',
l2_timestamp: '2022-02-15T12:50:02.000000Z', l2_timestamp: '2022-02-15T12:50:02.000000Z',
l2_tx_hash: '0x918cd8c5c24c17e06cd02b0379510c4ad56324bf153578fb9caaaa2fe4e7dc35', l2_tx_hash: '0x918cd8c5c24c17e06cd02b0379510c4ad56324bf153578fb9caaaa2fe4e7dc35',
msg_nonce: 396, msg_nonce: 396,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620172',
msg_nonce_version: 1, msg_nonce_version: 1,
status: 'Ready to prove', status: 'Ready to prove',
}, },
...@@ -27,7 +29,6 @@ export const data = { ...@@ -27,7 +29,6 @@ export const data = {
l2_timestamp: null, l2_timestamp: null,
l2_tx_hash: '0x2f117bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136def593', l2_tx_hash: '0x2f117bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136def593',
msg_nonce: 391, msg_nonce: 391,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620167',
msg_nonce_version: 1, msg_nonce_version: 1,
status: 'Ready to prove', status: 'Ready to prove',
}, },
...@@ -38,7 +39,6 @@ export const data = { ...@@ -38,7 +39,6 @@ export const data = {
l2_timestamp: null, l2_timestamp: null,
l2_tx_hash: '0xe14b1f46838176702244a5343629bcecf728ca2d9881d47b4ce46e00c387d7e3', l2_tx_hash: '0xe14b1f46838176702244a5343629bcecf728ca2d9881d47b4ce46e00c387d7e3',
msg_nonce: 390, msg_nonce: 390,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620166',
msg_nonce_version: 1, msg_nonce_version: 1,
status: 'Ready for relay', status: 'Ready for relay',
}, },
......
import type { AddressMetadataInfo, AddressMetadataTag } from 'types/api/addressMetadata'; /* eslint-disable max-len */
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
import { hash } from '../address/address'; export const nameTag: AddressMetadataTagApi = {
slug: 'quack-quack',
export const nameTag1: AddressMetadataTag = { name: 'Quack quack',
slug: 'ethermineru',
name: 'Ethermine.ru',
tagType: 'name', tagType: 'name',
ordinal: 0, ordinal: 99,
meta: null, meta: null,
}; };
export const genericTag1: AddressMetadataTag = { export const customNameTag: AddressMetadataTagApi = {
slug: 'ethermine.ru', slug: 'unicorn-uproar',
name: 'Ethermine.ru', name: 'Unicorn Uproar',
tagType: 'name',
ordinal: 777,
meta: {
tagUrl: 'https://example.com',
bgColor: 'linear-gradient(45deg, deeppink, deepskyblue)',
textColor: '#FFFFFF',
},
};
export const genericTag: AddressMetadataTagApi = {
slug: 'duck-owner',
name: 'duck owner 🦆',
tagType: 'generic', tagType: 'generic',
ordinal: 0, ordinal: 55,
meta: null, meta: {
bgColor: 'rgba(255,243,12,90%)',
},
}; };
export const protocolTag1: AddressMetadataTag = { export const infoTagWithLink: AddressMetadataTagApi = {
slug: 'goosegang',
name: 'GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG GooseGanG',
tagType: 'classifier',
ordinal: 11,
meta: {
tagUrl: 'https://example.com',
},
};
export const tagWithTooltip: AddressMetadataTagApi = {
slug: 'blockscout-heroes',
name: 'BlockscoutHeroes',
tagType: 'classifier',
ordinal: 42,
meta: {
tooltipDescription: 'The Blockscout team, EVM blockchain aficionados, illuminate Ethereum networks with unparalleled insight and prowess, leading the way in blockchain exploration! 🚀🔎',
tooltipIcon: 'https://localhost:3100/icon.svg',
tooltipTitle: 'Blockscout team member',
tooltipUrl: 'https://blockscout.com',
},
};
export const protocolTag: AddressMetadataTagApi = {
slug: 'aerodrome', slug: 'aerodrome',
name: 'Aerodrome', name: 'Aerodrome',
tagType: 'protocol', tagType: 'protocol',
ordinal: 0, ordinal: 0,
meta: null, meta: null,
}; };
export const baseInfo: AddressMetadataInfo = {
addresses: {
[hash]: {
tags: [ nameTag1, genericTag1, protocolTag1 ],
reputation: null,
},
},
};
export const data = { import type { AddressParam } from 'types/api/addressParams';
import type { WithdrawalsResponse } from 'types/api/withdrawals';
export const data: WithdrawalsResponse = {
items: [ items: [
{ {
amount: '192175000000000', amount: '192175000000000',
...@@ -10,7 +13,7 @@ export const data = { ...@@ -10,7 +13,7 @@ export const data = {
is_contract: false, is_contract: false,
is_verified: null, is_verified: null,
name: null, name: null,
}, } as AddressParam,
timestamp: '2022-06-07T18:12:24.000000Z', timestamp: '2022-06-07T18:12:24.000000Z',
validator_index: 49622, validator_index: 49622,
}, },
...@@ -24,7 +27,7 @@ export const data = { ...@@ -24,7 +27,7 @@ export const data = {
is_contract: false, is_contract: false,
is_verified: null, is_verified: null,
name: null, name: null,
}, } as AddressParam,
timestamp: '2022-05-07T18:12:24.000000Z', timestamp: '2022-05-07T18:12:24.000000Z',
validator_index: 49621, validator_index: 49621,
}, },
...@@ -38,7 +41,7 @@ export const data = { ...@@ -38,7 +41,7 @@ export const data = {
is_contract: false, is_contract: false,
is_verified: null, is_verified: null,
name: null, name: null,
}, } as AddressParam,
timestamp: '2022-04-07T18:12:24.000000Z', timestamp: '2022-04-07T18:12:24.000000Z',
validator_index: 49620, validator_index: 49620,
}, },
......
...@@ -10,6 +10,7 @@ const headers = require('./nextjs/headers'); ...@@ -10,6 +10,7 @@ const headers = require('./nextjs/headers');
const redirects = require('./nextjs/redirects'); const redirects = require('./nextjs/redirects');
const rewrites = require('./nextjs/rewrites'); const rewrites = require('./nextjs/rewrites');
/** @type {import('next').NextConfig} */
const moduleExports = { const moduleExports = {
transpilePackages: [ transpilePackages: [
'react-syntax-highlighter', 'react-syntax-highlighter',
...@@ -46,6 +47,14 @@ const moduleExports = { ...@@ -46,6 +47,14 @@ const moduleExports = {
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
experimental: { experimental: {
instrumentationHook: true, instrumentationHook: true,
turbo: {
rules: {
'*.svg': {
loaders: [ '@svgr/webpack' ],
as: '*.js',
},
},
},
}, },
}; };
......
...@@ -18,6 +18,7 @@ import theme from 'theme'; ...@@ -18,6 +18,7 @@ import theme from 'theme';
export type Props = { export type Props = {
children: React.ReactNode; children: React.ReactNode;
withSocket?: boolean; withSocket?: boolean;
withWalletClient?: boolean;
appContext?: { appContext?: {
pageProps: PageProps; pageProps: PageProps;
}; };
...@@ -47,7 +48,20 @@ const wagmiConfig = createConfig({ ...@@ -47,7 +48,20 @@ const wagmiConfig = createConfig({
}, },
}); });
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => { 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 [ queryClient ] = React.useState(() => new QueryClient({ const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
...@@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props ...@@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }> <SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }>
<AppContextProvider { ...appContext }> <AppContextProvider { ...appContext }>
<GrowthBookProvider> <GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }> <WalletClientProvider withWalletClient={ withWalletClient }>
{ children } { children }
</WagmiProvider> </WalletClientProvider>
</GrowthBookProvider> </GrowthBookProvider>
</AppContextProvider> </AppContextProvider>
</SocketProvider> </SocketProvider>
......
...@@ -16,6 +16,11 @@ const fixture: TestFixture<MockEnvsFixture, { page: Page }> = async({ page }, us ...@@ -16,6 +16,11 @@ const fixture: TestFixture<MockEnvsFixture, { page: Page }> = async({ page }, us
export default fixture; export default fixture;
export const ENVS_MAP: Record<string, Array<[string, string]>> = { export const ENVS_MAP: Record<string, Array<[string, string]>> = {
optimisticRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'optimistic' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
[ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ],
],
shibariumRollup: [ shibariumRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ], [ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ], [ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
...@@ -24,6 +29,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -24,6 +29,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ], [ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ], [ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
], ],
zkSyncRollup: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkSync' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
],
bridgedTokens: [ bridgedTokens: [
[ 'NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS', '[{"id":"1","title":"Ethereum","short_title":"ETH","base_url":"https://eth.blockscout.com/token/"},{"id":"56","title":"Binance Smart Chain","short_title":"BSC","base_url":"https://bscscan.com/token/"},{"id":"99","title":"POA","short_title":"POA","base_url":"https://blockscout.com/poa/core/token/"}]' ], [ 'NEXT_PUBLIC_BRIDGED_TOKENS_CHAINS', '[{"id":"1","title":"Ethereum","short_title":"ETH","base_url":"https://eth.blockscout.com/token/"},{"id":"56","title":"Binance Smart Chain","short_title":"BSC","base_url":"https://bscscan.com/token/"},{"id":"99","title":"POA","short_title":"POA","base_url":"https://blockscout.com/poa/core/token/"}]' ],
[ 'NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES', '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]' ], [ 'NEXT_PUBLIC_BRIDGED_TOKENS_BRIDGES', '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]' ],
...@@ -37,4 +46,18 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -37,4 +46,18 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
blockHiddenFields: [ blockHiddenFields: [
[ 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', '["burnt_fees", "total_reward", "nonce"]' ], [ 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', '["burnt_fees", "total_reward", "nonce"]' ],
], ],
stabilityEnvs: [
[ 'NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS', '["top_accounts"]' ],
[ 'NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS', '["value","fee_currency","gas_price","gas_fees","burnt_fees"]' ],
[ 'NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS', '["fee_per_gas"]' ],
],
beaconChain: [
[ 'NEXT_PUBLIC_HAS_BEACON_CHAIN', 'true' ],
],
txInterpretation: [
[ 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', 'blockscout' ],
],
noWalletClient: [
[ 'NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID', '' ],
],
}; };
...@@ -71,6 +71,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block ...@@ -71,6 +71,7 @@ export function sendMessage(socket: WebSocket, channel: Channel, msg: 'new_block
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'verification_result', payload: SmartContractVerificationResponse): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'total_supply', payload: { total_supply: number}): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'total_supply', payload: { total_supply: number}): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_bytecode', payload: Record<string, never>): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'changed_bytecode', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'fetched_bytecode', payload: { fetched_bytecode: string }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'smart_contract_was_verified', payload: Record<string, never>): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void; export function sendMessage(socket: WebSocket, channel: Channel, msg: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void { export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
......
...@@ -10,45 +10,3 @@ export const viewport = { ...@@ -10,45 +10,3 @@ export const viewport = {
export const maskColor = '#4299E1'; // blue.400 export const maskColor = '#4299E1'; // blue.400
export const adsBannerSelector = '.adsbyslise'; export const adsBannerSelector = '.adsbyslise';
export const featureEnvs = {
beaconChain: [
{ name: 'NEXT_PUBLIC_HAS_BEACON_CHAIN', value: 'true' },
],
optimisticRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'optimistic' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
{ name: 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', value: 'https://localhost:3102' },
],
txInterpretation: [
{ name: 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', value: 'blockscout' },
],
zkEvmRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'zkEvm' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
],
zkSyncRollup: [
{ name: 'NEXT_PUBLIC_ROLLUP_TYPE', value: 'zkSync' },
{ name: 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', value: 'https://localhost:3101' },
],
userOps: [
{ name: 'NEXT_PUBLIC_HAS_USER_OPS', value: 'true' },
],
validators: [
{ name: 'NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE', value: 'stability' },
],
};
export const viewsEnvs = {
block: {
hiddenFields: [
{ name: 'NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS', value: '["burnt_fees", "total_reward", "nonce"]' },
],
},
};
export const stabilityEnvs = [
{ name: 'NEXT_PUBLIC_VIEWS_ADDRESS_HIDDEN_VIEWS', value: '["top_accounts"]' },
{ name: 'NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS', value: '["value","fee_currency","gas_price","gas_fees","burnt_fees"]' },
{ name: 'NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS', value: '["fee_per_gas"]' },
];
...@@ -28,5 +28,5 @@ dotenv \ ...@@ -28,5 +28,5 @@ dotenv \
-v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \ -v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \
-e $config_file \ -e $config_file \
-e $secrets_file \ -e $secrets_file \
-- bash -c './deploy/scripts/make_envs_script.sh && next dev -- -p $NEXT_PUBLIC_APP_PORT' | -- bash -c './deploy/scripts/make_envs_script.sh && next dev -p $NEXT_PUBLIC_APP_PORT --turbo' |
pino-pretty pino-pretty
\ No newline at end of file
...@@ -7,6 +7,7 @@ export interface AddressMetadataInfo { ...@@ -7,6 +7,7 @@ export interface AddressMetadataInfo {
export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol'; export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol';
// Response model from Metadata microservice API
export interface AddressMetadataTag { export interface AddressMetadataTag {
slug: string; slug: string;
name: string; name: string;
...@@ -14,3 +15,20 @@ export interface AddressMetadataTag { ...@@ -14,3 +15,20 @@ export interface AddressMetadataTag {
ordinal: number; ordinal: number;
meta: string | null; meta: string | null;
} }
// Response model from Blockscout API with parsed meta field
export interface AddressMetadataTagApi extends Omit<AddressMetadataTag, 'meta'> {
meta: {
textColor?: string;
bgColor?: string;
tagUrl?: string;
tooltipIcon?: string;
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
actionURL?: string;
appID?: string;
logoURL?: string;
text?: string;
} | null;
}
import type { AddressMetadataTagApi } from './addressMetadata';
export interface AddressTag { export interface AddressTag {
label: string; label: string;
display_name: string; display_name: string;
...@@ -22,6 +24,10 @@ export type AddressParamBasic = { ...@@ -22,6 +24,10 @@ export type AddressParamBasic = {
is_contract: boolean; is_contract: boolean;
is_verified: boolean | null; is_verified: boolean | null;
ens_domain_name: string | null; ens_domain_name: string | null;
metadata?: {
reputation: number | null;
tags: Array<AddressMetadataTagApi>;
} | null;
} }
export type AddressParam = UserTags & AddressParamBasic; export type AddressParam = UserTags & AddressParamBasic;
import type { Abi, AbiType } from 'abitype'; import type { Abi, AbiType, AbiFallback, AbiFunction, AbiReceive } from 'abitype';
export type SmartContractMethodArgType = AbiType; export type SmartContractMethodArgType = AbiType;
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable'; export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
...@@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary { ...@@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary {
name: string; name: string;
} }
export interface SmartContractMethodBase { export type SmartContractMethodOutputValue = string | boolean | object;
inputs: Array<SmartContractMethodInput>; export type SmartContractMethodOutput = AbiFunction['outputs'][number] & { value?: SmartContractMethodOutputValue };
outputs?: Array<SmartContractMethodOutput>; export type SmartContractMethodBase = Omit<AbiFunction, 'outputs'> & {
constant: boolean;
name: string;
stateMutability: SmartContractMethodStateMutability;
type: 'function';
payable: boolean;
error?: string;
method_id: string; method_id: string;
} outputs: Array<SmartContractMethodOutput>;
constant?: boolean;
error?: string;
};
export type SmartContractReadMethod = SmartContractMethodBase; export type SmartContractReadMethod = SmartContractMethodBase;
export type SmartContractWriteMethod = SmartContractMethodBase | AbiFallback | AbiReceive;
export interface SmartContractWriteFallback {
payable?: true;
stateMutability: 'payable';
type: 'fallback';
}
export interface SmartContractWriteReceive {
payable?: true;
stateMutability: 'payable';
type: 'receive';
}
export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWriteFallback | SmartContractWriteReceive;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod; export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput { export interface SmartContractQueryMethodSuccess {
internalType?: string; // there could be any string, e.g "enum MyEnum"
name: string;
type: SmartContractMethodArgType;
components?: Array<SmartContractMethodInput>;
fieldType?: 'native_coin';
}
export interface SmartContractMethodOutput extends SmartContractMethodInput {
value?: string | boolean | object;
}
export interface SmartContractQueryMethodReadSuccess {
is_error: false; is_error: false;
result: { result: {
names: Array<string | [ string, Array<string> ]>; names: Array<string | [ string, Array<string> ]>;
...@@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess { ...@@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess {
}; };
} }
export interface SmartContractQueryMethodReadError { export interface SmartContractQueryMethodError {
is_error: true; is_error: true;
result: { result: {
code: number; code: number;
...@@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError { ...@@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError {
}; };
} }
export type SmartContractQueryMethodRead = SmartContractQueryMethodReadSuccess | SmartContractQueryMethodReadError; export type SmartContractQueryMethod = SmartContractQueryMethodSuccess | SmartContractQueryMethodError;
// VERIFICATION // VERIFICATION
......
import type { AddressMetadataTagType } from 'types/api/addressMetadata'; import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
export interface AddressMetadataInfoFormatted { export interface AddressMetadataInfoFormatted {
addresses: Record<string, { addresses: Record<string, {
...@@ -7,21 +7,4 @@ export interface AddressMetadataInfoFormatted { ...@@ -7,21 +7,4 @@ export interface AddressMetadataInfoFormatted {
}>; }>;
} }
export interface AddressMetadataTagFormatted { export type AddressMetadataTagFormatted = AddressMetadataTagApi;
slug: string;
name: string;
tagType: AddressMetadataTagType;
ordinal: number;
meta: {
textColor?: string;
bgColor?: string;
actionURL?: string;
appID?: string;
logoURL?: string;
text?: string;
tooltipIcon?: string;
tooltipTitle?: string;
tooltipDescription?: string;
tooltipUrl?: string;
} | null;
}
...@@ -5,6 +5,8 @@ export const BLOCK_FIELDS_IDS = [ ...@@ -5,6 +5,8 @@ export const BLOCK_FIELDS_IDS = [
'total_reward', 'total_reward',
'nonce', 'nonce',
'miner', 'miner',
'L1_status',
'batch',
] as const; ] as const;
export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>; export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>;
...@@ -7,6 +7,8 @@ export const TX_FIELDS_IDS = [ ...@@ -7,6 +7,8 @@ export const TX_FIELDS_IDS = [
'tx_fee', 'tx_fee',
'gas_fees', 'gas_fees',
'burnt_fees', 'burnt_fees',
'L1_status',
'batch',
] as const; ] as const;
export type TxFieldsId = ArrayElement<typeof TX_FIELDS_IDS>; export type TxFieldsId = ArrayElement<typeof TX_FIELDS_IDS>;
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as contractInfoMock from 'mocks/contract/info';
import * as contractMethodsMock from 'mocks/contract/methods';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import * as socketServer from 'playwright/fixtures/socketServer';
import { test, expect } from 'playwright/lib';
import AddressContract from './AddressContract.pwstory';
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' } });
});
test.describe('ABI functionality', () => {
test('read', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('read, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'read_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('FLASHLOAN_PREMIUM_TOTAL').click();
await expect(component.getByRole('button', { name: 'Read' })).toBeVisible();
});
test('write', async({ render, createSocket }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeVisible();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeEnabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeEnabled();
});
test('write, no wallet client', async({ render, createSocket, mockEnvs }) => {
const hooksConfig = {
router: {
query: { hash, tab: 'write_contract' },
},
};
await mockEnvs(ENVS_MAP.noWalletClient);
const component = await render(<AddressContract/>, { hooksConfig }, { withSocket: true, withWalletClient: false });
const socket = await createSocket();
await socketServer.joinChannel(socket, 'addresses:' + addressMock.contract.hash.toLowerCase());
await expect(component.getByRole('button', { name: 'Connect wallet' })).toBeHidden();
await component.getByText('setReserveInterestRateStrategyAddress').click();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Simulate' })).toBeEnabled();
await expect(component.getByLabel('2.').getByRole('button', { name: 'Write' })).toBeDisabled();
await component.getByText('pause').click();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Simulate' })).toBeHidden();
await expect(component.getByLabel('5.').getByRole('button', { name: 'Write' })).toBeDisabled();
});
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import useContractTabs from 'lib/hooks/useContractTabs';
import getQueryParamString from 'lib/router/getQueryParamString';
import AddressContract from './AddressContract';
const AddressContractPwStory = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const addressQuery = useApiQuery('address', { pathParams: { hash } });
const { tabs } = useContractTabs(addressQuery.data, false);
return <AddressContract tabs={ tabs } shouldRender={ true } isLoading={ false }/>;
};
export default AddressContractPwStory;
...@@ -3,7 +3,6 @@ import React from 'react'; ...@@ -3,7 +3,6 @@ import React from 'react';
import type { RoutedSubTab } from 'ui/shared/Tabs/types'; import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props { interface Props {
tabs: Array<RoutedSubTab>; tabs: Array<RoutedSubTab>;
...@@ -16,21 +15,12 @@ const TAB_LIST_PROPS = { ...@@ -16,21 +15,12 @@ const TAB_LIST_PROPS = {
}; };
const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => { const AddressContract = ({ tabs, isLoading, shouldRender }: Props) => {
const fallback = React.useCallback(() => {
const noProviderTabs = tabs.filter(({ id }) => id === 'contract_code' || id.startsWith('read_'));
return (
<RoutedTabs tabs={ noProviderTabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
);
}, [ isLoading, tabs ]);
if (!shouldRender) { if (!shouldRender) {
return null; return null;
} }
return ( return (
<Web3ModalProvider fallback={ fallback }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/> <RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
</Web3ModalProvider>
); );
}; };
......
import { Accordion, Box, Flex, Link } from '@chakra-ui/react'; import { Accordion, Box, Flex, Link } from '@chakra-ui/react';
import _range from 'lodash/range'; import _range from 'lodash/range';
import React from 'react'; import React from 'react';
import { scroller } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract'; import type { MethodType, ContractAbi as TContractAbi } from './types';
import ContractMethodsAccordionItem from './ContractMethodsAccordionItem'; import ContractAbiItem from './ContractAbiItem';
import useFormSubmit from './useFormSubmit';
import useScrollToMethod from './useScrollToMethod';
interface Props<T extends SmartContractMethod> { interface Props {
data: Array<T>; data: TContractAbi;
addressHash?: string; addressHash: string;
renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string; tab: string;
methodType: MethodType;
} }
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => { const ContractAbi = ({ data, addressHash, tab, methodType }: Props) => {
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);
React.useEffect(() => { useScrollToMethod(data, setExpandedSections);
const hash = window.location.hash.replace('#', '');
if (!hash) { const handleFormSubmit = useFormSubmit({ addressHash, tab });
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === hash);
if (index > -1) {
scroller.scrollTo(`method_${ hash }`, {
duration: 500,
smooth: true,
offset: -100,
});
setExpandedSections([ index ]);
}
}, [ data ]);
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => { const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue); setExpandedSections(newValue);
...@@ -73,14 +60,15 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address ...@@ -73,14 +60,15 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
</Flex> </Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }> <Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => ( { data.map((item, index) => (
<ContractMethodsAccordionItem <ContractAbiItem
key={ index } key={ index }
data={ item } data={ item }
id={ id } id={ id }
index={ index } index={ index }
addressHash={ addressHash } addressHash={ addressHash }
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
tab={ tab } tab={ tab }
onSubmit={ handleFormSubmit }
methodType={ methodType }
/> />
)) } )) }
</Accordion> </Accordion>
...@@ -88,4 +76,4 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address ...@@ -88,4 +76,4 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
); );
}; };
export default React.memo(ContractMethodsAccordion) as typeof ContractMethodsAccordion; export default React.memo(ContractAbi);
import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react'; import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Alert, Box, Flex, Tooltip, useClipboard, useDisclosure } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { Element } from 'react-scroll'; import { Element } from 'react-scroll';
import type { SmartContractMethod } from 'types/api/contract'; import type { FormSubmitHandler, MethodType, ContractAbiItem as TContractAbiItem } from './types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import config from 'configs/app'; import config from 'configs/app';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import Hint from 'ui/shared/Hint'; import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
interface Props<T extends SmartContractMethod> { import ContractAbiItemConstant from './ContractAbiItemConstant';
data: T; import ContractMethodForm from './form/ContractMethodForm';
import { getElementName } from './useScrollToMethod';
interface Props {
data: TContractAbiItem;
index: number; index: number;
id: number; id: number;
addressHash?: string; addressHash: string;
renderContent: (item: T, index: number, id: number) => React.ReactNode;
tab: string; tab: string;
onSubmit: FormSubmitHandler;
methodType: MethodType;
} }
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => { const ContractAbiItem = ({ data, index, id, addressHash, tab, onSubmit, methodType }: Props) => {
const url = React.useMemo(() => { const url = React.useMemo(() => {
if (!('method_id' in data)) { if (!('method_id' in data)) {
return ''; return '';
...@@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
onCopy(); onCopy();
}, [ onCopy ]); }, [ onCopy ]);
const handleCopyMethodIdClick = React.useCallback((event: React.MouseEvent) => {
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 }
/>
);
})();
return ( return (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}> <AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ isExpanded }) => ( { ({ isExpanded }) => (
<> <>
<Element as="h2" name={ 'method_id' in data ? `method_${ data.method_id }` : '' }> <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"> <AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }} wordBreak="break-all" textAlign="left" as="div" cursor="pointer">
{ 'method_id' in data && ( { 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }> <Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
...@@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
the contract cannot receive Ether through regular transactions and throws an exception.` the contract cannot receive Ether through regular transactions and throws an exception.`
}/> }/>
) } ) }
{ 'method_id' in data && (
<>
<Tag>{ data.method_id }</Tag>
<CopyToClipboard text={ `${ data.name } (${ data.method_id })` } onClick={ handleCopyMethodIdClick }/>
</>
) }
<AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/> <AccordionIcon transform={ isExpanded ? 'rotate(0deg)' : 'rotate(-90deg)' } color="gray.500"/>
</AccordionButton> </AccordionButton>
</Element> </Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)"> <AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) } { content }
</AccordionPanel> </AccordionPanel>
</> </>
) } ) }
...@@ -97,4 +139,4 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind ...@@ -97,4 +139,4 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
); );
}; };
export default React.memo(ContractMethodsAccordionItem); export default React.memo(ContractAbiItem);
...@@ -4,12 +4,14 @@ import type { ChangeEvent } from 'react'; ...@@ -4,12 +4,14 @@ import type { ChangeEvent } from 'react';
import React from 'react'; import React from 'react';
import { getAddress } from 'viem'; import { getAddress } from 'viem';
import type { SmartContractMethodOutput } from 'types/api/contract'; import type { ContractAbiItemOutput } from './types';
import { WEI } from 'lib/consts'; import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { matchInt } from './form/utils';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string { function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
switch (typeof value) { switch (typeof value) {
case 'string': case 'string':
...@@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint | ...@@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint |
} }
interface Props { interface Props {
data: SmartContractMethodOutput; data: ContractAbiItemOutput;
} }
const ContractMethodStatic = ({ data }: Props) => { const ContractAbiItemConstant = ({ data }: Props) => {
const [ value, setValue ] = React.useState<string>(castValueToString(data.value)); const [ value, setValue ] = React.useState<string>(castValueToString(data.value));
const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase()); const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase());
const intMatch = matchInt(data.type);
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => { const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const initialValue = castValueToString(data.value); const initialValue = castValueToString(data.value);
...@@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => { ...@@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => {
return ( return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }> <Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
{ content } { content }
{ (data.type.includes('int256') || data.type.includes('int128')) && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> } { Number(intMatch?.power) >= 128 && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex> </Flex>
); );
}; };
export default ContractMethodStatic; export default ContractAbiItemConstant;
...@@ -3,18 +3,18 @@ import React from 'react'; ...@@ -3,18 +3,18 @@ import React from 'react';
import { useController, useFormContext } from 'react-hook-form'; import { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import ClearButton from 'ui/shared/ClearButton'; import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton'; import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt';
import useFormatFieldValue from './useFormatFieldValue'; import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField'; import useValidateField from './useValidateField';
import { matchInt } from './utils';
interface Props { interface Props {
data: SmartContractMethodInput; data: ContractAbiItemInput;
hideLabel?: boolean; hideLabel?: boolean;
path: string; path: string;
className?: string; className?: string;
...@@ -28,7 +28,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi ...@@ -28,7 +28,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const isNativeCoin = data.fieldType === 'native_coin'; const isNativeCoin = data.fieldType === 'native_coin';
const isOptional = isNativeCoin; const isOptional = isNativeCoin;
const argTypeMatchInt = useArgTypeMatchInt({ argType: data.type }); const argTypeMatchInt = React.useMemo(() => matchInt(data.type), [ data.type ]);
const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt }); const validate = useValidateField({ isOptional, argType: data.type, argTypeMatchInt });
const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt }); const format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
......
...@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react'; ...@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput, SmartContractMethodArgType } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import ContractMethodArrayButton from './ContractMethodArrayButton'; import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion'; import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
...@@ -10,21 +10,35 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; ...@@ -10,21 +10,35 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFieldLabel from './ContractMethodFieldLabel'; import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel } from './utils'; import { getFieldLabel, matchArray, transformDataForArrayItem } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> { interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput; data: ContractAbiItemInput;
level: number; level: number;
basePath: string; basePath: string;
isDisabled: boolean; isDisabled: boolean;
isArrayElement?: boolean;
size?: number;
} }
const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRemoveClick, index: parentIndex, isDisabled }: Props) => { const ContractMethodFieldInputArray = ({
data,
level,
basePath,
onAddClick,
onRemoveClick,
index: parentIndex,
isDisabled,
isArrayElement,
}: Props) => {
const { formState: { errors } } = useFormContext(); const { formState: { errors } } = useFormContext();
const fieldsWithErrors = Object.keys(errors); const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath)); const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
const [ registeredIndices, setRegisteredIndices ] = React.useState([ 0 ]); const arrayMatch = matchArray(data.type);
const hasFixedSize = arrayMatch !== null && arrayMatch.size !== Infinity;
const [ registeredIndices, setRegisteredIndices ] = React.useState(hasFixedSize ? Array(arrayMatch.size).fill(0).map((_, i) => i) : [ 0 ]);
const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => { const handleAddButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault(); event.preventDefault();
...@@ -39,63 +53,48 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -39,63 +53,48 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
} }
}, [ ]); }, [ ]);
const getItemData = (index: number) => { if (arrayMatch?.isNested) {
const childrenType = data.type.slice(0, -2) as SmartContractMethodArgType; return (
const childrenInternalType = data.internalType?.slice(0, parentIndex !== undefined ? -4 : -2).replaceAll('struct ', ''); <>
{
const namePostfix = childrenInternalType ? ' ' + childrenInternalType : ''; registeredIndices.map((registeredIndex, index) => {
const nameParentIndex = parentIndex !== undefined ? `${ parentIndex + 1 }.` : ''; const itemData = transformDataForArrayItem(data, index);
const nameIndex = index + 1; const itemBasePath = `${ basePath }:${ registeredIndex }`;
const itemIsInvalid = fieldsWithErrors.some((field) => field.startsWith(itemBasePath));
return {
...data,
type: childrenType,
name: `#${ nameParentIndex + nameIndex }${ namePostfix }`,
};
};
const isNestedArray = data.type.includes('[][]');
if (isNestedArray) {
return ( return (
<ContractMethodFieldAccordion <ContractMethodFieldAccordion
level={ level } key={ registeredIndex }
label={ getFieldLabel(data) } level={ level + 1 }
isInvalid={ isInvalid } label={ getFieldLabel(itemData) }
isInvalid={ itemIsInvalid }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
> >
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputArray <ContractMethodFieldInputArray
key={ registeredIndex } key={ registeredIndex }
data={ itemData } data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` } basePath={ itemBasePath }
level={ level + 1 } level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled } isDisabled={ isDisabled }
isArrayElement
/> />
);
}) }
</ContractMethodFieldAccordion> </ContractMethodFieldAccordion>
); );
})
}
</>
);
} }
const isTupleArray = data.type.includes('tuple'); const isTupleArray = arrayMatch?.itemType.includes('tuple');
if (isTupleArray) { if (isTupleArray) {
return ( const content = (
<ContractMethodFieldAccordion <>
level={ level }
label={ getFieldLabel(data) }
onAddClick={ onAddClick }
onRemoveClick={ onRemoveClick }
index={ parentIndex }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => { { registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index); const itemData = transformDataForArrayItem(data, index);
return ( return (
<ContractMethodFieldInputTuple <ContractMethodFieldInputTuple
...@@ -103,13 +102,30 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -103,13 +102,30 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
data={ itemData } data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` } basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 } level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined } onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined } onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex } index={ registeredIndex }
isDisabled={ isDisabled } isDisabled={ isDisabled }
/> />
); );
}) } }) }
</>
);
if (isArrayElement) {
return content;
}
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
onAddClick={ onAddClick }
onRemoveClick={ onRemoveClick }
index={ parentIndex }
isInvalid={ isInvalid }
>
{ content }
</ContractMethodFieldAccordion> </ContractMethodFieldAccordion>
); );
} }
...@@ -117,10 +133,10 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -117,10 +133,10 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
// primitive value array // primitive value array
return ( return (
<Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px"> <Flex flexDir={{ base: 'column', md: 'row' }} alignItems="flex-start" columnGap={ 3 } px="6px">
<ContractMethodFieldLabel data={ data } level={ level }/> { !isArrayElement && <ContractMethodFieldLabel data={ data } level={ level }/> }
<Flex flexDir="column" rowGap={ 1 } w="100%"> <Flex flexDir="column" rowGap={ 1 } w="100%">
{ registeredIndices.map((registeredIndex, index) => { { registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index); const itemData = transformDataForArrayItem(data, index);
return ( return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }> <Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
...@@ -132,9 +148,9 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe ...@@ -132,9 +148,9 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
px={ 0 } px={ 0 }
isDisabled={ isDisabled } isDisabled={ isDisabled }
/> />
{ registeredIndices.length > 1 && { !hasFixedSize && registeredIndices.length > 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> } <ContractMethodArrayButton index={ registeredIndex } onClick={ handleRemoveButtonClick } type="remove" my="6px"/> }
{ index === registeredIndices.length - 1 && { !hasFixedSize && index === registeredIndices.length - 1 &&
<ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> } <ContractMethodArrayButton index={ registeredIndex } onClick={ handleAddButtonClick } type="add" my="6px"/> }
</Flex> </Flex>
); );
......
import React from 'react'; import React from 'react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion'; import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion'; import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import { ARRAY_REGEXP, getFieldLabel } from './utils'; import { getFieldLabel, matchArray } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> { interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput; data: ContractAbiItemInput;
basePath: string; basePath: string;
level: number; level: number;
isDisabled: boolean; isDisabled: boolean;
...@@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a ...@@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
const fieldsWithErrors = Object.keys(errors); const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath)); const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
if (!('components' in data)) {
return null;
}
return ( return (
<ContractMethodFieldAccordion <ContractMethodFieldAccordion
{ ...accordionProps } { ...accordionProps }
...@@ -29,7 +33,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a ...@@ -29,7 +33,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
isInvalid={ isInvalid } isInvalid={ isInvalid }
> >
{ data.components?.map((component, index) => { { data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') { if ('components' in component && component.type === 'tuple') {
return ( return (
<ContractMethodFieldInputTuple <ContractMethodFieldInputTuple
key={ index } key={ index }
...@@ -41,15 +45,14 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a ...@@ -41,15 +45,14 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
); );
} }
const arrayMatch = component.type.match(ARRAY_REGEXP); const arrayMatch = matchArray(component.type);
if (arrayMatch) { if (arrayMatch) {
const [ , itemType ] = arrayMatch;
return ( return (
<ContractMethodFieldInputArray <ContractMethodFieldInputArray
key={ index } key={ index }
data={ component } data={ component }
basePath={ `${ basePath }:${ index }` } basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level } level={ arrayMatch.itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled } isDisabled={ isDisabled }
/> />
); );
......
import { Box, useColorModeValue } from '@chakra-ui/react'; import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
import { getFieldLabel } from './utils'; import { getFieldLabel } from './utils';
interface Props { interface Props {
data: SmartContractMethodInput; data: ContractAbiItemInput;
isOptional?: boolean; isOptional?: boolean;
level: number; level: number;
} }
......
import { test, expect } from '@playwright/experimental-ct-react'; import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract'; import type { ContractAbiItem } from '../types';
import TestApp from 'playwright/TestApp'; import TestApp from 'playwright/TestApp';
import ContractMethodForm from './ContractMethodForm'; import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` }); const onSubmit = () => Promise.resolve({ source: 'wallet_client' as const, result: { hash: '0x0000' as `0x${ string }` } });
const resultComponent = () => null;
const data: SmartContractWriteMethod = { const data: ContractAbiItem = {
inputs: [ inputs: [
// TUPLE // TUPLE
{ {
...@@ -53,6 +52,13 @@ const data: SmartContractWriteMethod = { ...@@ -53,6 +52,13 @@ const data: SmartContractWriteMethod = {
type: 'tuple[][]', type: 'tuple[][]',
}, },
// TOP LEVEL NESTED ARRAY
{
internalType: 'int256[2][][3]',
name: 'ParentArray',
type: 'int256[2][][3]',
},
// LITERALS // LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' }, { internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' }, { internalType: 'address', name: 'recipient', type: 'address' },
...@@ -95,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => { ...@@ -95,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount( const component = await mount(
<TestApp> <TestApp>
<ContractMethodForm<SmartContractWriteMethod> <ContractMethodForm
data={ data } data={ data }
onSubmit={ onSubmit } onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write" methodType="write"
/> />
</TestApp>, </TestApp>,
...@@ -125,9 +130,13 @@ test('base view +@mobile +@dark-mode', async({ mount }) => { ...@@ -125,9 +130,13 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
await component.getByText('struct FulfillmentComponent[][]').click(); await component.getByText('struct FulfillmentComponent[][]').click();
await component.getByRole('button', { name: 'add' }).nth(1).click(); await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('#1 FulfillmentComponent[]').click(); await component.getByText('#1 FulfillmentComponent[]').click();
await component.getByText('#1.1 FulfillmentComponent').click(); await component.getByLabel('#1 FulfillmentComponent[] (tuple[])').getByText('#1 FulfillmentComponent (tuple)').click();
await component.getByRole('button', { name: 'add' }).nth(1).click(); await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('ParentArray (int256[2][][3])').click();
await component.getByText('#1 int256[2][] (int256[2][])').click();
await component.getByLabel('#1 int256[2][] (int256[2][])').getByText('#1 int256[2] (int256[2])').click();
// submit form // submit form
await component.getByRole('button', { name: 'Write' }).click(); await component.getByRole('button', { name: 'Write' }).click();
......
import { Box, Button, Flex, chakra } from '@chakra-ui/react'; import { Box, Button, Flex, Tooltip, chakra } from '@chakra-ui/react';
import _mapValues from 'lodash/mapValues'; import _mapValues from 'lodash/mapValues';
import React from 'react'; import React from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form'; import { useForm, FormProvider } from 'react-hook-form';
import type { AbiFunction } from 'viem';
import type { ContractMethodCallResult } from '../types'; import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, MethodType, ContractAbiItem } from '../types';
import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import config from 'configs/app'; import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index'; import * as mixpanel from 'lib/mixpanel/index';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput'; import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray'; import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple'; import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFormOutputs from './ContractMethodFormOutputs'; import ContractMethodOutputs from './ContractMethodOutputs';
import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils'; import ContractMethodResult from './ContractMethodResult';
import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils';
import type { ContractMethodFormFields } from './utils'; import type { ContractMethodFormFields } from './utils';
interface Props<T extends SmartContractMethod> { interface Props {
data: T; data: ContractAbiItem;
onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>; onSubmit: FormSubmitHandler;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null; methodType: MethodType;
methodType: 'read' | 'write';
} }
const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, methodType }: Props<T>) => { const ContractMethodForm = ({ data, onSubmit, methodType }: Props) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>(); const [ result, setResult ] = React.useState<FormSubmitResult>();
const [ isLoading, setLoading ] = React.useState(false); const [ isLoading, setLoading ] = React.useState(false);
const [ callStrategy, setCallStrategy ] = React.useState<MethodCallStrategy>();
const callStrategyRef = React.useRef(callStrategy);
const formApi = useForm<ContractMethodFormFields>({ const formApi = useForm<ContractMethodFormFields>({
mode: 'all', mode: 'all',
shouldUnregister: true, shouldUnregister: true,
}); });
const handleButtonClick = React.useCallback((event: React.MouseEvent) => {
const callStrategy = event?.currentTarget.getAttribute('data-call-strategy');
setCallStrategy(callStrategy as MethodCallStrategy);
callStrategyRef.current = callStrategy as MethodCallStrategy;
}, []);
const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => { const onFormSubmit: SubmitHandler<ContractMethodFormFields> = React.useCallback(async(formData) => {
// The API used for reading from contracts expects all values to be strings. // The API used for reading from contracts expects all values to be strings.
const formattedData = methodType === 'read' ? const formattedData = callStrategyRef.current === 'api' ?
_mapValues(formData, (value) => value !== undefined ? String(value) : undefined) : _mapValues(formData, (value) => value !== undefined ? String(value) : undefined) :
formData; formData;
const args = transformFormDataToMethodArgs(formattedData); const args = transformFormDataToMethodArgs(formattedData);
...@@ -45,12 +53,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -45,12 +53,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
setResult(undefined); setResult(undefined);
setLoading(true); setLoading(true);
onSubmit(data, args) onSubmit(data, args, callStrategyRef.current)
.then((result) => { .then((result) => {
setResult(result); setResult(result);
}) })
.catch((error) => { .catch((error) => {
setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error); setResult({
source: callStrategyRef.current ?? 'wallet_client',
result: error?.error || error?.data || (error?.reason && { message: error.reason }) || error,
});
setLoading(false); setLoading(false);
}) })
.finally(() => { .finally(() => {
...@@ -69,9 +80,9 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -69,9 +80,9 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
result && setResult(undefined); result && setResult(undefined);
}, [ result ]); }, [ result ]);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => { const inputs: AbiFunction['inputs'] = React.useMemo(() => {
return [ return [
...('inputs' in data ? data.inputs : []), ...('inputs' in data && data.inputs ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ { ...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`, name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const, type: 'uint256' as const,
...@@ -83,6 +94,28 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -83,6 +94,28 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
const outputs = 'outputs' in data && data.outputs ? data.outputs : []; const outputs = 'outputs' in data && data.outputs ? data.outputs : [];
const callStrategies = (() => {
switch (methodType) {
case 'read': {
return { primary: 'api', secondary: undefined };
}
case 'write': {
return {
primary: config.features.blockchainInteraction.isEnabled ? 'wallet_client' : undefined,
secondary: 'outputs' in data && Boolean(data.outputs?.length) ? 'api' : undefined,
};
}
default: {
return { primary: undefined, secondary: undefined };
}
}
})();
// 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.';
return ( return (
<Box> <Box>
<FormProvider { ...formApi }> <FormProvider { ...formApi }>
...@@ -93,20 +126,65 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -93,20 +126,65 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
> >
<Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}> <Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
{ inputs.map((input, index) => { { inputs.map((input, index) => {
if (input.components && input.type === 'tuple') { const props = {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>; data: input,
basePath: `${ index }`,
isDisabled: isLoading,
level: 0,
};
if ('components' in input && input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } { ...props }/>;
} }
const arrayMatch = input.type.match(ARRAY_REGEXP); const arrayMatch = matchArray(input.type);
if (arrayMatch) { if (arrayMatch) {
return <ContractMethodFieldInputArray key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>; if (arrayMatch.isNested) {
const fieldsWithErrors = Object.keys(formApi.formState.errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(index + ':'));
return (
<ContractMethodFieldAccordion
key={ index }
level={ 0 }
label={ getFieldLabel(input) }
isInvalid={ isInvalid }
>
<ContractMethodFieldInputArray { ...props }/>
</ContractMethodFieldAccordion>
);
}
return <ContractMethodFieldInputArray key={ index } { ...props }/>;
} }
return <ContractMethodFieldInput key={ index } data={ input } path={ `${ index }` } isDisabled={ isLoading } level={ 0 }/>; return <ContractMethodFieldInput key={ index } { ...props } path={ `${ index }` }/>;
}) } }) }
</Flex> </Flex>
{ callStrategies.secondary && (
<Button
isLoading={ callStrategy === callStrategies.secondary && isLoading }
isDisabled={ isLoading }
onClick={ handleButtonClick }
loadingText="Simulate"
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
mr={ 3 }
type="submit"
data-call-strategy={ callStrategies.secondary }
>
Simulate
</Button>
) }
<Tooltip label={ !callStrategies.primary ? noWalletClientText : undefined } maxW="300px">
<Button <Button
isLoading={ isLoading } isLoading={ callStrategy === callStrategies.primary && isLoading }
isDisabled={ isLoading || !callStrategies.primary }
onClick={ handleButtonClick }
loadingText={ methodType === 'write' ? 'Write' : 'Read' } loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline" variant="outline"
size="sm" size="sm"
...@@ -114,13 +192,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res ...@@ -114,13 +192,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
width="min-content" width="min-content"
px={ 4 } px={ 4 }
type="submit" type="submit"
data-call-strategy={ callStrategies.primary }
> >
{ methodType === 'write' ? 'Write' : 'Read' } { methodType === 'write' ? 'Write' : 'Read' }
</Button> </Button>
</Tooltip>
</chakra.form> </chakra.form>
</FormProvider> </FormProvider>
{ methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> } { 'outputs' in data && Boolean(data.outputs?.length) && <ContractMethodOutputs data={ outputs }/> }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> } { result && <ContractMethodResult abiItem={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box> </Box>
); );
}; };
......
import { Flex, chakra } from '@chakra-ui/react'; import { Flex, chakra } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { AbiFunction } from 'viem';
import type { SmartContractMethodOutput } from 'types/api/contract';
import IconSvg from 'ui/shared/IconSvg'; import IconSvg from 'ui/shared/IconSvg';
interface Props { interface Props {
data: Array<SmartContractMethodOutput>; data: AbiFunction['outputs'];
} }
const ContractMethodFormOutputs = ({ data }: Props) => { const ContractMethodOutputs = ({ data }: Props) => {
if (data.length === 0) { if (data.length === 0) {
return null; return null;
} }
...@@ -32,4 +31,4 @@ const ContractMethodFormOutputs = ({ data }: Props) => { ...@@ -32,4 +31,4 @@ const ContractMethodFormOutputs = ({ data }: Props) => {
); );
}; };
export default React.memo(ContractMethodFormOutputs); 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 { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import type { ContractMethodReadResult } from './types'; import type { FormSubmitResultApi } from '../types';
import * as contractMethodsMock from 'mocks/contract/methods'; import * as contractMethodsMock from 'mocks/contract/methods';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import ContractReadResult from './ContractReadResult'; import ContractMethodResultApi from './ContractMethodResultApi';
const item = contractMethodsMock.read[0]; const item = contractMethodsMock.read[0];
const onSettle = () => Promise.resolve(); const onSettle = () => Promise.resolve();
test.use({ viewport: { width: 500, height: 500 } }); test.use({ viewport: { width: 500, height: 500 } });
test('default error', async({ mount }) => { test('default error', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
error: 'I am an error', error: 'I am an error',
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('error with code', async({ mount }) => { test('error with code', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
message: 'I am an error', message: 'I am an error',
code: -32017, code: -32017,
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('raw error', async({ mount }) => { test('raw error', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72', raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72',
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('complex error', async({ mount }) => { test('complex error', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: true, is_error: true,
result: { result: {
method_call: 'SomeCustomError(address addr, uint256 balance)', method_call: 'SomeCustomError(address addr, uint256 balance)',
...@@ -74,34 +61,26 @@ test('complex error', async({ mount }) => { ...@@ -74,34 +61,26 @@ test('complex error', async({ mount }) => {
], ],
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('success', async({ mount }) => { test('success', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: false, is_error: false,
result: { result: {
names: [ 'address' ], names: [ 'address' ],
output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ], output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ],
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('complex success', async({ mount }) => { test('complex success', async({ render }) => {
const result: ContractMethodReadResult = { const result: FormSubmitResultApi['result'] = {
is_error: false, is_error: false,
result: { result: {
names: [ names: [
...@@ -122,11 +101,7 @@ test('complex success', async({ mount }) => { ...@@ -122,11 +101,7 @@ test('complex success', async({ mount }) => {
], ],
}, },
}; };
const component = await mount( const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); 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 { test, expect } from '@playwright/experimental-ct-react';
import React from 'react'; import React from 'react';
import TestApp from 'playwright/TestApp'; import { test, expect } from 'playwright/lib';
import ContractWriteResultDumb from './ContractWriteResultDumb'; import type { PropsDumb } from './ContractMethodResultWalletClient';
import { ContractMethodResultWalletClientDumb } from './ContractMethodResultWalletClient';
test('loading', async({ mount }) => { test('loading', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'pending' as const, status: 'pending' as const,
error: null, error: null,
}, } as PropsDumb['txInfo'],
result: { result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
}, },
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('success', async({ mount }) => { test('success', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'success' as const, status: 'success' as const,
error: null, error: null,
}, } as PropsDumb['txInfo'],
result: { result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
}, },
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('error +@mobile', async({ mount }) => { test('error +@mobile', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'error' as const, status: 'error' as const,
...@@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => { ...@@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => {
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
message: 'missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ]', 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 Error,
}, } as PropsDumb['txInfo'],
result: { result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`, hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
}, },
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('error in result', async({ mount }) => { test('error in result', async({ render }) => {
const props = { const props = {
txInfo: { txInfo: {
status: 'idle' as const, status: 'idle' as const,
error: null, error: null,
}, } as unknown as PropsDumb['txInfo'],
result: { result: {
message: 'wallet is not connected', message: 'wallet is not connected',
} as Error, } as Error,
onSettle: () => {}, onSettle: () => {},
}; };
const component = await mount( const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
import { Box, chakra, Spinner } from '@chakra-ui/react'; import { chakra, Spinner, Box } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { UseWaitForTransactionReceiptReturnType } from 'wagmi';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ContractMethodWriteResult } from './types'; import type { FormSubmitResultWalletClient } from '../types';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
interface Props { interface Props {
result: ContractMethodWriteResult; result: FormSubmitResultWalletClient['result'];
onSettle: () => void; onSettle: () => void;
txInfo: {
status: 'loading' | 'success' | 'error' | 'idle' | 'pending';
error: Error | null;
};
} }
const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { const ContractMethodResultWalletClient = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractMethodResultWalletClientDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export interface PropsDumb {
result: FormSubmitResultWalletClient['result'];
onSettle: () => void;
txInfo: UseWaitForTransactionReceiptReturnType;
}
export const ContractMethodResultWalletClientDumb = ({ result, onSettle, txInfo }: PropsDumb) => {
const txHash = result && 'hash' in result ? result.hash : undefined; const txHash = result && 'hash' in result ? result.hash : undefined;
React.useEffect(() => { React.useEffect(() => {
...@@ -93,4 +106,4 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => { ...@@ -93,4 +106,4 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
); );
}; };
export default React.memo(ContractWriteResultDumb); export default React.memo(ContractMethodResultWalletClient);
import React from 'react'; import React from 'react';
import type { SmartContractMethodArgType } from 'types/api/contract'; import type { MatchInt } from './utils';
import type { MatchInt } from './useArgTypeMatchInt';
interface Params { interface Params {
argType: SmartContractMethodArgType; argType: string;
argTypeMatchInt: MatchInt | null; argTypeMatchInt: MatchInt | null;
} }
......
import React from 'react'; import React from 'react';
import { getAddress, isAddress, isHex } from 'viem'; import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract'; import type { MatchInt } from './utils';
import type { MatchInt } from './useArgTypeMatchInt';
import { BYTES_REGEXP } from './utils'; import { BYTES_REGEXP } from './utils';
interface Params { interface Params {
argType: SmartContractMethodArgType; argType: string;
argTypeMatchInt: MatchInt | null; argTypeMatchInt: MatchInt | null;
isOptional: boolean; isOptional: boolean;
} }
......
import _set from 'lodash/set'; import _set from 'lodash/set';
import type { SmartContractMethodInput } from 'types/api/contract'; import type { ContractAbiItemInput } from '../types';
export type ContractMethodFormFields = Record<string, string | boolean | undefined>; export type ContractMethodFormFields = Record<string, string | boolean | undefined>;
...@@ -10,6 +10,62 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i; ...@@ -10,6 +10,62 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i;
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/; export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
export interface MatchArray {
itemType: string;
size: number;
isNested: boolean;
}
export const matchArray = (argType: string): MatchArray | null => {
const match = argType.match(ARRAY_REGEXP);
if (!match) {
return null;
}
const [ , itemType, size ] = match;
const isNested = Boolean(matchArray(itemType));
return {
itemType,
size: size ? Number(size) : Infinity,
isNested,
};
};
export interface MatchInt {
isUnsigned: boolean;
power: string;
min: bigint;
max: bigint;
}
export const matchInt = (argType: string): MatchInt | null => {
const match = argType.match(INT_REGEXP);
if (!match) {
return null;
}
const [ , isUnsigned, power = '256' ] = match;
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
return { isUnsigned: Boolean(isUnsigned), power, min, max };
};
export const transformDataForArrayItem = (data: ContractAbiItemInput, index: number): ContractAbiItemInput => {
const arrayMatchType = matchArray(data.type);
const arrayMatchInternalType = data.internalType ? matchArray(data.internalType) : null;
const childrenInternalType = arrayMatchInternalType?.itemType.replaceAll('struct ', '');
const postfix = childrenInternalType ? ' ' + childrenInternalType : '';
return {
...data,
type: arrayMatchType?.itemType || data.type,
internalType: childrenInternalType,
name: `#${ index + 1 }${ postfix }`,
};
};
export const getIntBoundaries = (power: number, isUnsigned: boolean) => { export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
const maxUnsigned = BigInt(2 ** power); const maxUnsigned = BigInt(2 ** power);
const max = isUnsigned ? maxUnsigned - BigInt(1) : maxUnsigned / BigInt(2) - BigInt(1); const max = isUnsigned ? maxUnsigned - BigInt(1) : maxUnsigned / BigInt(2) - BigInt(1);
...@@ -41,7 +97,7 @@ function filterOurEmptyItems(array: Array<unknown>): Array<unknown> { ...@@ -41,7 +97,7 @@ function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
.filter((item) => item !== undefined); .filter((item) => item !== undefined);
} }
export function getFieldLabel(input: SmartContractMethodInput, isRequired?: boolean) { export function getFieldLabel(input: ContractAbiItemInput, isRequired?: boolean) {
const name = input.name || input.internalType || '<unnamed argument>'; const name = input.name || input.internalType || '<unnamed argument>';
return `${ name } (${ input.type })${ isRequired ? '*' : '' }`; return `${ name } (${ input.type })${ isRequired ? '*' : '' }`;
} }
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 ]);
}
import React from 'react';
import type { Abi } from 'viem';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { ContractAbiItem, FormSubmitResult } from './types';
import config from 'configs/app';
import { getNativeCoinValue } from './utils';
interface Params {
item: ContractAbiItem;
args: Array<unknown>;
addressHash: string;
}
export default function useCallMethodWalletClient(): (params: Params) => Promise<FormSubmitResult> {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
return React.useCallback(async({ args, item, addressHash }) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (!walletClient) {
throw new Error('Wallet Client is not defined');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { source: 'wallet_client', result: { hash } };
}
const methodName = item.name;
if (!methodName) {
throw new Error('Method name is not defined');
}
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const hash = await walletClient.writeContract({
args: _args,
// Here we provide the ABI as an array containing only one item from the submitted form.
// This is a workaround for the issue with the "viem" library.
// It lacks a "method_id" field to uniquely identify the correct method and instead attempts to find a method based on its name.
// But the name is not unique in the contract ABI and this behavior in the "viem" could result in calling the wrong method.
// See related issues:
// - https://github.com/blockscout/frontend/issues/1032,
// - https://github.com/blockscout/frontend/issues/1327
abi: [ item ] as Abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { source: 'wallet_client', result: { hash } };
}, [ chainId, isConnected, switchChainAsync, walletClient ]);
}
import React from 'react';
import type { FormSubmitHandler } from './types';
import config from 'configs/app';
import useCallMethodApi from './useCallMethodApi';
import useCallMethodWalletClient from './useCallMethodWalletClient';
interface Params {
tab: string;
addressHash: string;
}
function useFormSubmit({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
const callMethodWalletClient = useCallMethodWalletClient();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
case 'wallet_client': {
return callMethodWalletClient({ args, item, addressHash });
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, callMethodWalletClient, tab ]);
}
function useFormSubmitFallback({ tab, addressHash }: Params): FormSubmitHandler {
const callMethodApi = useCallMethodApi();
return React.useCallback(async(item, args, strategy) => {
switch (strategy) {
case 'api': {
if (!('method_id' in item)) {
throw new Error('Method ID is not defined');
}
return callMethodApi({
args,
methodId: item.method_id,
addressHash,
isCustomAbi: tab === 'read_custom_methods' || tab === 'write_custom_methods',
isProxy: tab === 'read_proxy' || tab === 'write_proxy',
});
}
default: {
throw new Error(`Unknown call strategy "${ strategy }"`);
}
}
}, [ addressHash, callMethodApi, tab ]);
}
const hook = config.features.blockchainInteraction.isEnabled ? useFormSubmit : useFormSubmitFallback;
export default hook;
import React from 'react';
import { scroller } from 'react-scroll';
import type { ContractAbi } from './types';
export const getElementName = (id: string) => `method_${ id }`;
export default function useScrollToMethod(data: ContractAbi, onScroll: (indices: Array<number>) => void) {
React.useEffect(() => {
const id = window.location.hash.replace('#', '');
if (!id) {
return;
}
const index = data.findIndex((item) => 'method_id' in item && item.method_id === id);
if (index > -1) {
scroller.scrollTo(getElementName(id), {
duration: 500,
smooth: true,
offset: -100,
});
onScroll([ index ]);
}
}, [ data, onScroll ]);
}
export const getNativeCoinValue = (value: unknown) => {
if (typeof value !== 'string') {
return BigInt(0);
}
return BigInt(value);
};
import { Alert, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract'; import config from 'configs/app';
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 getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion'; import useAccount from 'lib/web3/useAccount';
import ContentLoader from 'ui/shared/ContentLoader'; import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert'; import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount';
interface Props { interface Props {
isLoading?: boolean; isLoading?: boolean;
} }
const ContractRead = ({ isLoading }: Props) => { const ContractRead = ({ isLoading }: Props) => {
const apiFetch = useApiFetch(); const { address } = useAccount();
const account = useWatchAccount();
const router = useRouter(); const router = useRouter();
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
...@@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => { ...@@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => {
pathParams: { hash: addressHash }, pathParams: { hash: addressHash },
queryParams: { queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false', is_custom_abi: isCustomAbi ? 'true' : 'false',
from: account?.address, from: address,
}, },
queryOptions: { queryOptions: {
enabled: !isLoading, enabled: !isLoading,
}, },
}); });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<unknown>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
fetchParams: {
method: 'POST',
body: {
args,
method_id: item.method_id,
contract_type: isProxy ? 'proxy' : 'regular',
from: account?.address,
},
},
});
}, [ account?.address, addressHash, apiFetch, isCustomAbi, isProxy ]);
const renderItemContent = React.useCallback((item: SmartContractReadMethod, index: number, id: number) => {
if (item.error) {
return <Alert status="error" fontSize="sm" wordBreak="break-word">{ item.error }</Alert>;
}
if (item.outputs?.some(({ value }) => value !== undefined && value !== null)) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ item.outputs.map((output, index) => <ContractMethodConstant key={ index } data={ output }/>) }
</Flex>
);
}
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractReadResult }
methodType="read"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => { ...@@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => {
return ( return (
<> <>
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
{ account && <ContractConnectWallet/> } { config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/> <ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="read"/>
</> </>
); );
}; };
......
import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractMethodReadResult } from './types';
import type { SmartContractQueryMethodReadSuccess, SmartContractReadMethod } from 'types/api/contract';
import hexToUtf8 from 'lib/hexToUtf8';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
const ContractReadResultError = ({ children }: {children: React.ReactNode}) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</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 {
item: SmartContractReadMethod;
result: ContractMethodReadResult;
onSettle: () => void;
}
const ContractReadResult = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractReadResultError>{ result.statusText }</ContractReadResultError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractReadResultError>{ result.result.error }</ContractReadResultError>;
}
if ('message' in result.result) {
return <ContractReadResultError>[{ result.result.code }] { result.result.message }</ContractReadResultError>;
}
if ('raw' in result.result) {
return <ContractReadResultError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractReadResultError>;
}
if ('method_id' in result.result) {
return <ContractReadResultError>{ JSON.stringify(result.result, undefined, 2) }</ContractReadResultError>;
}
return <ContractReadResultError>Something went wrong.</ContractReadResultError>;
}
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) => <ContractReadResultItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractReadResult);
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
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 getQueryParamString from 'lib/router/getQueryParamString';
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';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet'; import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert'; import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress'; import ContractImplementationAddress from './ContractImplementationAddress';
import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils';
interface Props { interface Props {
isLoading?: boolean; isLoading?: boolean;
} }
const ContractWrite = ({ isLoading }: Props) => { const ContractWrite = ({ isLoading }: Props) => {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
const router = useRouter(); const router = useRouter();
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
...@@ -46,63 +35,6 @@ const ContractWrite = ({ isLoading }: Props) => { ...@@ -46,63 +35,6 @@ const ContractWrite = ({ isLoading }: Props) => {
}, },
}); });
const contractAbi = useContractAbi({ addressHash, isProxy, isCustomAbi });
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<unknown>) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
if (chainId && String(chainId) !== config.chain.id) {
await switchChainAsync?.({ chainId: Number(config.chain.id) });
}
if (!contractAbi) {
throw new Error('Something went wrong. Try again later.');
}
if (item.type === 'receive' || item.type === 'fallback') {
const value = getNativeCoinValue(args[0]);
const hash = await walletClient?.sendTransaction({
to: addressHash as `0x${ string }` | undefined,
value,
});
return { hash };
}
const methodName = item.name;
if (!methodName) {
throw new Error('Method name is not defined');
}
const _args = args.slice(0, item.inputs.length);
const value = getNativeCoinValue(args[item.inputs.length]);
const abi = prepareAbi(contractAbi, item);
const hash = await walletClient?.writeContract({
args: _args,
abi,
functionName: methodName,
address: addressHash as `0x${ string }`,
value,
});
return { hash };
}, [ isConnected, chainId, contractAbi, walletClient, addressHash, switchChainAsync ]);
const renderItemContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return (
<ContractMethodForm
key={ id + '_' + index }
data={ item }
onSubmit={ handleMethodFormSubmit }
resultComponent={ ContractWriteResult }
methodType="write"
/>
);
}, [ handleMethodFormSubmit ]);
if (isError) { if (isError) {
return <DataFetchAlert/>; return <DataFetchAlert/>;
} }
...@@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => { ...@@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => {
return ( return (
<> <>
{ isCustomAbi && <ContractCustomAbiAlert/> } { isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/> { config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> } { isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/> <ContractAbi data={ data } addressHash={ addressHash } tab={ tab } methodType="write"/>
</> </>
); );
}; };
......
import React from 'react';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ResultComponentProps } from './methodForm/types';
import type { ContractMethodWriteResult } from './types';
import type { SmartContractWriteMethod } from 'types/api/contract';
import ContractWriteResultDumb from './ContractWriteResultDumb';
const ContractWriteResult = ({ result, onSettle }: ResultComponentProps<SmartContractWriteMethod>) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransactionReceipt({
hash: txHash,
});
return <ContractWriteResultDumb result={ result as ContractMethodWriteResult } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export default React.memo(ContractWriteResult) as typeof ContractWriteResult;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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