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
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"'),
}),
});
const adminServiceSchema = yup
.object()
.shape({
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST: yup
.string()
.when('NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', {
is: (value: boolean) => value,
.when([ 'NEXT_PUBLIC_IS_ACCOUNT_SUPPORTED', 'NEXT_PUBLIC_MARKETPLACE_ENABLED' ], {
is: (value1: boolean, value2: boolean) => value1 || value2,
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
.concat(rollupSchema)
.concat(beaconChainSchema)
.concat(bridgedTokensSchema)
.concat(sentrySchema);
.concat(sentrySchema)
.concat(adminServiceSchema);
export default schema;
#!/bin/bash
secrets_file=".env.secrets"
test_folder="./test"
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_TAG=$(git describe --tags --abbrev=0)
../../scripts/collect_envs.sh ../../../docs/ENVS.md
cp ../../../.env.example ${secrets_file}
# Copy test assets
mkdir -p "./public/assets"
......@@ -26,7 +24,6 @@ validate_file() {
dotenv \
-e $test_file \
-e $common_file \
-e $secrets_file \
yarn run validate -- --silent
if [ $? -eq 0 ]; then
......@@ -46,4 +43,4 @@ for file in "${test_files[@]}"; do
if [ $? -eq 1 ]; then
exit 1
fi
done
\ No newline at end of file
done
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_BANNER_PROVIDER=slise
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://example.com
......@@ -60,4 +72,4 @@ NEXT_PUBLIC_VISUALIZE_API_BASE_PATH=https://example.com
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
NEXT_PUBLIC_SWAP_BUTTON_URL=uniswap
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE=stability
\ No newline at end of file
NEXT_PUBLIC_VALIDATORS_CHAIN_TYPE=stability
NEXT_PUBLIC_API_HOST=blockscout.com
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_NAME=Testnet
......@@ -50,11 +50,11 @@ frontend:
NEXT_PUBLIC_APP_ENV: development
NEXT_PUBLIC_APP_INSTANCE: review
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_NETWORK_LOGO: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/goerli.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/goerli.svg
NEXT_PUBLIC_API_HOST: eth-sepolia.blockscout.com
NEXT_PUBLIC_STATS_API_HOST: https://stats-goerli.k8s-dev.blockscout.com/
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/sepolia.svg
NEXT_PUBLIC_NETWORK_ICON: https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/sepolia.png
NEXT_PUBLIC_API_HOST: eth-sepolia.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_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
......
......@@ -200,6 +200,8 @@ Settings for meta tags, OG tags and SEO
| `total_reward` | Total block reward |
| `nonce` | Block nonce |
| `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
| `tx_fee` | Total transaction fee |
| `gas_fees` | Gas fees breakdown |
| `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
| Id | Description |
......
This diff is collapsed.
......@@ -20,6 +20,7 @@ export default function parseMetaPayload(meta: AddressMetadataTag['meta']): Addr
'appID',
'logoURL',
'text',
'tagUrl',
'tooltipIcon',
'tooltipTitle',
'tooltipDescription',
......
......@@ -7,7 +7,6 @@ import { STORAGE_KEY, STORAGE_LIMIT } from './consts';
export interface GrowthBookFeatures {
test_value: string;
security_score_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 ? (
'Type': 'Action button';
'Info': string;
'Source': 'Txn' | 'NFT collection' | 'NFT item';
} | {
'Type': 'Address tag';
'Info': string;
'URL': string;
}
) :
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
......
......@@ -28,6 +28,7 @@ SocketMessage.AddressTxs |
SocketMessage.AddressTxsPending |
SocketMessage.AddressTokenTransfer |
SocketMessage.AddressChangedBytecode |
SocketMessage.AddressFetchedBytecode |
SocketMessage.SmartContractWasVerified |
SocketMessage.TokenTransfers |
SocketMessage.TokenTotalSupply |
......@@ -64,6 +65,7 @@ export namespace SocketMessage {
export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transactions: Array<Transaction> }>;
export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfers: Array<TokenTransfer> }>;
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 TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: 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 = {
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 = {
hash: hash,
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 {
SmartContractQueryMethodReadError,
SmartContractQueryMethodReadSuccess,
SmartContractQueryMethodError,
SmartContractQueryMethodSuccess,
SmartContractReadMethod,
SmartContractWriteMethod,
} from 'types/api/contract';
......@@ -94,7 +94,7 @@ export const read: Array<SmartContractReadMethod> = [
},
];
export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
export const readResultSuccess: SmartContractQueryMethodSuccess = {
is_error: false,
result: {
names: [ 'amount' ],
......@@ -104,7 +104,7 @@ export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
},
};
export const readResultError: SmartContractQueryMethodReadError = {
export const readResultError: SmartContractQueryMethodError = {
is_error: true,
result: {
message: 'Some shit happened',
......
export const data = {
import type { OptimisticL2WithdrawalsResponse } from 'types/api/optimisticL2';
export const data: OptimisticL2WithdrawalsResponse = {
items: [
{
challenge_period_end: null,
......@@ -11,12 +13,12 @@ export const data = {
private_tags: [],
public_tags: [],
watchlist_names: [],
ens_domain_name: null,
},
l1_tx_hash: '0x1a235bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136dca684',
l2_timestamp: '2022-02-15T12:50:02.000000Z',
l2_tx_hash: '0x918cd8c5c24c17e06cd02b0379510c4ad56324bf153578fb9caaaa2fe4e7dc35',
msg_nonce: 396,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620172',
msg_nonce_version: 1,
status: 'Ready to prove',
},
......@@ -27,7 +29,6 @@ export const data = {
l2_timestamp: null,
l2_tx_hash: '0x2f117bee32ac10cb7efdad98415737484ca66386e491cde9e17d42b136def593',
msg_nonce: 391,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620167',
msg_nonce_version: 1,
status: 'Ready to prove',
},
......@@ -38,7 +39,6 @@ export const data = {
l2_timestamp: null,
l2_tx_hash: '0xe14b1f46838176702244a5343629bcecf728ca2d9881d47b4ce46e00c387d7e3',
msg_nonce: 390,
msg_nonce_raw: '1766847064778384329583297500742918515827483896875618958121606201292620166',
msg_nonce_version: 1,
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 nameTag1: AddressMetadataTag = {
slug: 'ethermineru',
name: 'Ethermine.ru',
export const nameTag: AddressMetadataTagApi = {
slug: 'quack-quack',
name: 'Quack quack',
tagType: 'name',
ordinal: 0,
ordinal: 99,
meta: null,
};
export const genericTag1: AddressMetadataTag = {
slug: 'ethermine.ru',
name: 'Ethermine.ru',
export const customNameTag: AddressMetadataTagApi = {
slug: 'unicorn-uproar',
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',
ordinal: 0,
meta: null,
ordinal: 55,
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',
name: 'Aerodrome',
tagType: 'protocol',
ordinal: 0,
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: [
{
amount: '192175000000000',
......@@ -10,7 +13,7 @@ export const data = {
is_contract: false,
is_verified: null,
name: null,
},
} as AddressParam,
timestamp: '2022-06-07T18:12:24.000000Z',
validator_index: 49622,
},
......@@ -24,7 +27,7 @@ export const data = {
is_contract: false,
is_verified: null,
name: null,
},
} as AddressParam,
timestamp: '2022-05-07T18:12:24.000000Z',
validator_index: 49621,
},
......@@ -38,7 +41,7 @@ export const data = {
is_contract: false,
is_verified: null,
name: null,
},
} as AddressParam,
timestamp: '2022-04-07T18:12:24.000000Z',
validator_index: 49620,
},
......
......@@ -10,6 +10,7 @@ const headers = require('./nextjs/headers');
const redirects = require('./nextjs/redirects');
const rewrites = require('./nextjs/rewrites');
/** @type {import('next').NextConfig} */
const moduleExports = {
transpilePackages: [
'react-syntax-highlighter',
......@@ -46,6 +47,14 @@ const moduleExports = {
productionBrowserSourceMaps: true,
experimental: {
instrumentationHook: true,
turbo: {
rules: {
'*.svg': {
loaders: [ '@svgr/webpack' ],
as: '*.js',
},
},
},
},
};
......
......@@ -18,6 +18,7 @@ import theme from 'theme';
export type Props = {
children: React.ReactNode;
withSocket?: boolean;
withWalletClient?: boolean;
appContext?: {
pageProps: PageProps;
};
......@@ -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({
defaultOptions: {
queries: {
......@@ -63,9 +77,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<SocketProvider url={ withSocket ? `ws://${ config.app.host }:${ app.socketPort }` : undefined }>
<AppContextProvider { ...appContext }>
<GrowthBookProvider>
<WagmiProvider config={ wagmiConfig }>
<WalletClientProvider withWalletClient={ withWalletClient }>
{ children }
</WagmiProvider>
</WalletClientProvider>
</GrowthBookProvider>
</AppContextProvider>
</SocketProvider>
......
......@@ -16,6 +16,11 @@ const fixture: TestFixture<MockEnvsFixture, { page: Page }> = async({ page }, us
export default fixture;
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: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'shibarium' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
......@@ -24,6 +29,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'zkEvm' ],
[ '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: [
[ '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"}]' ],
......@@ -37,4 +46,18 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
blockHiddenFields: [
[ '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
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: '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: 'token_transfer', payload: { token_transfers: Array<TokenTransfer> }): void;
export function sendMessage(socket: WebSocket, channel: Channel, msg: string, payload: unknown): void {
......
......@@ -10,45 +10,3 @@ export const viewport = {
export const maskColor = '#4299E1'; // blue.400
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 \
-v NEXT_PUBLIC_GIT_TAG=$(git describe --tags --abbrev=0) \
-e $config_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
\ No newline at end of file
......@@ -7,6 +7,7 @@ export interface AddressMetadataInfo {
export type AddressMetadataTagType = 'name' | 'generic' | 'classifier' | 'information' | 'note' | 'protocol';
// Response model from Metadata microservice API
export interface AddressMetadataTag {
slug: string;
name: string;
......@@ -14,3 +15,20 @@ export interface AddressMetadataTag {
ordinal: number;
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 {
label: string;
display_name: string;
......@@ -22,6 +24,10 @@ export type AddressParamBasic = {
is_contract: boolean;
is_verified: boolean | null;
ens_domain_name: string | null;
metadata?: {
reputation: number | null;
tags: Array<AddressMetadataTagApi>;
} | null;
}
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 SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
......@@ -78,49 +78,19 @@ export interface SmartContractExternalLibrary {
name: string;
}
export interface SmartContractMethodBase {
inputs: Array<SmartContractMethodInput>;
outputs?: Array<SmartContractMethodOutput>;
constant: boolean;
name: string;
stateMutability: SmartContractMethodStateMutability;
type: 'function';
payable: boolean;
error?: string;
export type SmartContractMethodOutputValue = string | boolean | object;
export type SmartContractMethodOutput = AbiFunction['outputs'][number] & { value?: SmartContractMethodOutputValue };
export type SmartContractMethodBase = Omit<AbiFunction, 'outputs'> & {
method_id: string;
}
outputs: Array<SmartContractMethodOutput>;
constant?: boolean;
error?: string;
};
export type SmartContractReadMethod = SmartContractMethodBase;
export 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 SmartContractWriteMethod = SmartContractMethodBase | AbiFallback | AbiReceive;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput {
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 {
export interface SmartContractQueryMethodSuccess {
is_error: false;
result: {
names: Array<string | [ string, Array<string> ]>;
......@@ -131,7 +101,7 @@ export interface SmartContractQueryMethodReadSuccess {
};
}
export interface SmartContractQueryMethodReadError {
export interface SmartContractQueryMethodError {
is_error: true;
result: {
code: number;
......@@ -147,7 +117,7 @@ export interface SmartContractQueryMethodReadError {
};
}
export type SmartContractQueryMethodRead = SmartContractQueryMethodReadSuccess | SmartContractQueryMethodReadError;
export type SmartContractQueryMethod = SmartContractQueryMethodSuccess | SmartContractQueryMethodError;
// VERIFICATION
......
import type { AddressMetadataTagType } from 'types/api/addressMetadata';
import type { AddressMetadataTagApi } from 'types/api/addressMetadata';
export interface AddressMetadataInfoFormatted {
addresses: Record<string, {
......@@ -7,21 +7,4 @@ export interface AddressMetadataInfoFormatted {
}>;
}
export interface AddressMetadataTagFormatted {
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;
}
export type AddressMetadataTagFormatted = AddressMetadataTagApi;
......@@ -5,6 +5,8 @@ export const BLOCK_FIELDS_IDS = [
'total_reward',
'nonce',
'miner',
'L1_status',
'batch',
] as const;
export type BlockFieldId = ArrayElement<typeof BLOCK_FIELDS_IDS>;
......@@ -7,6 +7,8 @@ export const TX_FIELDS_IDS = [
'tx_fee',
'gas_fees',
'burnt_fees',
'L1_status',
'batch',
] as const;
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';
import type { RoutedSubTab } from 'ui/shared/Tabs/types';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
interface Props {
tabs: Array<RoutedSubTab>;
......@@ -16,21 +15,12 @@ const TAB_LIST_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) {
return null;
}
return (
<Web3ModalProvider fallback={ fallback }>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
</Web3ModalProvider>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS } isLoading={ isLoading }/>
);
};
......
import { Accordion, Box, Flex, Link } from '@chakra-ui/react';
import _range from 'lodash/range';
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> {
data: Array<T>;
addressHash?: string;
renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
interface Props {
data: TContractAbi;
addressHash: 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 [ id, setId ] = React.useState(0);
React.useEffect(() => {
const hash = window.location.hash.replace('#', '');
useScrollToMethod(data, setExpandedSections);
if (!hash) {
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 handleFormSubmit = useFormSubmit({ addressHash, tab });
const handleAccordionStateChange = React.useCallback((newValue: Array<number>) => {
setExpandedSections(newValue);
......@@ -73,14 +60,15 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
</Flex>
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => (
<ContractMethodsAccordionItem
<ContractAbiItem
key={ index }
data={ item }
id={ id }
index={ index }
addressHash={ addressHash }
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
tab={ tab }
onSubmit={ handleFormSubmit }
methodType={ methodType }
/>
)) }
</Accordion>
......@@ -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 { 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 config from 'configs/app';
import Tag from 'ui/shared/chakra/Tag';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import Hint from 'ui/shared/Hint';
import IconSvg from 'ui/shared/IconSvg';
interface Props<T extends SmartContractMethod> {
data: T;
import ContractAbiItemConstant from './ContractAbiItemConstant';
import ContractMethodForm from './form/ContractMethodForm';
import { getElementName } from './useScrollToMethod';
interface Props {
data: TContractAbiItem;
index: number;
id: number;
addressHash?: string;
renderContent: (item: T, index: number, id: number) => React.ReactNode;
addressHash: 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(() => {
if (!('method_id' in data)) {
return '';
......@@ -43,11 +50,40 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
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 (
<AccordionItem as="section" _first={{ borderTopWidth: 0 }} _last={{ borderBottomWidth: 0 }}>
{ ({ 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">
{ 'method_id' in data && (
<Tooltip label={ hasCopied ? 'Copied!' : 'Copy link' } isOpen={ isOpen || hasCopied } onClose={ onClose }>
......@@ -85,11 +121,17 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
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"/>
</AccordionButton>
</Element>
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
{ renderContent(data, index, id) }
{ content }
</AccordionPanel>
</>
) }
......@@ -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';
import React from 'react';
import { getAddress } from 'viem';
import type { SmartContractMethodOutput } from 'types/api/contract';
import type { ContractAbiItemOutput } from './types';
import { WEI } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import { matchInt } from './form/utils';
function castValueToString(value: number | string | boolean | object | bigint | undefined): string {
switch (typeof value) {
case 'string':
......@@ -28,13 +30,15 @@ function castValueToString(value: number | string | boolean | object | bigint |
}
interface Props {
data: SmartContractMethodOutput;
data: ContractAbiItemOutput;
}
const ContractMethodStatic = ({ data }: Props) => {
const ContractAbiItemConstant = ({ data }: Props) => {
const [ value, setValue ] = React.useState<string>(castValueToString(data.value));
const [ label, setLabel ] = React.useState(currencyUnits.wei.toUpperCase());
const intMatch = matchInt(data.type);
const handleCheckboxChange = React.useCallback((event: ChangeEvent<HTMLInputElement>) => {
const initialValue = castValueToString(data.value);
......@@ -63,9 +67,9 @@ const ContractMethodStatic = ({ data }: Props) => {
return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
{ content }
{ (data.type.includes('int256') || data.type.includes('int128')) && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
{ Number(intMatch?.power) >= 128 && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex>
);
};
export default ContractMethodStatic;
export default ContractAbiItemConstant;
......@@ -3,18 +3,18 @@ import React from 'react';
import { useController, useFormContext } from 'react-hook-form';
import { NumericFormat } from 'react-number-format';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import ClearButton from 'ui/shared/ClearButton';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import ContractMethodMultiplyButton from './ContractMethodMultiplyButton';
import useArgTypeMatchInt from './useArgTypeMatchInt';
import useFormatFieldValue from './useFormatFieldValue';
import useValidateField from './useValidateField';
import { matchInt } from './utils';
interface Props {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
hideLabel?: boolean;
path: string;
className?: string;
......@@ -28,7 +28,7 @@ const ContractMethodFieldInput = ({ data, hideLabel, path: name, className, isDi
const isNativeCoin = data.fieldType === 'native_coin';
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 format = useFormatFieldValue({ argType: data.type, argTypeMatchInt });
......
......@@ -2,7 +2,7 @@ import { Flex } from '@chakra-ui/react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import type { SmartContractMethodInput, SmartContractMethodArgType } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import ContractMethodArrayButton from './ContractMethodArrayButton';
import type { Props as AccordionProps } from './ContractMethodFieldAccordion';
......@@ -10,21 +10,35 @@ import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFieldLabel from './ContractMethodFieldLabel';
import { getFieldLabel } from './utils';
import { getFieldLabel, matchArray, transformDataForArrayItem } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
level: number;
basePath: string;
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 fieldsWithErrors = Object.keys(errors);
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>) => {
event.preventDefault();
......@@ -39,52 +53,69 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
}
}, [ ]);
const getItemData = (index: number) => {
const childrenType = data.type.slice(0, -2) as SmartContractMethodArgType;
const childrenInternalType = data.internalType?.slice(0, parentIndex !== undefined ? -4 : -2).replaceAll('struct ', '');
const namePostfix = childrenInternalType ? ' ' + childrenInternalType : '';
const nameParentIndex = parentIndex !== undefined ? `${ parentIndex + 1 }.` : '';
const nameIndex = index + 1;
if (arrayMatch?.isNested) {
return (
<>
{
registeredIndices.map((registeredIndex, index) => {
const itemData = transformDataForArrayItem(data, index);
const itemBasePath = `${ basePath }:${ registeredIndex }`;
const itemIsInvalid = fieldsWithErrors.some((field) => field.startsWith(itemBasePath));
return (
<ContractMethodFieldAccordion
key={ registeredIndex }
level={ level + 1 }
label={ getFieldLabel(itemData) }
isInvalid={ itemIsInvalid }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
>
<ContractMethodFieldInputArray
key={ registeredIndex }
data={ itemData }
basePath={ itemBasePath }
level={ level + 1 }
isDisabled={ isDisabled }
isArrayElement
/>
</ContractMethodFieldAccordion>
);
})
}
</>
);
}
return {
...data,
type: childrenType,
name: `#${ nameParentIndex + nameIndex }${ namePostfix }`,
};
};
const isNestedArray = data.type.includes('[][]');
const isTupleArray = arrayMatch?.itemType.includes('tuple');
if (isNestedArray) {
return (
<ContractMethodFieldAccordion
level={ level }
label={ getFieldLabel(data) }
isInvalid={ isInvalid }
>
if (isTupleArray) {
const content = (
<>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
const itemData = transformDataForArrayItem(data, index);
return (
<ContractMethodFieldInputArray
<ContractMethodFieldInputTuple
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
onAddClick={ !hasFixedSize && index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ !hasFixedSize && registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
</ContractMethodFieldAccordion>
</>
);
}
const isTupleArray = data.type.includes('tuple');
if (isArrayElement) {
return content;
}
if (isTupleArray) {
return (
<ContractMethodFieldAccordion
level={ level }
......@@ -94,22 +125,7 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
index={ parentIndex }
isInvalid={ isInvalid }
>
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
return (
<ContractMethodFieldInputTuple
key={ registeredIndex }
data={ itemData }
basePath={ `${ basePath }:${ registeredIndex }` }
level={ level + 1 }
onAddClick={ index === registeredIndices.length - 1 ? handleAddButtonClick : undefined }
onRemoveClick={ registeredIndices.length > 1 ? handleRemoveButtonClick : undefined }
index={ registeredIndex }
isDisabled={ isDisabled }
/>
);
}) }
{ content }
</ContractMethodFieldAccordion>
);
}
......@@ -117,10 +133,10 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
// primitive value array
return (
<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%">
{ registeredIndices.map((registeredIndex, index) => {
const itemData = getItemData(index);
const itemData = transformDataForArrayItem(data, index);
return (
<Flex key={ registeredIndex } alignItems="flex-start" columnGap={ 3 }>
......@@ -132,9 +148,9 @@ const ContractMethodFieldInputArray = ({ data, level, basePath, onAddClick, onRe
px={ 0 }
isDisabled={ isDisabled }
/>
{ registeredIndices.length > 1 &&
{ !hasFixedSize && registeredIndices.length > 1 &&
<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"/> }
</Flex>
);
......
import React from 'react';
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 ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import { ARRAY_REGEXP, getFieldLabel } from './utils';
import { getFieldLabel, matchArray } from './utils';
interface Props extends Pick<AccordionProps, 'onAddClick' | 'onRemoveClick' | 'index'> {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
basePath: string;
level: number;
isDisabled: boolean;
......@@ -21,6 +21,10 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
const fieldsWithErrors = Object.keys(errors);
const isInvalid = fieldsWithErrors.some((field) => field.startsWith(basePath));
if (!('components' in data)) {
return null;
}
return (
<ContractMethodFieldAccordion
{ ...accordionProps }
......@@ -29,7 +33,7 @@ const ContractMethodFieldInputTuple = ({ data, basePath, level, isDisabled, ...a
isInvalid={ isInvalid }
>
{ data.components?.map((component, index) => {
if (component.components && component.type === 'tuple') {
if ('components' in component && component.type === 'tuple') {
return (
<ContractMethodFieldInputTuple
key={ index }
......@@ -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) {
const [ , itemType ] = arrayMatch;
return (
<ContractMethodFieldInputArray
key={ index }
data={ component }
basePath={ `${ basePath }:${ index }` }
level={ itemType === 'tuple' ? level + 1 : level }
level={ arrayMatch.itemType === 'tuple' ? level + 1 : level }
isDisabled={ isDisabled }
/>
);
......
import { Box, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodInput } from 'types/api/contract';
import type { ContractAbiItemInput } from '../types';
import { getFieldLabel } from './utils';
interface Props {
data: SmartContractMethodInput;
data: ContractAbiItemInput;
isOptional?: boolean;
level: number;
}
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { SmartContractWriteMethod } from 'types/api/contract';
import type { ContractAbiItem } from '../types';
import TestApp from 'playwright/TestApp';
import ContractMethodForm from './ContractMethodForm';
const onSubmit = () => Promise.resolve({ hash: '0x0000' as `0x${ string }` });
const resultComponent = () => null;
const onSubmit = () => Promise.resolve({ source: 'wallet_client' as const, result: { hash: '0x0000' as `0x${ string }` } });
const data: SmartContractWriteMethod = {
const data: ContractAbiItem = {
inputs: [
// TUPLE
{
......@@ -53,6 +52,13 @@ const data: SmartContractWriteMethod = {
type: 'tuple[][]',
},
// TOP LEVEL NESTED ARRAY
{
internalType: 'int256[2][][3]',
name: 'ParentArray',
type: 'int256[2][][3]',
},
// LITERALS
{ internalType: 'bytes32', name: 'fulfillerConduitKey', type: 'bytes32' },
{ internalType: 'address', name: 'recipient', type: 'address' },
......@@ -95,10 +101,9 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<ContractMethodForm<SmartContractWriteMethod>
<ContractMethodForm
data={ data }
onSubmit={ onSubmit }
resultComponent={ resultComponent }
methodType="write"
/>
</TestApp>,
......@@ -125,9 +130,13 @@ test('base view +@mobile +@dark-mode', async({ mount }) => {
await component.getByText('struct FulfillmentComponent[][]').click();
await component.getByRole('button', { name: 'add' }).nth(1).click();
await component.getByText('#1 FulfillmentComponent[]').click();
await component.getByText('#1.1 FulfillmentComponent').click();
await component.getByLabel('#1 FulfillmentComponent[] (tuple[])').getByText('#1 FulfillmentComponent (tuple)').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
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 React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm, FormProvider } from 'react-hook-form';
import type { AbiFunction } from 'viem';
import type { ContractMethodCallResult } from '../types';
import type { ResultComponentProps } from './types';
import type { SmartContractMethod, SmartContractMethodInput } from 'types/api/contract';
import type { FormSubmitHandler, FormSubmitResult, MethodCallStrategy, MethodType, ContractAbiItem } from '../types';
import config from 'configs/app';
import * as mixpanel from 'lib/mixpanel/index';
import ContractMethodFieldAccordion from './ContractMethodFieldAccordion';
import ContractMethodFieldInput from './ContractMethodFieldInput';
import ContractMethodFieldInputArray from './ContractMethodFieldInputArray';
import ContractMethodFieldInputTuple from './ContractMethodFieldInputTuple';
import ContractMethodFormOutputs from './ContractMethodFormOutputs';
import { ARRAY_REGEXP, transformFormDataToMethodArgs } from './utils';
import ContractMethodOutputs from './ContractMethodOutputs';
import ContractMethodResult from './ContractMethodResult';
import { getFieldLabel, matchArray, transformFormDataToMethodArgs } from './utils';
import type { ContractMethodFormFields } from './utils';
interface Props<T extends SmartContractMethod> {
data: T;
onSubmit: (data: T, args: Array<unknown>) => Promise<ContractMethodCallResult<T>>;
resultComponent: (props: ResultComponentProps<T>) => JSX.Element | null;
methodType: 'read' | 'write';
interface Props {
data: ContractAbiItem;
onSubmit: FormSubmitHandler;
methodType: MethodType;
}
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 [ callStrategy, setCallStrategy ] = React.useState<MethodCallStrategy>();
const callStrategyRef = React.useRef(callStrategy);
const formApi = useForm<ContractMethodFormFields>({
mode: 'all',
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) => {
// 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) :
formData;
const args = transformFormDataToMethodArgs(formattedData);
......@@ -45,12 +53,15 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
setResult(undefined);
setLoading(true);
onSubmit(data, args)
onSubmit(data, args, callStrategyRef.current)
.then((result) => {
setResult(result);
})
.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);
})
.finally(() => {
......@@ -69,9 +80,9 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
result && setResult(undefined);
}, [ result ]);
const inputs: Array<SmartContractMethodInput> = React.useMemo(() => {
const inputs: AbiFunction['inputs'] = React.useMemo(() => {
return [
...('inputs' in data ? data.inputs : []),
...('inputs' in data && data.inputs ? data.inputs : []),
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
name: `Send native ${ config.chain.currency.symbol || 'coin' }`,
type: 'uint256' as const,
......@@ -83,6 +94,28 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
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 (
<Box>
<FormProvider { ...formApi }>
......@@ -93,34 +126,81 @@ const ContractMethodForm = <T extends SmartContractMethod>({ data, onSubmit, res
>
<Flex flexDir="column" rowGap={ 3 } mb={ 6 } _empty={{ display: 'none' }}>
{ inputs.map((input, index) => {
if (input.components && input.type === 'tuple') {
return <ContractMethodFieldInputTuple key={ index } data={ input } basePath={ `${ index }` } level={ 0 } isDisabled={ isLoading }/>;
const props = {
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) {
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>
<Button
isLoading={ isLoading }
loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
>
{ methodType === 'write' ? 'Write' : 'Read' }
</Button>
{ 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
isLoading={ callStrategy === callStrategies.primary && isLoading }
isDisabled={ isLoading || !callStrategies.primary }
onClick={ handleButtonClick }
loadingText={ methodType === 'write' ? 'Write' : 'Read' }
variant="outline"
size="sm"
flexShrink={ 0 }
width="min-content"
px={ 4 }
type="submit"
data-call-strategy={ callStrategies.primary }
>
{ methodType === 'write' ? 'Write' : 'Read' }
</Button>
</Tooltip>
</chakra.form>
</FormProvider>
{ methodType === 'read' && <ContractMethodFormOutputs data={ outputs }/> }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
{ 'outputs' in data && Boolean(data.outputs?.length) && <ContractMethodOutputs data={ outputs }/> }
{ result && <ContractMethodResult abiItem={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box>
);
};
......
import { Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractMethodOutput } from 'types/api/contract';
import type { AbiFunction } from 'viem';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: Array<SmartContractMethodOutput>;
data: AbiFunction['outputs'];
}
const ContractMethodFormOutputs = ({ data }: Props) => {
const ContractMethodOutputs = ({ data }: Props) => {
if (data.length === 0) {
return null;
}
......@@ -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 type { ContractMethodReadResult } from './types';
import type { FormSubmitResultApi } from '../types';
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 onSettle = () => Promise.resolve();
test.use({ viewport: { width: 500, height: 500 } });
test('default error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('default error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
error: 'I am an error',
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('error with code', async({ mount }) => {
const result: ContractMethodReadResult = {
test('error with code', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
message: 'I am an error',
code: -32017,
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('raw error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('raw error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
raw: '49276d20616c7761797320726576657274696e67207769746820616e206572726f72',
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex error', async({ mount }) => {
const result: ContractMethodReadResult = {
test('complex error', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: true,
result: {
method_call: 'SomeCustomError(address addr, uint256 balance)',
......@@ -74,34 +61,26 @@ test('complex error', async({ mount }) => {
],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('success', async({ mount }) => {
const result: ContractMethodReadResult = {
test('success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: false,
result: {
names: [ 'address' ],
output: [ { type: 'address', value: '0x0000000000000000000000000000000000000000' } ],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
test('complex success', async({ mount }) => {
const result: ContractMethodReadResult = {
test('complex success', async({ render }) => {
const result: FormSubmitResultApi['result'] = {
is_error: false,
result: {
names: [
......@@ -122,11 +101,7 @@ test('complex success', async({ mount }) => {
],
},
};
const component = await mount(
<TestApp>
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
</TestApp>,
);
const component = await render(<ContractMethodResultApi item={ item } onSettle={ onSettle } result={ result }/>);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractAbiItem, FormSubmitResultApi } from '../types';
import hexToUtf8 from 'lib/hexToUtf8';
import ContractMethodResultApiError from './ContractMethodResultApiError';
import ContractMethodResultApiItem from './ContractMethodResultApiItem';
interface Props {
item: ContractAbiItem;
result: FormSubmitResultApi['result'];
onSettle: () => void;
}
const ContractMethodResultApi = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <ContractMethodResultApiError>{ result.statusText }</ContractMethodResultApiError>;
}
if (result instanceof Error) {
return <ContractMethodResultApiError>{ result.message }</ContractMethodResultApiError>;
}
if (result.is_error) {
if ('error' in result.result) {
return <ContractMethodResultApiError>{ result.result.error }</ContractMethodResultApiError>;
}
if ('message' in result.result) {
return <ContractMethodResultApiError>[{ result.result.code }] { result.result.message }</ContractMethodResultApiError>;
}
if ('raw' in result.result) {
return <ContractMethodResultApiError>{ `Revert reason: ${ hexToUtf8(result.result.raw) }` }</ContractMethodResultApiError>;
}
if ('method_id' in result.result) {
return <ContractMethodResultApiError>{ JSON.stringify(result.result, undefined, 2) }</ContractMethodResultApiError>;
}
return <ContractMethodResultApiError>Something went wrong.</ContractMethodResultApiError>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
<p>
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map((output, index) => <ContractMethodResultApiItem key={ index } output={ output } name={ result.result.names[index] }/>) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractMethodResultApi);
import { Alert } from '@chakra-ui/react';
import React from 'react';
interface Props {
children: React.ReactNode;
}
const ContractMethodResultApiError = ({ children }: Props) => {
return (
<Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm" wordBreak="break-word" whiteSpace="pre-wrap">
{ children }
</Alert>
);
};
export default React.memo(ContractMethodResultApiError);
import { chakra } from '@chakra-ui/react';
import React from 'react';
import type { SmartContractQueryMethodSuccess } from 'types/api/contract';
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
interface Props {
output: SmartContractQueryMethodSuccess['result']['output'][0];
name: SmartContractQueryMethodSuccess['result']['names'][0];
}
const ContractMethodResultApiItem = ({ output, name }: Props) => {
if (Array.isArray(name)) {
const [ structName, argNames ] = name;
const argTypes = output.type.match(TUPLE_TYPE_REGEX)?.[1].split(',');
return (
<>
<p>
<chakra.span fontWeight={ 500 }> { structName }</chakra.span>
<span> ({ output.type }) :</span>
</p>
{ argNames.map((argName, argIndex) => {
return (
<p key={ argName }>
<chakra.span fontWeight={ 500 }> { argName }</chakra.span>
<span>{ argTypes?.[argIndex] ? ` (${ argTypes[argIndex] })` : '' } : { String(output.value[argIndex]) }</span>
</p>
);
}) }
</>
);
}
return (
<p>
<span> </span>
{ name && <chakra.span fontWeight={ 500 }>{ name } </chakra.span> }
<span>({ output.type }) : { String(output.value) }</span>
</p>
);
};
export default React.memo(ContractMethodResultApiItem);
import { test, expect } from '@playwright/experimental-ct-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 = {
txInfo: {
status: 'pending' as const,
error: null,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('success', async({ mount }) => {
test('success', async({ render }) => {
const props = {
txInfo: {
status: 'success' as const,
error: null,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('error +@mobile', async({ mount }) => {
test('error +@mobile', async({ render }) => {
const props = {
txInfo: {
status: 'error' as const,
......@@ -55,39 +45,29 @@ test('error +@mobile', async({ mount }) => {
// 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 ]',
} as Error,
},
} as PropsDumb['txInfo'],
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29' as `0x${ string }`,
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
await expect(component).toHaveScreenshot();
});
test('error in result', async({ mount }) => {
test('error in result', async({ render }) => {
const props = {
txInfo: {
status: 'idle' as const,
error: null,
},
} as unknown as PropsDumb['txInfo'],
result: {
message: 'wallet is not connected',
} as Error,
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
const component = await render(<ContractMethodResultWalletClientDumb { ...props }/>);
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 type { UseWaitForTransactionReceiptReturnType } from 'wagmi';
import { useWaitForTransactionReceipt } from 'wagmi';
import type { ContractMethodWriteResult } from './types';
import type { FormSubmitResultWalletClient } from '../types';
import { route } from 'nextjs-routes';
import LinkInternal from 'ui/shared/LinkInternal';
interface Props {
result: ContractMethodWriteResult;
result: FormSubmitResultWalletClient['result'];
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;
React.useEffect(() => {
......@@ -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 type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import type { MatchInt } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argType: string;
argTypeMatchInt: MatchInt | null;
}
......
import React from 'react';
import { getAddress, isAddress, isHex } from 'viem';
import type { SmartContractMethodArgType } from 'types/api/contract';
import type { MatchInt } from './useArgTypeMatchInt';
import type { MatchInt } from './utils';
import { BYTES_REGEXP } from './utils';
interface Params {
argType: SmartContractMethodArgType;
argType: string;
argTypeMatchInt: MatchInt | null;
isOptional: boolean;
}
......
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>;
......@@ -10,6 +10,62 @@ export const BYTES_REGEXP = /^bytes(\d+)?$/i;
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) => {
const maxUnsigned = BigInt(2 ** power);
const max = isUnsigned ? maxUnsigned - BigInt(1) : maxUnsigned / BigInt(2) - BigInt(1);
......@@ -41,7 +97,7 @@ function filterOurEmptyItems(array: Array<unknown>): Array<unknown> {
.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>';
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 React from 'react';
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract';
import useApiFetch from 'lib/api/useApiFetch';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import useAccount from 'lib/web3/useAccount';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useWatchAccount from './useWatchAccount';
interface Props {
isLoading?: boolean;
}
const ContractRead = ({ isLoading }: Props) => {
const apiFetch = useApiFetch();
const account = useWatchAccount();
const { address } = useAccount();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
......@@ -37,55 +30,13 @@ const ContractRead = ({ isLoading }: Props) => {
pathParams: { hash: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
from: account?.address,
from: address,
},
queryOptions: {
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) {
return <DataFetchAlert/>;
}
......@@ -101,9 +52,9 @@ const ContractRead = ({ isLoading }: Props) => {
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
{ account && <ContractConnectWallet/> }
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ 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 React from 'react';
import { useAccount, useWalletClient, useSwitchChain } from 'wagmi';
import type { SmartContractWriteMethod } from 'types/api/contract';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractAbi from './ABI/ContractAbi';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractWriteResult from './ContractWriteResult';
import ContractMethodForm from './methodForm/ContractMethodForm';
import useContractAbi from './useContractAbi';
import { getNativeCoinValue, prepareAbi } from './utils';
interface Props {
isLoading?: boolean;
}
const ContractWrite = ({ isLoading }: Props) => {
const { data: walletClient } = useWalletClient();
const { isConnected, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
const router = useRouter();
const tab = getQueryParamString(router.query.tab);
......@@ -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) {
return <DataFetchAlert/>;
}
......@@ -118,9 +50,9 @@ const ContractWrite = ({ isLoading }: Props) => {
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/>
{ config.features.blockchainInteraction.isEnabled && <ContractConnectWallet/> }
{ 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