Merge branch 'tom2drum/issue-1446' into tom2drum/issue-1448
@@ -40,7 +40,9 @@ jobs:
|
||||
const tag = process.env.TAG;
|
||||
const REGEXP = /^v[0-9]+.[0-9]+.[0-9]+-[a-z]+((\.|-)\d+)?$/i;
|
||||
const match = tag.match(REGEXP);
|
||||
return match && !match[1] ? 'true' : 'false';
|
||||
const isInitial = match && !match[1] ? true : false;
|
||||
core.info('is_initial flag value: ', isInitial);
|
||||
return isInitial;
|
||||
|
||||
label_issues:
|
||||
name: Add pre-release label to issues
|
||||
|
||||
@@ -17,6 +17,7 @@ export { default as sentry } from './sentry';
|
||||
export { default as sol2uml } from './sol2uml';
|
||||
export { default as stats } from './stats';
|
||||
export { default as suave } from './suave';
|
||||
export { default as txInterpretation } from './txInterpretation';
|
||||
export { default as web3Wallet } from './web3Wallet';
|
||||
export { default as verifiedTokens } from './verifiedTokens';
|
||||
export { default as zkEvmRollup } from './zkEvmRollup';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Feature } from './types';
|
||||
import type { Provider } from 'types/client/txInterpretation';
|
||||
import { PROVIDERS } from 'types/client/txInterpretation';
|
||||
|
||||
import { getEnvValue } from '../utils';
|
||||
|
||||
const title = 'Transaction interpretation';
|
||||
|
||||
const provider: Provider = (() => {
|
||||
const value = getEnvValue('NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER');
|
||||
|
||||
if (value && PROVIDERS.includes(value as Provider)) {
|
||||
return value as Provider;
|
||||
}
|
||||
|
||||
return 'none';
|
||||
})();
|
||||
|
||||
const config: Feature<{ provider: Provider }> = (() => {
|
||||
if (provider !== 'none') {
|
||||
return Object.freeze({
|
||||
title,
|
||||
provider,
|
||||
isEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
title,
|
||||
isEnabled: false,
|
||||
});
|
||||
})();
|
||||
|
||||
export default config;
|
||||
@@ -42,6 +42,7 @@ NEXT_PUBLIC_STATS_API_HOST=https://stats-eth-main.k8s.blockscout.com
|
||||
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
|
||||
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
|
||||
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
|
||||
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
|
||||
|
||||
#meta
|
||||
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { MarketplaceAppOverview } from '../../../types/client/marketplace';
|
||||
import { NAVIGATION_LINK_IDS } from '../../../types/client/navigation-items';
|
||||
import type { NavItemExternal, NavigationLinkId } from '../../../types/client/navigation-items';
|
||||
import type { BridgedTokenChain, TokenBridge } from '../../../types/client/token';
|
||||
import { PROVIDERS as TX_INTERPRETATION_PROVIDERS } from '../../../types/client/txInterpretation';
|
||||
import type { WalletType } from '../../../types/client/wallets';
|
||||
import { SUPPORTED_WALLETS } from '../../../types/client/wallets';
|
||||
import type { CustomLink, CustomLinksGroup } from '../../../types/footerLinks';
|
||||
@@ -438,6 +439,7 @@ const schema = yup
|
||||
return isNoneSchema.isValidSync(data) || isArrayOfWalletsSchema.isValidSync(data);
|
||||
}),
|
||||
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: yup.boolean(),
|
||||
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: yup.string().oneOf(TX_INTERPRETATION_PROVIDERS),
|
||||
NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string<AdTextProviders>().oneOf(SUPPORTED_AD_TEXT_PROVIDERS),
|
||||
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(),
|
||||
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
|
||||
|
||||
@@ -44,6 +44,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will
|
||||
- [Solidity to UML diagrams](ENVS.md#solidity-to-uml-diagrams)
|
||||
- [Blockchain statistics](ENVS.md#blockchain-statistics)
|
||||
- [Web3 wallet integration](ENVS.md#web3-wallet-integration-add-token-or-network-to-the-wallet) (add token or network to the wallet)
|
||||
- [Transaction interpretation](ENVS.md#transaction-interpretation)
|
||||
- [Verified tokens info](ENVS.md#verified-tokens-info)
|
||||
- [Bridged tokens](ENVS.md#bridged-tokens)
|
||||
- [Safe{Core} address tags](ENVS.md#safecore-address-tags)
|
||||
@@ -473,6 +474,14 @@ This feature is **enabled by default** with the `['metamask']` value. To switch
|
||||
|
||||
|
||||
|
||||
### Transaction interpretation
|
||||
|
||||
| Variable | Type| Description | Compulsoriness | Default value | Example value |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER | `blockscout` \| `none` | Transaction interpretation provider that displays human readable transaction description | - | `none` | `blockscout` |
|
||||
|
||||
|
||||
|
||||
### Verified tokens info
|
||||
|
||||
| Variable | Type| Description | Compulsoriness | Default value | Example value |
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M10.833 7.5H17.5L9.167 20v-7.5H3.333l7.5-12.5v7.5ZM9.167 9.167v-3.15l-2.89 4.816h4.556v3.662l3.553-5.328h-5.22Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -1,4 +1,5 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { TextEncoder, TextDecoder } from 'util';
|
||||
|
||||
import fetchMock from 'jest-fetch-mock';
|
||||
|
||||
@@ -6,6 +7,8 @@ fetchMock.enableMocks();
|
||||
|
||||
const envs = dotenv.config({ path: './configs/envs/.env.jest' });
|
||||
|
||||
Object.assign(global, { TextDecoder, TextEncoder });
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
|
||||
@@ -65,6 +65,7 @@ import type {
|
||||
TransactionsResponseWatchlist,
|
||||
TransactionsSorting,
|
||||
} from 'types/api/transaction';
|
||||
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
|
||||
import type { TTxsFilters } from 'types/api/txsFilters';
|
||||
import type { TxStateChanges } from 'types/api/txStateChanges';
|
||||
import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
|
||||
@@ -246,6 +247,10 @@ export const RESOURCES = {
|
||||
pathParams: [ 'hash' as const ],
|
||||
filterFields: [],
|
||||
},
|
||||
tx_interpretation: {
|
||||
path: '/api/v2/transactions/:hash/summary',
|
||||
pathParams: [ 'hash' as const ],
|
||||
},
|
||||
withdrawals: {
|
||||
path: '/api/v2/withdrawals',
|
||||
filterFields: [],
|
||||
@@ -651,6 +656,7 @@ Q extends 'tx_logs' ? LogsResponseTx :
|
||||
Q extends 'tx_token_transfers' ? TokenTransferResponse :
|
||||
Q extends 'tx_raw_trace' ? RawTracesResponse :
|
||||
Q extends 'tx_state_changes' ? TxStateChanges :
|
||||
Q extends 'tx_interpretation' ? TxInterpretationResponse :
|
||||
Q extends 'addresses' ? AddressesResponse :
|
||||
Q extends 'address' ? Address :
|
||||
Q extends 'address_counters' ? AddressCounters :
|
||||
|
||||
@@ -15,22 +15,22 @@ export default function useContractTabs(data: Address | undefined) {
|
||||
// { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
|
||||
// undefined,
|
||||
data?.has_methods_read ?
|
||||
{ id: 'read_contract', title: 'Read contract', component: <ContractRead addressHash={ data?.hash }/> } :
|
||||
{ id: 'read_contract', title: 'Read contract', component: <ContractRead/> } :
|
||||
undefined,
|
||||
data?.has_methods_read_proxy ?
|
||||
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead addressHash={ data?.hash } isProxy/> } :
|
||||
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead/> } :
|
||||
undefined,
|
||||
data?.has_custom_methods_read ?
|
||||
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead addressHash={ data?.hash } isCustomAbi/> } :
|
||||
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead/> } :
|
||||
undefined,
|
||||
data?.has_methods_write ?
|
||||
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite addressHash={ data?.hash }/> } :
|
||||
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite/> } :
|
||||
undefined,
|
||||
data?.has_methods_write_proxy ?
|
||||
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite addressHash={ data?.hash } isProxy/> } :
|
||||
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite/> } :
|
||||
undefined,
|
||||
data?.has_custom_methods_write ?
|
||||
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite addressHash={ data?.hash } isCustomAbi/> } :
|
||||
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite/> } :
|
||||
undefined,
|
||||
].filter(Boolean);
|
||||
}, [ data ]);
|
||||
|
||||
@@ -170,7 +170,6 @@ export default function useNavItems(): ReturnType {
|
||||
{
|
||||
text: 'Verify contract',
|
||||
nextRoute: { pathname: '/contract-verification' as const },
|
||||
icon: 'verify-contract',
|
||||
isActive: pathname.startsWith('/contract-verification'),
|
||||
},
|
||||
...config.UI.sidebar.otherLinks,
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum EventTypes {
|
||||
CONTRACT_VERIFICATION = 'Contract verification',
|
||||
QR_CODE = 'QR code',
|
||||
PAGE_WIDGET = 'Page widget',
|
||||
TX_INTERPRETATION_INTERACTION = 'Transaction interpratetion interaction'
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
@@ -78,5 +79,8 @@ Type extends EventTypes.QR_CODE ? {
|
||||
Type extends EventTypes.PAGE_WIDGET ? {
|
||||
'Type': 'Tokens dropdown' | 'Tokens show all (icon)' | 'Add to watchlist' | 'Address actions (more button)';
|
||||
} :
|
||||
Type extends EventTypes.TX_INTERPRETATION_INTERACTION ? {
|
||||
'Type': 'Address click' | 'Token click';
|
||||
} :
|
||||
undefined;
|
||||
/* eslint-enable @typescript-eslint/indent */
|
||||
|
||||
@@ -56,6 +56,7 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
|
||||
'Script error.',
|
||||
|
||||
// Relay and WalletConnect errors
|
||||
'The quota has been exceeded',
|
||||
'Attempt to connect to relay via',
|
||||
'WebSocket connection failed for URL: wss://relay.walletconnect.com',
|
||||
],
|
||||
@@ -67,9 +68,11 @@ export const config: Sentry.BrowserOptions | undefined = (() => {
|
||||
// Woopra flakiness
|
||||
/eatdifferent\.com\.woopra-ns\.com/i,
|
||||
/static\.woopra\.com\/js\/woopra\.js/i,
|
||||
// Chrome extensions
|
||||
// Chrome and other extensions
|
||||
/extensions\//i,
|
||||
/^chrome:\/\//i,
|
||||
/^chrome-extension:\/\//i,
|
||||
/^moz-extension:\/\//i,
|
||||
// Other plugins
|
||||
/127\.0\.0\.1:4001\/isrunning/i, // Cacaoweb
|
||||
/webappstoolbarba\.texthelp\.com\//i,
|
||||
|
||||
@@ -9,12 +9,12 @@ export const read: Array<SmartContractReadMethod> = [
|
||||
{
|
||||
constant: true,
|
||||
inputs: [
|
||||
{ internalType: 'address', name: '', type: 'address' },
|
||||
{ internalType: 'address', name: 'wallet', type: 'address' },
|
||||
],
|
||||
method_id: '70a08231',
|
||||
name: 'FLASHLOAN_PREMIUM_TOTAL',
|
||||
outputs: [
|
||||
{ internalType: 'uint256', name: '', type: 'uint256' },
|
||||
{ internalType: 'uint256', name: 'amount', type: 'uint256' },
|
||||
],
|
||||
payable: false,
|
||||
stateMutability: 'view',
|
||||
@@ -97,7 +97,7 @@ export const read: Array<SmartContractReadMethod> = [
|
||||
export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
|
||||
is_error: false,
|
||||
result: {
|
||||
names: [ 'uint256' ],
|
||||
names: [ 'amount' ],
|
||||
output: [
|
||||
{ type: 'uint256', value: '42' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
|
||||
|
||||
export const txInterpretation: TxInterpretationResponse = {
|
||||
data: {
|
||||
summaries: [ {
|
||||
summary_template: `{action_type} {amount} {token} to {to_address} on {timestamp}`,
|
||||
summary_template_variables: {
|
||||
action_type: { type: 'string', value: 'Transfer' },
|
||||
amount: { type: 'currency', value: '100' },
|
||||
token: {
|
||||
type: 'token',
|
||||
value: {
|
||||
name: 'Duck',
|
||||
type: 'ERC-20',
|
||||
symbol: 'DUCK',
|
||||
address: '0x486a3c5f34cDc4EF133f248f1C81168D78da52e8',
|
||||
holders: '1152',
|
||||
decimals: '18',
|
||||
icon_url: null,
|
||||
total_supply: '210000000000000000000000000',
|
||||
exchange_rate: null,
|
||||
circulating_market_cap: null,
|
||||
},
|
||||
},
|
||||
to_address: {
|
||||
type: 'address',
|
||||
value: {
|
||||
hash: '0x48c04ed5691981C42154C6167398f95e8f38a7fF',
|
||||
implementation_name: null,
|
||||
is_contract: false,
|
||||
is_verified: false,
|
||||
name: null,
|
||||
private_tags: [],
|
||||
public_tags: [],
|
||||
watchlist_names: [],
|
||||
},
|
||||
},
|
||||
timestamp: {
|
||||
type: 'timestamp',
|
||||
value: '1687005431',
|
||||
},
|
||||
},
|
||||
} ],
|
||||
},
|
||||
};
|
||||
@@ -9,11 +9,11 @@ export function ad(): CspDev.DirectiveDescriptor {
|
||||
'connect-src': [
|
||||
'coinzilla.com',
|
||||
'*.coinzilla.com',
|
||||
'request-global.czilladx.com',
|
||||
'https://request-global.czilladx.com',
|
||||
'*.slise.xyz',
|
||||
],
|
||||
'frame-src': [
|
||||
'request-global.czilladx.com',
|
||||
'https://request-global.czilladx.com',
|
||||
],
|
||||
'script-src': [
|
||||
'coinzillatag.com',
|
||||
@@ -27,7 +27,7 @@ export function ad(): CspDev.DirectiveDescriptor {
|
||||
'cdn.coinzilla.io',
|
||||
],
|
||||
'font-src': [
|
||||
'request-global.czilladx.com',
|
||||
'https://request-global.czilladx.com',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { KEY_WORDS } from '../utils';
|
||||
const MAIN_DOMAINS = [
|
||||
`*.${ config.app.host }`,
|
||||
config.app.host,
|
||||
getFeaturePayload(config.features.sol2uml)?.api.endpoint,
|
||||
].filter(Boolean);
|
||||
|
||||
const getCspReportUrl = () => {
|
||||
@@ -113,6 +112,7 @@ export function app(): CspDev.DirectiveDescriptor {
|
||||
|
||||
'font-src': [
|
||||
KEY_WORDS.DATA,
|
||||
...MAIN_DOMAINS,
|
||||
],
|
||||
|
||||
'object-src': [
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"react-identicons": "^1.2.5",
|
||||
"react-intersection-observer": "^9.5.2",
|
||||
"react-jazzicon": "^1.0.4",
|
||||
"react-number-format": "^5.3.1",
|
||||
"react-scroll": "^1.8.7",
|
||||
"swagger-ui-react": "^5.9.0",
|
||||
"use-font-face-observer": "^1.2.1",
|
||||
|
||||
@@ -29,6 +29,9 @@ export const featureEnvs = {
|
||||
value: '[{"type":"omni","title":"OmniBridge","short_title":"OMNI"},{"type":"amb","title":"Arbitrary Message Bridge","short_title":"AMB"}]',
|
||||
},
|
||||
],
|
||||
txInterpretation: [
|
||||
{ name: 'NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER', value: 'blockscout' },
|
||||
],
|
||||
zkRollup: [
|
||||
{ name: 'NEXT_PUBLIC_IS_ZKEVM_L2_NETWORK', value: 'true' },
|
||||
{ name: 'NEXT_PUBLIC_L1_BASE_URL', value: 'https://localhost:3101' },
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
| "graphQL"
|
||||
| "info"
|
||||
| "key"
|
||||
| "lightning"
|
||||
| "link"
|
||||
| "lock"
|
||||
| "minus"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
|
||||
|
||||
import { TOKEN_INFO_ERC_20 } from './token';
|
||||
|
||||
export const TX_INTERPRETATION: TxInterpretationResponse = {
|
||||
data: {
|
||||
summaries: [
|
||||
{
|
||||
summary_template: '{action_type} {source_amount} Ether into {destination_amount} {destination_token}',
|
||||
summary_template_variables: {
|
||||
action_type: { type: 'string', value: 'Wrap' },
|
||||
source_amount: { type: 'currency', value: '0.7' },
|
||||
destination_amount: { type: 'currency', value: '0.7' },
|
||||
destination_token: {
|
||||
type: 'token',
|
||||
value: TOKEN_INFO_ERC_20,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
summary_template: '{action_type} {source_amount} Ether into {destination_amount} {destination_token}',
|
||||
summary_template_variables: {
|
||||
action_type: { type: 'string', value: 'Wrap' },
|
||||
source_amount: { type: 'currency', value: '0.7' },
|
||||
destination_amount: { type: 'currency', value: '0.7' },
|
||||
destination_token: {
|
||||
type: 'token',
|
||||
value: TOKEN_INFO_ERC_20,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -13,7 +13,7 @@ function getBg(props: StyleFunctionProps) {
|
||||
const { theme, colorScheme: c } = props;
|
||||
const darkBg = transparentize(`${ c }.200`, 0.16)(theme);
|
||||
return {
|
||||
light: `colors.${ c }.100`,
|
||||
light: `colors.${ c }.${ c === 'red' ? '50' : '100' }`,
|
||||
dark: darkBg,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Abi } from 'abitype';
|
||||
import type { Abi, AbiType } from 'abitype';
|
||||
|
||||
export type SmartContractMethodArgType = 'address' | 'uint256' | 'bool' | 'string' | 'bytes' | 'bytes32' | 'bytes32[]';
|
||||
export type SmartContractMethodArgType = AbiType;
|
||||
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
|
||||
|
||||
export interface SmartContract {
|
||||
@@ -88,6 +88,8 @@ export interface SmartContractMethodInput {
|
||||
internalType?: SmartContractMethodArgType;
|
||||
name: string;
|
||||
type: SmartContractMethodArgType;
|
||||
components?: Array<SmartContractMethodInput>;
|
||||
fieldType?: 'native_coin';
|
||||
}
|
||||
|
||||
export interface SmartContractMethodOutput extends SmartContractMethodInput {
|
||||
@@ -97,10 +99,10 @@ export interface SmartContractMethodOutput extends SmartContractMethodInput {
|
||||
export interface SmartContractQueryMethodReadSuccess {
|
||||
is_error: false;
|
||||
result: {
|
||||
names: Array<string>;
|
||||
names: Array<string | [ string, Array<string> ]>;
|
||||
output: Array<{
|
||||
type: string;
|
||||
value: string;
|
||||
value: string | Array<unknown>;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ export type HomeStats = {
|
||||
}
|
||||
|
||||
export type GasPrices = {
|
||||
average: number;
|
||||
fast: number;
|
||||
slow: number;
|
||||
average: number | null;
|
||||
fast: number | null;
|
||||
slow: number | null;
|
||||
}
|
||||
|
||||
export type Counters = {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AddressParam } from 'types/api/addressParams';
|
||||
import type { TokenInfo } from 'types/api/token';
|
||||
|
||||
export interface TxInterpretationResponse {
|
||||
data: {
|
||||
summaries: Array<TxInterpretationSummary>;
|
||||
};
|
||||
}
|
||||
|
||||
export type TxInterpretationSummary = {
|
||||
summary_template: string;
|
||||
summary_template_variables: Record<string, TxInterpretationVariable>;
|
||||
}
|
||||
|
||||
export type TxInterpretationVariable =
|
||||
TxInterpretationVariableString |
|
||||
TxInterpretationVariableCurrency |
|
||||
TxInterpretationVariableTimestamp |
|
||||
TxInterpretationVariableToken |
|
||||
TxInterpretationVariableAddress;
|
||||
|
||||
export type TxInterpretationVariableType = 'string' | 'currency' | 'timestamp' | 'token' | 'address';
|
||||
|
||||
export type TxInterpretationVariableString = {
|
||||
type: 'string';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type TxInterpretationVariableCurrency = {
|
||||
type: 'currency';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type TxInterpretationVariableTimestamp = {
|
||||
type: 'timestamp';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type TxInterpretationVariableToken = {
|
||||
type: 'token';
|
||||
value: TokenInfo;
|
||||
}
|
||||
|
||||
export type TxInterpretationVariableAddress = {
|
||||
type: 'address';
|
||||
value: AddressParam;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ArrayElement } from 'types/utils';
|
||||
|
||||
export const PROVIDERS = [
|
||||
'blockscout',
|
||||
'none',
|
||||
] as const;
|
||||
|
||||
export type Provider = ArrayElement<typeof PROVIDERS>;
|
||||
@@ -84,6 +84,10 @@ const SolidityscanReport = ({ className, hash }: Props) => {
|
||||
const yetAnotherGrayColor = useColorModeValue('gray.400', 'gray.500');
|
||||
const popoverBgColor = useColorModeValue('white', 'gray.900');
|
||||
|
||||
const greatScoreColor = useColorModeValue('green.600', 'green.400');
|
||||
const averageScoreColor = useColorModeValue('purple.600', 'purple.400');
|
||||
const lowScoreColor = useColorModeValue('red.600', 'red.400');
|
||||
|
||||
if (isError || !score) {
|
||||
return null;
|
||||
}
|
||||
@@ -91,13 +95,13 @@ const SolidityscanReport = ({ className, hash }: Props) => {
|
||||
let scoreColor;
|
||||
let scoreLevel;
|
||||
if (score >= 80) {
|
||||
scoreColor = 'green.600';
|
||||
scoreColor = greatScoreColor;
|
||||
scoreLevel = 'GREAT';
|
||||
} else if (score >= 30) {
|
||||
scoreColor = 'orange.600';
|
||||
scoreColor = averageScoreColor;
|
||||
scoreLevel = 'AVERAGE';
|
||||
} else {
|
||||
scoreColor = 'red.600';
|
||||
scoreColor = lowScoreColor;
|
||||
scoreLevel = 'LOW';
|
||||
}
|
||||
|
||||
@@ -112,7 +116,6 @@ const SolidityscanReport = ({ className, hash }: Props) => {
|
||||
<Button
|
||||
className={ className }
|
||||
color={ scoreColor }
|
||||
borderColor={ scoreColor }
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -1,16 +1,17 @@
|
||||
import { Box, Button, chakra, Flex, Text } from '@chakra-ui/react';
|
||||
import _fromPairs from 'lodash/fromPairs';
|
||||
import { Box, Button, chakra, Flex } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, FormProvider } from 'react-hook-form';
|
||||
|
||||
import type { MethodFormFields, ContractMethodCallResult } from './types';
|
||||
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract';
|
||||
|
||||
import config from 'configs/app';
|
||||
import * as mixpanel from 'lib/mixpanel/index';
|
||||
import IconSvg from 'ui/shared/IconSvg';
|
||||
|
||||
import ContractMethodField from './ContractMethodField';
|
||||
import ContractMethodCallableRow from './ContractMethodCallableRow';
|
||||
import { formatFieldValues, transformFieldsToArgs } from './utils';
|
||||
|
||||
interface ResultComponentProps<T extends SmartContractMethod> {
|
||||
item: T;
|
||||
@@ -25,42 +26,9 @@ interface Props<T extends SmartContractMethod> {
|
||||
isWrite?: boolean;
|
||||
}
|
||||
|
||||
const getFieldName = (name: string | undefined, index: number): string => name || String(index);
|
||||
|
||||
const sortFields = (data: Array<SmartContractMethodInput>) => ([ a ]: [string, string], [ b ]: [string, string]): 1 | -1 | 0 => {
|
||||
const fieldNames = data.map(({ name }, index) => getFieldName(name, index));
|
||||
const indexA = fieldNames.indexOf(a);
|
||||
const indexB = fieldNames.indexOf(b);
|
||||
|
||||
if (indexA > indexB) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (indexA < indexB) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const castFieldValue = (data: Array<SmartContractMethodInput>) => ([ key, value ]: [ string, string ], index: number) => {
|
||||
if (data[index].type.includes('[')) {
|
||||
return [ key, parseArrayValue(value) ];
|
||||
}
|
||||
return [ key, value ];
|
||||
};
|
||||
|
||||
const parseArrayValue = (value: string) => {
|
||||
try {
|
||||
const parsedResult = JSON.parse(value);
|
||||
if (Array.isArray(parsedResult)) {
|
||||
return parsedResult;
|
||||
}
|
||||
throw new Error('Not an array');
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
// groupName%groupIndex:inputName%inputIndex
|
||||
const getFormFieldName = (input: { index: number; name: string }, group?: { index: number; name: string }) =>
|
||||
`${ group ? `${ group.name }%${ group.index }:` : '' }${ input.name || 'input' }%${ input.index }`;
|
||||
|
||||
const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, resultComponent: ResultComponent, isWrite }: Props<T>) => {
|
||||
|
||||
@@ -71,15 +39,16 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
|
||||
return [
|
||||
...('inputs' in data ? data.inputs : []),
|
||||
...('stateMutability' in data && data.stateMutability === 'payable' ? [ {
|
||||
name: 'value',
|
||||
name: `Send native ${ config.chain.currency.symbol }`,
|
||||
type: 'uint256' as const,
|
||||
internalType: 'uint256' as const,
|
||||
fieldType: 'native_coin' as const,
|
||||
} ] : []),
|
||||
];
|
||||
}, [ data ]);
|
||||
|
||||
const { control, handleSubmit, setValue, getValues } = useForm<MethodFormFields>({
|
||||
defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])),
|
||||
const formApi = useForm<MethodFormFields>({
|
||||
mode: 'onBlur',
|
||||
});
|
||||
|
||||
const handleTxSettle = React.useCallback(() => {
|
||||
@@ -91,10 +60,8 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
|
||||
}, [ result ]);
|
||||
|
||||
const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
|
||||
const args = Object.entries(formData)
|
||||
.sort(sortFields(inputs))
|
||||
.map(castFieldValue(inputs))
|
||||
.map(([ , value ]) => value);
|
||||
const formattedData = formatFieldValues(formData, inputs);
|
||||
const args = transformFieldsToArgs(formattedData);
|
||||
|
||||
setResult(undefined);
|
||||
setLoading(true);
|
||||
@@ -117,48 +84,99 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit,
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<chakra.form
|
||||
noValidate
|
||||
display="flex"
|
||||
columnGap={ 3 }
|
||||
flexDir={{ base: 'column', lg: 'row' }}
|
||||
rowGap={ 2 }
|
||||
alignItems={{ base: 'flex-start', lg: 'center' }}
|
||||
onSubmit={ handleSubmit(onFormSubmit) }
|
||||
flexWrap="wrap"
|
||||
onChange={ handleFormChange }
|
||||
>
|
||||
{ inputs.map(({ type, name }, index) => {
|
||||
const fieldName = getFieldName(name, index);
|
||||
return (
|
||||
<ContractMethodField
|
||||
key={ fieldName }
|
||||
name={ fieldName }
|
||||
valueType={ type }
|
||||
placeholder={ `${ name }(${ type })` }
|
||||
control={ control }
|
||||
setValue={ setValue }
|
||||
getValues={ getValues }
|
||||
isDisabled={ isLoading }
|
||||
onChange={ handleFormChange }
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
<Button
|
||||
isLoading={ isLoading }
|
||||
loadingText={ isWrite ? 'Write' : 'Query' }
|
||||
variant="outline"
|
||||
size="sm"
|
||||
flexShrink={ 0 }
|
||||
type="submit"
|
||||
<FormProvider { ...formApi }>
|
||||
<chakra.form
|
||||
noValidate
|
||||
onSubmit={ formApi.handleSubmit(onFormSubmit) }
|
||||
onChange={ handleFormChange }
|
||||
>
|
||||
{ isWrite ? 'Write' : 'Query' }
|
||||
</Button>
|
||||
</chakra.form>
|
||||
<Flex
|
||||
flexDir="column"
|
||||
rowGap={ 3 }
|
||||
mb={ 3 }
|
||||
_empty={{ display: 'none' }}
|
||||
>
|
||||
{ inputs.map((input, index) => {
|
||||
const fieldName = getFormFieldName({ name: input.name, index });
|
||||
|
||||
if (input.type === 'tuple' && input.components) {
|
||||
return (
|
||||
<React.Fragment key={ fieldName }>
|
||||
{ index !== 0 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
|
||||
<Box
|
||||
fontWeight={ 500 }
|
||||
lineHeight="20px"
|
||||
py={{ lg: '6px' }}
|
||||
fontSize="sm"
|
||||
wordBreak="break-word"
|
||||
>
|
||||
{ input.name } ({ input.type })
|
||||
</Box>
|
||||
{ input.components.map((component, componentIndex) => {
|
||||
const fieldName = getFormFieldName(
|
||||
{ name: component.name, index: componentIndex },
|
||||
{ name: input.name, index },
|
||||
);
|
||||
|
||||
return (
|
||||
<ContractMethodCallableRow
|
||||
key={ fieldName }
|
||||
fieldName={ fieldName }
|
||||
argName={ component.name }
|
||||
argType={ component.type }
|
||||
isDisabled={ isLoading }
|
||||
onChange={ handleFormChange }
|
||||
isGrouped
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
{ index !== inputs.length - 1 && <><Box h={{ base: 0, lg: 3 }}/><div/></> }
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractMethodCallableRow
|
||||
key={ fieldName }
|
||||
fieldName={ fieldName }
|
||||
fieldType={ input.fieldType }
|
||||
argName={ input.name }
|
||||
argType={ input.type }
|
||||
isDisabled={ isLoading }
|
||||
isOptional={ input.fieldType === 'native_coin' && inputs.length > 1 }
|
||||
onChange={ handleFormChange }
|
||||
/>
|
||||
);
|
||||
}) }
|
||||
</Flex>
|
||||
<Button
|
||||
isLoading={ isLoading }
|
||||
loadingText={ isWrite ? 'Write' : 'Read' }
|
||||
variant="outline"
|
||||
size="sm"
|
||||
flexShrink={ 0 }
|
||||
width="min-content"
|
||||
px={ 4 }
|
||||
type="submit"
|
||||
>
|
||||
{ isWrite ? 'Write' : 'Read' }
|
||||
</Button>
|
||||
</chakra.form>
|
||||
</FormProvider>
|
||||
{ 'outputs' in data && !isWrite && data.outputs.length > 0 && (
|
||||
<Flex mt={ 3 }>
|
||||
<Flex mt={ 3 } fontSize="sm">
|
||||
<IconSvg name="arrows/down-right" boxSize={ 5 } mr={ 1 }/>
|
||||
<Text>{ data.outputs.map(({ type }) => type).join(', ') }</Text>
|
||||
<p>
|
||||
{ data.outputs.map(({ type, name }, index) => {
|
||||
return (
|
||||
<>
|
||||
<chakra.span fontWeight={ 500 }>{ name } </chakra.span>
|
||||
<span>{ name ? `(${ type })` : type }</span>
|
||||
{ index < data.outputs.length - 1 && <span>, </span> }
|
||||
</>
|
||||
);
|
||||
}) }
|
||||
</p>
|
||||
</Flex>
|
||||
) }
|
||||
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import type { MethodFormFields } from './types';
|
||||
import type { SmartContractMethodArgType, SmartContractMethodInput } from 'types/api/contract';
|
||||
|
||||
import ContractMethodField from './ContractMethodField';
|
||||
import ContractMethodFieldArray from './ContractMethodFieldArray';
|
||||
import { ARRAY_REGEXP } from './utils';
|
||||
|
||||
interface Props {
|
||||
fieldName: string;
|
||||
fieldType?: SmartContractMethodInput['fieldType'];
|
||||
argName: string;
|
||||
argType: SmartContractMethodArgType;
|
||||
onChange: () => void;
|
||||
isDisabled: boolean;
|
||||
isGrouped?: boolean;
|
||||
isOptional?: boolean;
|
||||
}
|
||||
|
||||
const ContractMethodCallableRow = ({ argName, fieldName, fieldType, argType, onChange, isDisabled, isGrouped, isOptional }: Props) => {
|
||||
const { control, getValues, setValue } = useFormContext<MethodFormFields>();
|
||||
const arrayTypeMatch = argType.match(ARRAY_REGEXP);
|
||||
const nativeCoinFieldBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
|
||||
|
||||
const content = arrayTypeMatch ? (
|
||||
<ContractMethodFieldArray
|
||||
name={ fieldName }
|
||||
argType={ arrayTypeMatch[1] as SmartContractMethodArgType }
|
||||
size={ Number(arrayTypeMatch[2] || Infinity) }
|
||||
control={ control }
|
||||
setValue={ setValue }
|
||||
getValues={ getValues }
|
||||
isDisabled={ isDisabled }
|
||||
onChange={ onChange }
|
||||
/>
|
||||
) : (
|
||||
<ContractMethodField
|
||||
name={ fieldName }
|
||||
argType={ argType }
|
||||
placeholder={ argType }
|
||||
control={ control }
|
||||
setValue={ setValue }
|
||||
getValues={ getValues }
|
||||
isDisabled={ isDisabled }
|
||||
isOptional={ isOptional }
|
||||
onChange={ onChange }
|
||||
/>
|
||||
);
|
||||
|
||||
const isNativeCoinField = fieldType === 'native_coin';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
flexDir={{ base: 'column', lg: 'row' }}
|
||||
columnGap={ 3 }
|
||||
rowGap={{ base: 2, lg: 0 }}
|
||||
bgColor={ isNativeCoinField ? nativeCoinFieldBgColor : undefined }
|
||||
py={ isNativeCoinField ? 1 : undefined }
|
||||
px={ isNativeCoinField ? '6px' : undefined }
|
||||
mx={ isNativeCoinField ? '-6px' : undefined }
|
||||
borderRadius="base"
|
||||
>
|
||||
<Box
|
||||
position="relative"
|
||||
fontWeight={ 500 }
|
||||
lineHeight="20px"
|
||||
py={{ lg: '6px' }}
|
||||
fontSize="sm"
|
||||
color={ isGrouped ? 'text_secondary' : 'initial' }
|
||||
wordBreak="break-word"
|
||||
w={{ lg: '250px' }}
|
||||
flexShrink={ 0 }
|
||||
>
|
||||
{ argName }{ isOptional ? '' : '*' } ({ argType })
|
||||
</Box>
|
||||
{ content }
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ContractMethodCallableRow);
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import type { Control, ControllerRenderProps, UseFormGetValues, UseFormSetValue } from 'react-hook-form';
|
||||
import type { Control, ControllerRenderProps, FieldError, UseFormGetValues, UseFormSetValue, UseFormStateReturn } from 'react-hook-form';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
import { isAddress, isHex, getAddress } from 'viem';
|
||||
|
||||
import type { MethodFormFields } from './types';
|
||||
import type { SmartContractMethodArgType } from 'types/api/contract';
|
||||
@@ -14,21 +18,25 @@ import type { SmartContractMethodArgType } from 'types/api/contract';
|
||||
import ClearButton from 'ui/shared/ClearButton';
|
||||
|
||||
import ContractMethodFieldZeroes from './ContractMethodFieldZeroes';
|
||||
import { addZeroesAllowed } from './utils';
|
||||
import { INT_REGEXP, BYTES_REGEXP, getIntBoundaries, formatBooleanValue } from './utils';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
index?: number;
|
||||
groupName?: string;
|
||||
placeholder: string;
|
||||
argType: SmartContractMethodArgType;
|
||||
control: Control<MethodFormFields>;
|
||||
setValue: UseFormSetValue<MethodFormFields>;
|
||||
getValues: UseFormGetValues<MethodFormFields>;
|
||||
placeholder: string;
|
||||
name: string;
|
||||
valueType: SmartContractMethodArgType;
|
||||
isDisabled: boolean;
|
||||
isOptional?: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
const ContractMethodField = ({ control, name, valueType, placeholder, setValue, getValues, isDisabled, onChange }: Props) => {
|
||||
const ContractMethodField = ({ control, name, groupName, index, argType, placeholder, setValue, getValues, isDisabled, isOptional, onChange }: Props) => {
|
||||
const ref = React.useRef<HTMLInputElement>(null);
|
||||
const bgColor = useColorModeValue('white', 'black');
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
setValue(name, '');
|
||||
@@ -37,47 +45,147 @@ const ContractMethodField = ({ control, name, valueType, placeholder, setValue,
|
||||
}, [ name, onChange, setValue ]);
|
||||
|
||||
const handleAddZeroesClick = React.useCallback((power: number) => {
|
||||
const value = getValues()[name];
|
||||
const value = groupName && index !== undefined ? getValues()[groupName][index] : getValues()[name];
|
||||
const zeroes = Array(power).fill('0').join('');
|
||||
const newValue = value ? value + zeroes : '1' + zeroes;
|
||||
setValue(name, newValue);
|
||||
onChange();
|
||||
}, [ getValues, name, onChange, setValue ]);
|
||||
}, [ getValues, groupName, index, name, onChange, setValue ]);
|
||||
|
||||
const hasZerosControl = addZeroesAllowed(valueType);
|
||||
const intMatch = React.useMemo(() => {
|
||||
const match = argType.match(INT_REGEXP);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [ , isUnsigned, power = '256' ] = match;
|
||||
const [ min, max ] = getIntBoundaries(Number(power), Boolean(isUnsigned));
|
||||
|
||||
return { isUnsigned, power, min, max };
|
||||
}, [ argType ]);
|
||||
|
||||
const bytesMatch = React.useMemo(() => {
|
||||
return argType.match(BYTES_REGEXP);
|
||||
}, [ argType ]);
|
||||
|
||||
const renderInput = React.useCallback((
|
||||
{ field, formState }: { field: ControllerRenderProps<MethodFormFields>; formState: UseFormStateReturn<MethodFormFields> },
|
||||
) => {
|
||||
const error: FieldError | undefined = index !== undefined && groupName !== undefined ?
|
||||
(formState.errors[groupName] as unknown as Array<FieldError>)?.[index] :
|
||||
formState.errors[name];
|
||||
|
||||
// show control for all inputs which allows to insert 10^18 or greater numbers
|
||||
const hasZerosControl = intMatch && Number(intMatch.power) >= 64;
|
||||
|
||||
const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps<MethodFormFields> }) => {
|
||||
return (
|
||||
<FormControl
|
||||
id={ name }
|
||||
flexBasis={{ base: '100%', lg: 'calc((100% - 24px) / 3 - 65px)' }}
|
||||
w={{ base: '100%', lg: 'auto' }}
|
||||
flexGrow={ 1 }
|
||||
isDisabled={ isDisabled }
|
||||
>
|
||||
<InputGroup size="xs">
|
||||
<Input
|
||||
{ ...field }
|
||||
ref={ ref }
|
||||
placeholder={ placeholder }
|
||||
paddingRight={ hasZerosControl ? '120px' : '40px' }
|
||||
/>
|
||||
<InputRightElement w="auto" right={ 1 }>
|
||||
{ field.value && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
|
||||
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> }
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<Box w="100%">
|
||||
<FormControl
|
||||
id={ name }
|
||||
isDisabled={ isDisabled }
|
||||
>
|
||||
<InputGroup size="xs">
|
||||
<Input
|
||||
{ ...field }
|
||||
{ ...(intMatch ? {
|
||||
as: NumericFormat,
|
||||
thousandSeparator: ' ',
|
||||
decimalScale: 0,
|
||||
allowNegative: !intMatch.isUnsigned,
|
||||
} : {}) }
|
||||
ref={ ref }
|
||||
isInvalid={ Boolean(error) }
|
||||
required={ !isOptional }
|
||||
placeholder={ placeholder }
|
||||
paddingRight={ hasZerosControl ? '120px' : '40px' }
|
||||
autoComplete="off"
|
||||
bgColor={ bgColor }
|
||||
/>
|
||||
<InputRightElement w="auto" right={ 1 }>
|
||||
{ typeof field.value === 'string' && field.value.replace('\n', '') && <ClearButton onClick={ handleClear } isDisabled={ isDisabled }/> }
|
||||
{ hasZerosControl && <ContractMethodFieldZeroes onClick={ handleAddZeroesClick } isDisabled={ isDisabled }/> }
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
{ error && <Box color="error" fontSize="sm" mt={ 1 }>{ error.message }</Box> }
|
||||
</Box>
|
||||
);
|
||||
}, [ name, isDisabled, placeholder, hasZerosControl, handleClear, handleAddZeroesClick ]);
|
||||
}, [ index, groupName, name, intMatch, isDisabled, isOptional, placeholder, bgColor, handleClear, handleAddZeroesClick ]);
|
||||
|
||||
const validate = React.useCallback((_value: string | Array<string> | undefined) => {
|
||||
if (typeof _value === 'object' || !_value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = _value.replace('\n', '');
|
||||
|
||||
if (!value && !isOptional) {
|
||||
return 'Field is required';
|
||||
}
|
||||
|
||||
if (argType === 'address') {
|
||||
if (!isAddress(value)) {
|
||||
return 'Invalid address format';
|
||||
}
|
||||
|
||||
// all lowercase addresses are valid
|
||||
const isInLowerCase = value === value.toLowerCase();
|
||||
if (isInLowerCase) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check if address checksum is valid
|
||||
return getAddress(value) === value ? true : 'Invalid address checksum';
|
||||
}
|
||||
|
||||
if (intMatch) {
|
||||
const formattedValue = Number(value.replace(/\s/g, ''));
|
||||
|
||||
if (Object.is(formattedValue, NaN)) {
|
||||
return 'Invalid integer format';
|
||||
}
|
||||
|
||||
if (formattedValue > intMatch.max || formattedValue < intMatch.min) {
|
||||
const lowerBoundary = intMatch.isUnsigned ? '0' : `-1 * 2 ^ ${ Number(intMatch.power) / 2 }`;
|
||||
const upperBoundary = intMatch.isUnsigned ? `2 ^ ${ intMatch.power } - 1` : `2 ^ ${ Number(intMatch.power) / 2 } - 1`;
|
||||
return `Value should be in range from "${ lowerBoundary }" to "${ upperBoundary }" inclusively`;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (argType === 'bool') {
|
||||
const formattedValue = formatBooleanValue(value);
|
||||
if (formattedValue === undefined) {
|
||||
return 'Invalid boolean format. Allowed values: 0, 1, true, false';
|
||||
}
|
||||
}
|
||||
|
||||
if (bytesMatch) {
|
||||
const [ , length ] = bytesMatch;
|
||||
|
||||
if (!isHex(value)) {
|
||||
return 'Invalid bytes format';
|
||||
}
|
||||
|
||||
if (length) {
|
||||
const valueLengthInBytes = value.replace('0x', '').length / 2;
|
||||
return valueLengthInBytes !== Number(length) ? `Value should be ${ length } bytes in length` : true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [ isOptional, argType, intMatch, bytesMatch ]);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={ name }
|
||||
control={ control }
|
||||
render={ renderInput }
|
||||
rules={{ required: isOptional ? false : 'Field is required', validate }}
|
||||
/>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Flex, IconButton } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import type { Control, UseFormGetValues, UseFormSetValue } from 'react-hook-form';
|
||||
import { useFieldArray } from 'react-hook-form';
|
||||
|
||||
import type { MethodFormFields } from './types';
|
||||
import type { SmartContractMethodArgType } from 'types/api/contract';
|
||||
|
||||
import IconSvg from 'ui/shared/IconSvg';
|
||||
|
||||
import ContractMethodField from './ContractMethodField';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
size: number;
|
||||
argType: SmartContractMethodArgType;
|
||||
control: Control<MethodFormFields>;
|
||||
setValue: UseFormSetValue<MethodFormFields>;
|
||||
getValues: UseFormGetValues<MethodFormFields>;
|
||||
isDisabled: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
const ContractMethodFieldArray = ({ control, name, setValue, getValues, isDisabled, argType, onChange, size }: Props) => {
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: name as never,
|
||||
control,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (fields.length === 0) {
|
||||
if (size === Infinity) {
|
||||
append('');
|
||||
} else {
|
||||
for (let i = 0; i < size - 1; i++) {
|
||||
// a little hack to append multiple empty fields in the array
|
||||
// had to adjust code in ContractMethodField as well
|
||||
append('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}, [ fields.length, append, size ]);
|
||||
|
||||
const handleAddButtonClick = React.useCallback(() => {
|
||||
append('');
|
||||
}, [ append ]);
|
||||
|
||||
const handleRemoveButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const itemIndex = event.currentTarget.getAttribute('data-index');
|
||||
if (itemIndex) {
|
||||
remove(Number(itemIndex));
|
||||
}
|
||||
}, [ remove ]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" rowGap={ 3 } w="100%">
|
||||
{ fields.map((field, index, array) => {
|
||||
return (
|
||||
<Flex key={ field.id } columnGap={ 3 }>
|
||||
<ContractMethodField
|
||||
name={ `${ name }[${ index }]` }
|
||||
groupName={ name }
|
||||
index={ index }
|
||||
argType={ argType }
|
||||
placeholder={ argType }
|
||||
control={ control }
|
||||
setValue={ setValue }
|
||||
getValues={ getValues }
|
||||
isDisabled={ isDisabled }
|
||||
onChange={ onChange }
|
||||
/>
|
||||
{ array.length > 1 && size === Infinity && (
|
||||
<IconButton
|
||||
aria-label="remove"
|
||||
data-index={ index }
|
||||
variant="outline"
|
||||
w="30px"
|
||||
h="30px"
|
||||
flexShrink={ 0 }
|
||||
onClick={ handleRemoveButtonClick }
|
||||
icon={ <IconSvg name="minus" boxSize={ 4 }/> }
|
||||
isDisabled={ isDisabled }
|
||||
/>
|
||||
) }
|
||||
{ index === array.length - 1 && size === Infinity && (
|
||||
<IconButton
|
||||
aria-label="add"
|
||||
data-index={ index }
|
||||
variant="outline"
|
||||
w="30px"
|
||||
h="30px"
|
||||
flexShrink={ 0 }
|
||||
onClick={ handleAddButtonClick }
|
||||
icon={ <IconSvg name="plus" boxSize={ 4 }/> }
|
||||
isDisabled={ isDisabled }
|
||||
/>
|
||||
) }
|
||||
</Flex>
|
||||
);
|
||||
}) }
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ContractMethodFieldArray);
|
||||
@@ -11,9 +11,10 @@ interface Props<T extends SmartContractMethod> {
|
||||
data: Array<T>;
|
||||
addressHash?: string;
|
||||
renderItemContent: (item: T, index: number, id: number) => React.ReactNode;
|
||||
tab: string;
|
||||
}
|
||||
|
||||
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent }: Props<T>) => {
|
||||
const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, addressHash, renderItemContent, tab }: Props<T>) => {
|
||||
const [ expandedSections, setExpandedSections ] = React.useState<Array<number>>(data.length === 1 ? [ 0 ] : []);
|
||||
const [ id, setId ] = React.useState(0);
|
||||
|
||||
@@ -79,6 +80,7 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, address
|
||||
index={ index }
|
||||
addressHash={ addressHash }
|
||||
renderContent={ renderItemContent as (item: SmartContractMethod, index: number, id: number) => React.ReactNode }
|
||||
tab={ tab }
|
||||
/>
|
||||
)) }
|
||||
</Accordion>
|
||||
|
||||
@@ -16,9 +16,10 @@ interface Props<T extends SmartContractMethod> {
|
||||
id: number;
|
||||
addressHash?: string;
|
||||
renderContent: (item: T, index: number, id: number) => React.ReactNode;
|
||||
tab: string;
|
||||
}
|
||||
|
||||
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent }: Props<T>) => {
|
||||
const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, index, id, addressHash, renderContent, tab }: Props<T>) => {
|
||||
const url = React.useMemo(() => {
|
||||
if (!('method_id' in data)) {
|
||||
return '';
|
||||
@@ -28,11 +29,11 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
|
||||
pathname: '/address/[hash]',
|
||||
query: {
|
||||
hash: addressHash ?? '',
|
||||
tab: 'read_contract',
|
||||
tab,
|
||||
},
|
||||
hash: data.method_id,
|
||||
});
|
||||
}, [ addressHash, data ]);
|
||||
}, [ addressHash, data, tab ]);
|
||||
|
||||
const { hasCopied, onCopy } = useClipboard(url, 1000);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -85,7 +86,7 @@ const ContractMethodsAccordionItem = <T extends SmartContractMethod>({ data, ind
|
||||
<AccordionIcon/>
|
||||
</AccordionButton>
|
||||
</Element>
|
||||
<AccordionPanel pb={ 4 } px={ 0 }>
|
||||
<AccordionPanel pb={ 4 } pr={ 0 } pl="28px" w="calc(100% - 6px)">
|
||||
{ renderContent(data, index, id) }
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -28,7 +28,7 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<ContractRead addressHash={ addressHash }/>
|
||||
<ContractRead/>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
@@ -37,8 +37,8 @@ test('base view +@mobile +@dark-mode', async({ mount, page }) => {
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
|
||||
await component.getByPlaceholder(/address/i).type('address-hash');
|
||||
await component.getByText(/query/i).click();
|
||||
await component.getByPlaceholder(/address/i).type('0xa113Ce24919C08a26C952E81681dAc861d6a2466');
|
||||
await component.getByText(/read/i).click();
|
||||
|
||||
await component.getByText(/wei/i).click();
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Alert, Flex } from '@chakra-ui/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
|
||||
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract';
|
||||
|
||||
import useApiFetch from 'lib/api/useApiFetch';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
|
||||
import ContentLoader from 'ui/shared/ContentLoader';
|
||||
import DataFetchAlert from 'ui/shared/DataFetchAlert';
|
||||
@@ -17,15 +19,15 @@ import ContractMethodConstant from './ContractMethodConstant';
|
||||
import ContractReadResult from './ContractReadResult';
|
||||
import useWatchAccount from './useWatchAccount';
|
||||
|
||||
interface Props {
|
||||
addressHash?: string;
|
||||
isProxy?: boolean;
|
||||
isCustomAbi?: boolean;
|
||||
}
|
||||
|
||||
const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => {
|
||||
const ContractRead = () => {
|
||||
const apiFetch = useApiFetch();
|
||||
const account = useWatchAccount();
|
||||
const router = useRouter();
|
||||
|
||||
const tab = getQueryParamString(router.query.tab);
|
||||
const addressHash = getQueryParamString(router.query.hash);
|
||||
const isProxy = tab === 'read_proxy';
|
||||
const isCustomAbi = tab === 'read_custom_methods';
|
||||
|
||||
const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', {
|
||||
pathParams: { hash: addressHash },
|
||||
@@ -96,7 +98,7 @@ const ContractRead = ({ addressHash, isProxy, isCustomAbi }: Props) => {
|
||||
{ isCustomAbi && <ContractCustomAbiAlert/> }
|
||||
{ account && <ContractConnectWallet/> }
|
||||
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/>
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,3 +99,34 @@ test('success', async({ mount }) => {
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
test('complex success', async({ mount }) => {
|
||||
const result: ContractMethodReadResult = {
|
||||
is_error: false,
|
||||
result: {
|
||||
names: [
|
||||
[
|
||||
'data',
|
||||
[ 'totalSupply', 'owner', 'symbol' ],
|
||||
],
|
||||
'supports721',
|
||||
'page',
|
||||
],
|
||||
output: [
|
||||
{
|
||||
type: 'tuple[uint256,address,string]',
|
||||
value: [ 1000, '0xe150519ae293922cfe6217feba3add4726f5e851', 'AOC_INCUBATORS' ],
|
||||
},
|
||||
{ type: 'bool', value: 'true' },
|
||||
{ type: 'uint256[]', value: [ 1, 2, 3, 4, 5 ] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<ContractReadResult item={ item } onSettle={ onSettle } result={ result }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
@@ -2,17 +2,55 @@ import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { ContractMethodReadResult } from './types';
|
||||
import type { SmartContractReadMethod } from 'types/api/contract';
|
||||
import type { SmartContractQueryMethodReadSuccess, SmartContractReadMethod } from 'types/api/contract';
|
||||
|
||||
import hexToUtf8 from 'lib/hexToUtf8';
|
||||
|
||||
const TUPLE_TYPE_REGEX = /\[(.+)\]/;
|
||||
|
||||
const ContractReadResultError = ({ children }: {children: React.ReactNode}) => {
|
||||
return (
|
||||
<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 {
|
||||
@@ -53,14 +91,12 @@ const ContractReadResult = ({ item, result, onSettle }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm">
|
||||
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm" whiteSpace="break-spaces" wordBreak="break-all">
|
||||
<p>
|
||||
[ <chakra.span fontWeight={ 600 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
|
||||
[ <chakra.span fontWeight={ 500 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
|
||||
</p>
|
||||
<p>[</p>
|
||||
{ result.result.output.map(({ type, value }, index) => (
|
||||
<chakra.p key={ index } whiteSpace="break-spaces" wordBreak="break-all"> { type }: { String(value) }</chakra.p>
|
||||
)) }
|
||||
{ result.result.output.map((output, index) => <ContractReadResultItem key={ index } output={ output } name={ result.result.names[index] }/>) }
|
||||
<p>]</p>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,7 @@ test('base view +@mobile', async({ mount, page }) => {
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<ContractWrite addressHash={ addressHash }/>
|
||||
<ContractWrite/>
|
||||
</TestApp>,
|
||||
{ hooksConfig },
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { useAccount, useWalletClient, useNetwork, useSwitchNetwork } from 'wagmi';
|
||||
|
||||
@@ -5,6 +6,7 @@ import type { SmartContractWriteMethod } from 'types/api/contract';
|
||||
|
||||
import config from 'configs/app';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
|
||||
import ContentLoader from 'ui/shared/ContentLoader';
|
||||
import DataFetchAlert from 'ui/shared/DataFetchAlert';
|
||||
@@ -17,18 +19,19 @@ import ContractWriteResult from './ContractWriteResult';
|
||||
import useContractAbi from './useContractAbi';
|
||||
import { getNativeCoinValue, prepareAbi } from './utils';
|
||||
|
||||
interface Props {
|
||||
addressHash?: string;
|
||||
isProxy?: boolean;
|
||||
isCustomAbi?: boolean;
|
||||
}
|
||||
|
||||
const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
|
||||
const ContractWrite = () => {
|
||||
const { data: walletClient } = useWalletClient();
|
||||
const { isConnected } = useAccount();
|
||||
const { chain } = useNetwork();
|
||||
const { switchNetworkAsync } = useSwitchNetwork();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const tab = getQueryParamString(router.query.tab);
|
||||
const addressHash = getQueryParamString(router.query.hash);
|
||||
const isProxy = tab === 'write_proxy';
|
||||
const isCustomAbi = tab === 'write_custom_methods';
|
||||
|
||||
const { data, isPending, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', {
|
||||
pathParams: { hash: addressHash },
|
||||
queryParams: {
|
||||
@@ -112,7 +115,7 @@ const ContractWrite = ({ addressHash, isProxy, isCustomAbi }: Props) => {
|
||||
{ isCustomAbi && <ContractCustomAbiAlert/> }
|
||||
<ContractConnectWallet/>
|
||||
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent }/>
|
||||
<ContractMethodsAccordion data={ data } addressHash={ addressHash } renderItemContent={ renderItemContent } tab={ tab }/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -82,7 +82,6 @@ const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
fontSize="sm"
|
||||
pl={ 3 }
|
||||
mt={ 3 }
|
||||
alignItems="center"
|
||||
whiteSpace="pre-wrap"
|
||||
|
||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -2,7 +2,10 @@ import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/ap
|
||||
|
||||
import type { ResourceError } from 'lib/api/resources';
|
||||
|
||||
export type MethodFormFields = Record<string, string>;
|
||||
export type MethodFormFields = Record<string, string | Array<string>>;
|
||||
export type MethodFormFieldsFormatted = Record<string, MethodArgType>;
|
||||
|
||||
export type MethodArgType = string | boolean | Array<MethodArgType>;
|
||||
|
||||
export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { prepareAbi } from './utils';
|
||||
import type { SmartContractMethodInput } from 'types/api/contract';
|
||||
|
||||
import { prepareAbi, transformFieldsToArgs, formatFieldValues } from './utils';
|
||||
|
||||
describe('function prepareAbi()', () => {
|
||||
const commonAbi = [
|
||||
@@ -98,3 +100,100 @@ describe('function prepareAbi()', () => {
|
||||
expect(item).toEqual(commonAbi[2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('function formatFieldValues()', () => {
|
||||
const formFields = {
|
||||
'_tx%0:nonce%0': '1 000 000 000 000 000 000',
|
||||
'_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c',
|
||||
'_tx%0:targets%2': [
|
||||
'1',
|
||||
'true',
|
||||
],
|
||||
'_l2OutputIndex%1': '0xaeff',
|
||||
'_paused%2': '0',
|
||||
'_withdrawalProof%3': [
|
||||
'0x0f',
|
||||
'0x02',
|
||||
],
|
||||
};
|
||||
|
||||
const inputs: Array<SmartContractMethodInput> = [
|
||||
{
|
||||
components: [
|
||||
{ internalType: 'uint256', name: 'nonce', type: 'uint256' },
|
||||
{ internalType: 'address', name: 'sender', type: 'address' },
|
||||
{ internalType: 'bool[]', name: 'targets', type: 'bool[]' },
|
||||
],
|
||||
internalType: 'tuple',
|
||||
name: '_tx',
|
||||
type: 'tuple',
|
||||
},
|
||||
{ internalType: 'bytes32', name: '_l2OutputIndex', type: 'bytes32' },
|
||||
{
|
||||
internalType: 'bool',
|
||||
name: '_paused',
|
||||
type: 'bool',
|
||||
},
|
||||
{
|
||||
internalType: 'bytes32[]',
|
||||
name: '_withdrawalProof',
|
||||
type: 'bytes32[]',
|
||||
},
|
||||
];
|
||||
|
||||
it('converts values to correct format', () => {
|
||||
const result = formatFieldValues(formFields, inputs);
|
||||
expect(result).toEqual({
|
||||
'_tx%0:nonce%0': '1000000000000000000',
|
||||
'_tx%0:sender%1': '0xB375d4150A853482f25E3922A4C64c6C4fF6Ae3c',
|
||||
'_tx%0:targets%2': [
|
||||
true,
|
||||
true,
|
||||
],
|
||||
'_l2OutputIndex%1': '0xaeff',
|
||||
'_paused%2': false,
|
||||
'_withdrawalProof%3': [
|
||||
'0x0f',
|
||||
'0x02',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts nested array string representation to correct format', () => {
|
||||
const formFields = {
|
||||
'_withdrawalProof%0': '[ [ 1 ], [ 2, 3 ], [ 4 ]]',
|
||||
};
|
||||
const inputs: Array<SmartContractMethodInput> = [
|
||||
{ internalType: 'uint[][]', name: '_withdrawalProof', type: 'uint[][]' },
|
||||
];
|
||||
const result = formatFieldValues(formFields, inputs);
|
||||
|
||||
expect(result).toEqual({
|
||||
'_withdrawalProof%0': [ [ 1 ], [ 2, 3 ], [ 4 ] ],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('function transformFieldsToArgs()', () => {
|
||||
it('groups struct and array fields', () => {
|
||||
const formFields = {
|
||||
'_paused%2': 'primitive_1',
|
||||
'_l2OutputIndex%1': 'primitive_0',
|
||||
'_tx%0:nonce%0': 'struct_0',
|
||||
'_tx%0:sender%1': 'struct_1',
|
||||
'_tx%0:target%2': [ 'struct_2_0', 'struct_2_1' ],
|
||||
'_withdrawalProof%3': [
|
||||
'array_0',
|
||||
'array_1',
|
||||
],
|
||||
};
|
||||
|
||||
const args = transformFieldsToArgs(formFields);
|
||||
expect(args).toEqual([
|
||||
[ 'struct_0', 'struct_1', [ 'struct_2_0', 'struct_2_1' ] ],
|
||||
'primitive_0',
|
||||
'primitive_1',
|
||||
[ 'array_0', 'array_1' ],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
import type { Abi } from 'abitype';
|
||||
import _mapValues from 'lodash/mapValues';
|
||||
|
||||
import type { SmartContractWriteMethod } from 'types/api/contract';
|
||||
import type { MethodArgType, MethodFormFields, MethodFormFieldsFormatted } from './types';
|
||||
import type { SmartContractMethodArgType, SmartContractMethodInput, SmartContractWriteMethod } from 'types/api/contract';
|
||||
|
||||
export const INT_REGEXP = /^(u)?int(\d+)?$/i;
|
||||
|
||||
export const BYTES_REGEXP = /^bytes(\d+)?$/i;
|
||||
|
||||
export const ARRAY_REGEXP = /^(.*)\[(\d*)\]$/;
|
||||
|
||||
export const getIntBoundaries = (power: number, isUnsigned: boolean) => {
|
||||
const maxUnsigned = 2 ** power;
|
||||
const max = isUnsigned ? maxUnsigned - 1 : maxUnsigned / 2 - 1;
|
||||
const min = isUnsigned ? 0 : -maxUnsigned / 2;
|
||||
return [ min, max ];
|
||||
};
|
||||
|
||||
export const formatBooleanValue = (value: string) => {
|
||||
const formattedValue = value.toLowerCase();
|
||||
|
||||
switch (formattedValue) {
|
||||
case 'true':
|
||||
case '1': {
|
||||
return 'true';
|
||||
}
|
||||
|
||||
case 'false':
|
||||
case '0': {
|
||||
return 'false';
|
||||
}
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const getNativeCoinValue = (value: string | Array<unknown>) => {
|
||||
const _value = Array.isArray(value) ? value[0] : value;
|
||||
@@ -12,24 +46,6 @@ export const getNativeCoinValue = (value: string | Array<unknown>) => {
|
||||
return BigInt(_value);
|
||||
};
|
||||
|
||||
export const addZeroesAllowed = (valueType: string) => {
|
||||
if (valueType.includes('[')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const REGEXP = /^u?int(\d+)/i;
|
||||
|
||||
const match = valueType.match(REGEXP);
|
||||
const power = match?.[1];
|
||||
|
||||
if (power) {
|
||||
// show control for all inputs which allows to insert 10^18 or greater numbers
|
||||
return Number(power) >= 64;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
interface ExtendedError extends Error {
|
||||
detectedNetwork?: {
|
||||
chain: number;
|
||||
@@ -75,3 +91,106 @@ export function prepareAbi(abi: Abi, item: SmartContractWriteMethod): Abi {
|
||||
|
||||
return abi;
|
||||
}
|
||||
|
||||
function getFieldType(fieldName: string, inputs: Array<SmartContractMethodInput>) {
|
||||
const chunks = fieldName.split(':');
|
||||
|
||||
if (chunks.length === 1) {
|
||||
const [ , index ] = chunks[0].split('%');
|
||||
return inputs[Number(index)].type;
|
||||
} else {
|
||||
const group = chunks[0].split('%');
|
||||
const input = chunks[1].split('%');
|
||||
|
||||
return inputs[Number(group[1])].components?.[Number(input[1])].type;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArrayValue(value: string) {
|
||||
try {
|
||||
const parsedResult = JSON.parse(value);
|
||||
if (Array.isArray(parsedResult)) {
|
||||
return parsedResult as Array<string>;
|
||||
}
|
||||
throw new Error('Not an array');
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function castValue(value: string, type: SmartContractMethodArgType) {
|
||||
if (type === 'bool') {
|
||||
return formatBooleanValue(value) === 'true';
|
||||
}
|
||||
|
||||
const intMatch = type.match(INT_REGEXP);
|
||||
if (intMatch) {
|
||||
return value.replaceAll(' ', '');
|
||||
}
|
||||
|
||||
const isNestedArray = (type.match(/\[/g) || []).length > 1;
|
||||
if (isNestedArray) {
|
||||
return parseArrayValue(value) || value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatFieldValues(formFields: MethodFormFields, inputs: Array<SmartContractMethodInput>) {
|
||||
const formattedFields = _mapValues(formFields, (value, key) => {
|
||||
const type = getFieldType(key, inputs);
|
||||
|
||||
if (!type) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const arrayMatch = type.match(ARRAY_REGEXP);
|
||||
|
||||
if (arrayMatch) {
|
||||
return value.map((item) => castValue(item, arrayMatch[1] as SmartContractMethodArgType));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
return castValue(value, type);
|
||||
});
|
||||
|
||||
return formattedFields;
|
||||
}
|
||||
|
||||
export function transformFieldsToArgs(formFields: MethodFormFieldsFormatted) {
|
||||
const unGroupedFields = Object.entries(formFields)
|
||||
.reduce((
|
||||
result: Record<string, MethodArgType>,
|
||||
[ key, value ]: [ string, MethodArgType ],
|
||||
) => {
|
||||
const chunks = key.split(':');
|
||||
|
||||
if (chunks.length > 1) {
|
||||
const groupKey = chunks[0];
|
||||
const [ , fieldIndex ] = chunks[1].split('%');
|
||||
|
||||
if (result[groupKey] === undefined) {
|
||||
result[groupKey] = [];
|
||||
}
|
||||
|
||||
(result[groupKey] as Array<MethodArgType>)[Number(fieldIndex)] = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
const args = (Object.entries(unGroupedFields)
|
||||
.map(([ key, value ]) => {
|
||||
const [ , index ] = key.split('%');
|
||||
return [ Number(index), value ];
|
||||
}) as Array<[ number, string | Array<string> ]>)
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([ , value ]) => value);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import { route } from 'nextjs-routes';
|
||||
|
||||
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
|
||||
import LinkInternal from 'ui/shared/LinkInternal';
|
||||
import TruncatedValue from 'ui/shared/TruncatedValue';
|
||||
|
||||
import type { TokenEnhancedData } from '../utils/tokenUtils';
|
||||
@@ -47,11 +48,10 @@ const TokenSelectItem = ({ data }: Props) => {
|
||||
}
|
||||
})();
|
||||
|
||||
// TODO add filter param when token page is ready
|
||||
const url = route({ pathname: '/token/[hash]', query: { hash: data.token.address } });
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<LinkInternal
|
||||
px={ 1 }
|
||||
py="10px"
|
||||
display="flex"
|
||||
@@ -62,9 +62,8 @@ const TokenSelectItem = ({ data }: Props) => {
|
||||
_hover={{
|
||||
bgColor: useColorModeValue('blue.50', 'gray.800'),
|
||||
}}
|
||||
color="initial"
|
||||
fontSize="sm"
|
||||
cursor="pointer"
|
||||
as="a"
|
||||
href={ url }
|
||||
>
|
||||
<Flex alignItems="center" w="100%" overflow="hidden">
|
||||
@@ -80,7 +79,7 @@ const TokenSelectItem = ({ data }: Props) => {
|
||||
<Flex alignItems="center" justifyContent="space-between" w="100%" whiteSpace="nowrap">
|
||||
{ secondRow }
|
||||
</Flex>
|
||||
</Flex>
|
||||
</LinkInternal>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,18 +20,19 @@ const GraphQL = () => {
|
||||
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
const graphqlTheme = window.localStorage.getItem('graphiql:theme');
|
||||
|
||||
// colorModeState used as a key to re-render GraphiQL conponent after color mode change
|
||||
const [ colorModeState, setColorModeState ] = React.useState(colorMode);
|
||||
const [ colorModeState, setColorModeState ] = React.useState(graphqlTheme);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isBrowser()) {
|
||||
const graphqlTheme = window.localStorage.getItem('graphiql:theme');
|
||||
if (graphqlTheme !== colorMode) {
|
||||
window.localStorage.setItem('graphiql:theme', colorMode);
|
||||
setColorModeState(colorMode);
|
||||
}
|
||||
}
|
||||
}, [ colorMode ]);
|
||||
}, [ colorMode, graphqlTheme ]);
|
||||
|
||||
if (!feature.isEnabled) {
|
||||
return null;
|
||||
|
||||
@@ -92,7 +92,7 @@ const Stats = () => {
|
||||
<StatsItem
|
||||
icon="gas"
|
||||
title="Gas tracker"
|
||||
value={ `${ Number(data.gas_prices.average).toLocaleString() } Gwei` }
|
||||
value={ data.gas_prices.average !== null ? `${ Number(data.gas_prices.average).toLocaleString() } Gwei` : 'N/A' }
|
||||
_last={ isOdd ? lastItemTouchStyle : undefined }
|
||||
tooltipLabel={ gasLabel }
|
||||
isLoading={ isPlaceholderData }
|
||||
|
||||
@@ -153,6 +153,7 @@ const MarketplaceAppCard = ({
|
||||
<IconSvg
|
||||
name="arrows/north-east"
|
||||
marginLeft={ 1 }
|
||||
boxSize={ 4 }
|
||||
/>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
@@ -8,11 +8,8 @@ import useApiQuery from 'lib/api/useApiQuery';
|
||||
import { useAppContext } from 'lib/contexts/app';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import { TX } from 'stubs/tx';
|
||||
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
|
||||
import TextAd from 'ui/shared/ad/TextAd';
|
||||
import TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
import EntityTags from 'ui/shared/EntityTags';
|
||||
import NetworkExplorers from 'ui/shared/NetworkExplorers';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
|
||||
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
|
||||
@@ -23,6 +20,7 @@ import TxInternals from 'ui/tx/TxInternals';
|
||||
import TxLogs from 'ui/tx/TxLogs';
|
||||
import TxRawTrace from 'ui/tx/TxRawTrace';
|
||||
import TxState from 'ui/tx/TxState';
|
||||
import TxSubHeading from 'ui/tx/TxSubHeading';
|
||||
import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
|
||||
|
||||
const TransactionPageContent = () => {
|
||||
@@ -40,7 +38,11 @@ const TransactionPageContent = () => {
|
||||
});
|
||||
|
||||
const tabs: Array<RoutedTab> = [
|
||||
{ id: 'index', title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details', component: <TxDetails/> },
|
||||
{
|
||||
id: 'index',
|
||||
title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details',
|
||||
component: <TxDetails/>,
|
||||
},
|
||||
config.features.suave.isEnabled && data?.wrapped ?
|
||||
{ id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } :
|
||||
undefined,
|
||||
@@ -73,13 +75,7 @@ const TransactionPageContent = () => {
|
||||
};
|
||||
}, [ appProps.referrer ]);
|
||||
|
||||
const titleSecondRow = (
|
||||
<>
|
||||
<TxEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } mr={ 2 } fontFamily="heading"/>
|
||||
{ !data?.tx_tag && <AccountActionsMenu mr={{ base: 0, lg: 3 }}/> }
|
||||
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 3, lg: 'auto' }}/>
|
||||
</>
|
||||
);
|
||||
const titleSecondRow = <TxSubHeading hash={ hash } hasTag={ Boolean(data?.tx_tag) }/>;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -11,11 +11,11 @@ const GasInfoTooltipContent = ({ gasPrices }: {gasPrices: GasPrices}) => {
|
||||
return (
|
||||
<Grid templateColumns="repeat(2, max-content)" rowGap={ 2 } columnGap={ 4 } padding={ 4 } fontSize="xs">
|
||||
<GridItem { ...nameStyleProps }>Slow</GridItem>
|
||||
<GridItem>{ `${ gasPrices.slow } Gwei` }</GridItem>
|
||||
<GridItem>{ gasPrices.slow !== null ? `${ gasPrices.slow } Gwei` : 'N/A' }</GridItem>
|
||||
<GridItem { ...nameStyleProps }>Average</GridItem>
|
||||
<GridItem>{ `${ gasPrices.average } Gwei` }</GridItem>
|
||||
<GridItem>{ gasPrices.average !== null ? `${ gasPrices.average } Gwei` : 'N/A' }</GridItem>
|
||||
<GridItem { ...nameStyleProps }>Fast</GridItem>
|
||||
<GridItem>{ `${ gasPrices.fast } Gwei` }</GridItem>
|
||||
<GridItem>{ gasPrices.fast !== null ? `${ gasPrices.fast } Gwei` : 'N/A' }</GridItem>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import NextLink from 'next/link';
|
||||
import type { LegacyRef } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
// NOTE! use this component only for links to pages that are completely implemented in new UI
|
||||
const LinkInternal = ({ isLoading, ...props }: LinkProps & { isLoading?: boolean }, ref: LegacyRef<HTMLAnchorElement>) => {
|
||||
if (isLoading) {
|
||||
return <Flex alignItems="center" { ...props as FlexProps }>{ props.children }</Flex>;
|
||||
|
||||
@@ -35,6 +35,7 @@ const ColorModeSwitch = () => {
|
||||
window.document.documentElement.style.setProperty(varName, hex);
|
||||
|
||||
cookies.set(cookies.NAMES.COLOR_MODE_HEX, hex);
|
||||
window.localStorage.setItem(cookies.NAMES.COLOR_MODE, nextTheme.colorMode);
|
||||
}, [ setColorMode ]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -40,7 +40,7 @@ const TopBarStats = () => {
|
||||
</Skeleton>
|
||||
) }
|
||||
{ data?.coin_price && config.UI.homepage.showGasTracker && <TextSeparator color="divider"/> }
|
||||
{ data?.gas_prices && config.UI.homepage.showGasTracker && (
|
||||
{ data?.gas_prices && data.gas_prices.average !== null && config.UI.homepage.showGasTracker && (
|
||||
<Skeleton isLoaded={ !isPlaceholderData }>
|
||||
<chakra.span color="text_secondary">Gas </chakra.span>
|
||||
<LightMode>
|
||||
|
||||
@@ -44,7 +44,7 @@ import TextSeparator from 'ui/shared/TextSeparator';
|
||||
import TxFeeStability from 'ui/shared/tx/TxFeeStability';
|
||||
import Utilization from 'ui/shared/Utilization/Utilization';
|
||||
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
|
||||
import TxDetailsActions from 'ui/tx/details/TxDetailsActions';
|
||||
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
|
||||
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
|
||||
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
|
||||
import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
|
||||
@@ -98,8 +98,6 @@ const TxDetails = () => {
|
||||
...toAddress?.watchlist_names || [],
|
||||
].map((tag) => <Tag key={ tag.label }>{ tag.display_name }</Tag>);
|
||||
|
||||
const actionsExist = data.actions && data.actions.length > 0;
|
||||
|
||||
const executionSuccessBadge = toAddress?.is_contract && data.result === 'success' ? (
|
||||
<Tooltip label="Contract execution completed">
|
||||
<chakra.span display="inline-flex" ml={ 2 } mr={ 1 }>
|
||||
@@ -242,12 +240,7 @@ const TxDetails = () => {
|
||||
|
||||
<DetailsInfoItemDivider/>
|
||||
|
||||
{ actionsExist && (
|
||||
<>
|
||||
<TxDetailsActions actions={ data.actions }/>
|
||||
<DetailsInfoItemDivider/>
|
||||
</>
|
||||
) }
|
||||
<TxDetailsActions hash={ data.hash } actions={ data.actions } isTxDataLoading={ isPlaceholderData }/>
|
||||
|
||||
<DetailsInfoItem
|
||||
title="From"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test, expect } from '@playwright/experimental-ct-react';
|
||||
import React from 'react';
|
||||
|
||||
import { txInterpretation } from 'mocks/txs/txInterpretation';
|
||||
import contextWithEnvs from 'playwright/fixtures/contextWithEnvs';
|
||||
import TestApp from 'playwright/TestApp';
|
||||
import buildApiUrl from 'playwright/utils/buildApiUrl';
|
||||
import * as configs from 'playwright/utils/configs';
|
||||
|
||||
import TxSubHeading from './TxSubHeading';
|
||||
|
||||
const hash = '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193';
|
||||
|
||||
const TX_INTERPRETATION_API_URL = buildApiUrl('tx_interpretation', { hash });
|
||||
|
||||
test('no interpretation +@mobile', async({ mount }) => {
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<TxSubHeading hash={ hash } hasTag={ false }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
const bsInterpretationTest = test.extend({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: contextWithEnvs(configs.featureEnvs.txInterpretation) as any,
|
||||
});
|
||||
|
||||
bsInterpretationTest('with interpretation +@mobile +@dark-mode', async({ mount, page }) => {
|
||||
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(txInterpretation),
|
||||
}));
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<TxSubHeading hash={ hash } hasTag={ false }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
|
||||
bsInterpretationTest('no interpretation', async({ mount, page }) => {
|
||||
await page.route(TX_INTERPRETATION_API_URL, (route) => route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ data: { summaries: [] } }),
|
||||
}));
|
||||
|
||||
const component = await mount(
|
||||
<TestApp>
|
||||
<TxSubHeading hash={ hash } hasTag={ false }/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await expect(component).toHaveScreenshot();
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Box, Flex, Link } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import config from 'configs/app';
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import { TX_INTERPRETATION } from 'stubs/txInterpretation';
|
||||
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
|
||||
import TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
import NetworkExplorers from 'ui/shared/NetworkExplorers';
|
||||
import { TX_ACTIONS_BLOCK_ID } from 'ui/tx/details/txDetailsActions/TxDetailsActionsWrapper';
|
||||
import TxInterpretation from 'ui/tx/interpretation/TxInterpretation';
|
||||
|
||||
type Props = {
|
||||
hash?: string;
|
||||
hasTag: boolean;
|
||||
}
|
||||
|
||||
const TxSubHeading = ({ hash, hasTag }: Props) => {
|
||||
const hasInterpretationFeature = config.features.txInterpretation.isEnabled;
|
||||
|
||||
const txInterpretationQuery = useApiQuery('tx_interpretation', {
|
||||
pathParams: { hash },
|
||||
queryOptions: {
|
||||
enabled: Boolean(hash) && hasInterpretationFeature,
|
||||
placeholderData: TX_INTERPRETATION,
|
||||
},
|
||||
});
|
||||
|
||||
const hasInterpretation = hasInterpretationFeature &&
|
||||
(txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length));
|
||||
|
||||
return (
|
||||
<Box display={{ base: 'block', lg: 'flex' }} alignItems="center" w="100%">
|
||||
{ hasInterpretation && (
|
||||
<Flex mr={{ base: 0, lg: 6 }} flexWrap="wrap" alignItems="center">
|
||||
<TxInterpretation
|
||||
summary={ txInterpretationQuery.data?.data.summaries[0] }
|
||||
isLoading={ txInterpretationQuery.isPlaceholderData }
|
||||
fontSize="lg"
|
||||
/>
|
||||
{ !txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1 &&
|
||||
<Link ml={ 3 } href={ `#${ TX_ACTIONS_BLOCK_ID }` }>all actions</Link> }
|
||||
</Flex>
|
||||
) }
|
||||
{ !hasInterpretation && <TxEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } mr={{ base: 0, lg: 2 }} fontFamily="heading"/> }
|
||||
<Flex alignItems="center" justifyContent={{ base: 'start', lg: 'space-between' }} flexGrow={ 1 }>
|
||||
{ !hasTag && <AccountActionsMenu mr={ 3 } mt={{ base: 3, lg: 0 }}/> }
|
||||
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 0, lg: 'auto' }} mt={{ base: 3, lg: 0 }}/>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TxSubHeading;
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { TxAction } from 'types/api/txAction';
|
||||
|
||||
import config from 'configs/app';
|
||||
import TxDetailsActionsInterpretation from 'ui/tx/details/txDetailsActions/TxDetailsActionsInterpretation';
|
||||
import TxDetailsActionsRaw from 'ui/tx/details/txDetailsActions/TxDetailsActionsRaw';
|
||||
|
||||
type Props = {
|
||||
isTxDataLoading: boolean;
|
||||
actions?: Array<TxAction>;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
const TxDetailsActions = ({ isTxDataLoading, actions, hash }: Props) => {
|
||||
if (config.features.txInterpretation.isEnabled) {
|
||||
return <TxDetailsActionsInterpretation hash={ hash } isTxDataLoading={ isTxDataLoading }/>;
|
||||
}
|
||||
|
||||
/* if tx interpretation is not configured, show tx actions from tx info */
|
||||
if (actions && actions.length > 0) {
|
||||
return <TxDetailsActionsRaw actions={ actions } isLoading={ isTxDataLoading }/>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default TxDetailsActions;
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import useApiQuery from 'lib/api/useApiQuery';
|
||||
import { TX_INTERPRETATION } from 'stubs/txInterpretation';
|
||||
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
|
||||
import TxInterpretation from 'ui/tx/interpretation/TxInterpretation';
|
||||
|
||||
import TxDetailsActionsWrapper from './TxDetailsActionsWrapper';
|
||||
|
||||
interface Props {
|
||||
hash?: string;
|
||||
isTxDataLoading: boolean;
|
||||
}
|
||||
|
||||
const TxDetailsActionsInterpretation = ({ hash, isTxDataLoading }: Props) => {
|
||||
const txInterpretationQuery = useApiQuery('tx_interpretation', {
|
||||
pathParams: { hash },
|
||||
queryOptions: {
|
||||
enabled: Boolean(hash) && !isTxDataLoading,
|
||||
placeholderData: TX_INTERPRETATION,
|
||||
refetchOnMount: false,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = txInterpretationQuery.data?.data.summaries;
|
||||
|
||||
if (!actions || actions.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TxDetailsActionsWrapper isLoading={ isTxDataLoading || txInterpretationQuery.isPlaceholderData }>
|
||||
{ actions.map((action, index: number) => (
|
||||
<TxInterpretation
|
||||
key={ index }
|
||||
summary={ action }
|
||||
isLoading={ isTxDataLoading || txInterpretationQuery.isPlaceholderData }
|
||||
/>
|
||||
),
|
||||
) }
|
||||
</TxDetailsActionsWrapper>
|
||||
<DetailsInfoItemDivider/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TxDetailsActionsInterpretation;
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
|
||||
import type { TxAction } from 'types/api/txAction';
|
||||
|
||||
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
|
||||
|
||||
import TxDetailsAction from './TxDetailsAction';
|
||||
import TxDetailsActionsWrapper from './TxDetailsActionsWrapper';
|
||||
|
||||
interface Props {
|
||||
actions: Array<TxAction>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const TxDetailsActionsRaw = ({ actions, isLoading }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<TxDetailsActionsWrapper isLoading={ isLoading }>
|
||||
{ actions.map((action, index: number) => <TxDetailsAction key={ index } action={ action }/>) }
|
||||
</TxDetailsActionsWrapper>
|
||||
<DetailsInfoItemDivider/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TxDetailsActionsRaw;
|
||||
@@ -1,19 +1,18 @@
|
||||
import { Flex, useColorModeValue } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { TxAction } from 'types/api/txAction';
|
||||
|
||||
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
|
||||
|
||||
import TxDetailsAction from './TxDetailsAction';
|
||||
|
||||
const SCROLL_GRADIENT_HEIGHT = 48;
|
||||
|
||||
interface Props {
|
||||
actions: Array<TxAction>;
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const TxDetailsActions = ({ actions }: Props) => {
|
||||
export const TX_ACTIONS_BLOCK_ID = 'tx-actions';
|
||||
|
||||
const TxDetailsActions = ({ children, isLoading }: Props) => {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [ hasScroll, setHasScroll ] = React.useState(false);
|
||||
|
||||
@@ -34,8 +33,10 @@ const TxDetailsActions = ({ actions }: Props) => {
|
||||
hint="Highlighted events of the transaction"
|
||||
note={ hasScroll ? 'Scroll to see more' : undefined }
|
||||
position="relative"
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
<Flex
|
||||
id={ TX_ACTIONS_BLOCK_ID }
|
||||
flexDirection="column"
|
||||
alignItems="stretch"
|
||||
rowGap={ 5 }
|
||||
@@ -55,7 +56,7 @@ const TxDetailsActions = ({ actions }: Props) => {
|
||||
pr={ hasScroll ? 5 : 0 }
|
||||
pb={ hasScroll ? 10 : 0 }
|
||||
>
|
||||
{ actions.map((action, index: number) => <TxDetailsAction key={ index } action={ action }/>) }
|
||||
{ children }
|
||||
</Flex>
|
||||
</DetailsInfoItem>
|
||||
);
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Skeleton, Flex, Text, chakra } from '@chakra-ui/react';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import React from 'react';
|
||||
|
||||
import type { TxInterpretationSummary, TxInterpretationVariable } from 'types/api/txInterpretation';
|
||||
|
||||
import config from 'configs/app';
|
||||
import dayjs from 'lib/date/dayjs';
|
||||
import * as mixpanel from 'lib/mixpanel/index';
|
||||
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
|
||||
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
|
||||
import IconSvg from 'ui/shared/IconSvg';
|
||||
|
||||
import { extractVariables, getStringChunks, NATIVE_COIN_SYMBOL_VAR_NAME } from './utils';
|
||||
|
||||
type Props = {
|
||||
summary?: TxInterpretationSummary;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TxInterpretationElementByType = ({ variable }: { variable?: TxInterpretationVariable }) => {
|
||||
const onAddressClick = React.useCallback(() => {
|
||||
mixpanel.logEvent(mixpanel.EventTypes.TX_INTERPRETATION_INTERACTION, { Type: 'Address click' });
|
||||
}, []);
|
||||
|
||||
const onTokenClick = React.useCallback(() => {
|
||||
mixpanel.logEvent(mixpanel.EventTypes.TX_INTERPRETATION_INTERACTION, { Type: 'Token click' });
|
||||
}, []);
|
||||
|
||||
if (!variable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, value } = variable;
|
||||
switch (type) {
|
||||
case 'address': {
|
||||
return (
|
||||
<AddressEntity
|
||||
address={ value }
|
||||
truncation="constant"
|
||||
sx={{ ':not(:first-child)': { marginLeft: 1 } }}
|
||||
whiteSpace="initial"
|
||||
onClick={ onAddressClick }
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'token':
|
||||
return (
|
||||
<TokenEntity
|
||||
token={ value }
|
||||
onlySymbol
|
||||
noCopy
|
||||
width="fit-content"
|
||||
sx={{ ':not(:first-child)': { marginLeft: 1 } }}
|
||||
mr={ 2 }
|
||||
whiteSpace="initial"
|
||||
onClick={ onTokenClick }
|
||||
/>
|
||||
);
|
||||
case 'currency': {
|
||||
let numberString = '';
|
||||
if (BigNumber(value).isLessThan(0.1)) {
|
||||
numberString = BigNumber(value).toPrecision(2);
|
||||
} else if (BigNumber(value).isLessThan(10000)) {
|
||||
numberString = BigNumber(value).dp(2).toFormat();
|
||||
} else if (BigNumber(value).isLessThan(1000000)) {
|
||||
numberString = BigNumber(value).dividedBy(1000).toFormat(2) + 'K';
|
||||
} else {
|
||||
numberString = BigNumber(value).dividedBy(1000000).toFormat(2) + 'M';
|
||||
}
|
||||
return <Text>{ numberString + ' ' }</Text>;
|
||||
}
|
||||
case 'timestamp':
|
||||
// timestamp is in unix format
|
||||
return <Text color="text_secondary">{ dayjs(Number(value) * 1000).format('llll') + ' ' }</Text>;
|
||||
case 'string':
|
||||
default: {
|
||||
return <Text color="text_secondary">{ value.toString() + ' ' }</Text>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const TxInterpretation = ({ summary, isLoading, className }: Props) => {
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const template = summary.summary_template;
|
||||
const variables = summary.summary_template_variables;
|
||||
|
||||
const variablesNames = extractVariables(template);
|
||||
|
||||
const chunks = getStringChunks(template);
|
||||
|
||||
return (
|
||||
<Skeleton display="flex" flexWrap="wrap" alignItems="center" isLoaded={ !isLoading } className={ className }>
|
||||
<IconSvg name="lightning" boxSize={ 5 } color="text_secondary" mr={ 2 }/>
|
||||
{ chunks.map((chunk, index) => {
|
||||
return (
|
||||
<Flex whiteSpace="pre" key={ chunk + index } fontWeight={ 500 }>
|
||||
<Text color="text_secondary">{ chunk.trim() + (chunk.trim() && variablesNames[index] ? ' ' : '') }</Text>
|
||||
{ index < variablesNames.length && (
|
||||
variablesNames[index] === NATIVE_COIN_SYMBOL_VAR_NAME ?
|
||||
<Text>{ config.chain.currency.symbol + ' ' }</Text> :
|
||||
<TxInterpretationElementByType variable={ variables[variablesNames[index]] }/>
|
||||
) }
|
||||
</Flex>
|
||||
);
|
||||
}) }
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default chakra(TxInterpretation);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { extractVariables, getStringChunks } from './utils';
|
||||
|
||||
const template = '{action_type} {source_amount} {native} into {destination_amount} {destination_token}';
|
||||
|
||||
it('extracts variables names', () => {
|
||||
const result = extractVariables(template);
|
||||
expect(result).toEqual([ 'action_type', 'source_amount', 'native', 'destination_amount', 'destination_token' ]);
|
||||
});
|
||||
|
||||
it('split string without capturing variables', () => {
|
||||
const result = getStringChunks(template);
|
||||
expect(result).toEqual([ '', ' ', ' ', ' into ', ' ', '' ]);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
// we use that regex as a separator when splitting template and dont want to capture variables
|
||||
// eslint-disable-next-line regexp/no-useless-non-capturing-group
|
||||
export const VAR_REGEXP = /\{(?:[^}]+)\}/g;
|
||||
|
||||
export const NATIVE_COIN_SYMBOL_VAR_NAME = 'native';
|
||||
|
||||
export function extractVariables(templateString: string) {
|
||||
|
||||
const matches = templateString.match(VAR_REGEXP);
|
||||
|
||||
const variablesNames = matches ? matches.map(match => match.slice(1, -1)) : [];
|
||||
|
||||
return variablesNames;
|
||||
}
|
||||
|
||||
export function getStringChunks(template: string) {
|
||||
return template.split(VAR_REGEXP);
|
||||
}
|
||||
@@ -13950,6 +13950,13 @@ react-jazzicon@^1.0.4:
|
||||
dependencies:
|
||||
mersenne-twister "^1.1.0"
|
||||
|
||||
react-number-format@^5.3.1:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-number-format/-/react-number-format-5.3.1.tgz#840c257da9cb4b248990d8db46e4d23e8bac67ff"
|
||||
integrity sha512-qpYcQLauIeEhCZUZY9jXZnnroOtdy3jYaS1zQ3M1Sr6r/KMOBEIGNIb7eKT19g2N1wbYgFgvDzs19hw5TrB8XQ==
|
||||
dependencies:
|
||||
prop-types "^15.7.2"
|
||||
|
||||
react-redux@^8.1.2:
|
||||
version "8.1.3"
|
||||
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-8.1.3.tgz#4fdc0462d0acb59af29a13c27ffef6f49ab4df46"
|
||||
|
||||