Commit 898cfafa authored by isstuev's avatar isstuev

add transaction interpretations

parent 9facb82d
...@@ -17,6 +17,7 @@ export { default as sentry } from './sentry'; ...@@ -17,6 +17,7 @@ export { default as sentry } from './sentry';
export { default as sol2uml } from './sol2uml'; export { default as sol2uml } from './sol2uml';
export { default as stats } from './stats'; export { default as stats } from './stats';
export { default as suave } from './suave'; export { default as suave } from './suave';
export { default as txInterpretation } from './txInterpretation';
export { default as web3Wallet } from './web3Wallet'; export { default as web3Wallet } from './web3Wallet';
export { default as verifiedTokens } from './verifiedTokens'; export { default as verifiedTokens } from './verifiedTokens';
export { default as zkEvmRollup } from './zkEvmRollup'; export { default as zkEvmRollup } from './zkEvmRollup';
import type { Feature } from './types';
import { getEnvValue } from '../utils';
const title = 'Transaction interpretation';
const config: Feature<{ isEnabled: true }> = (() => {
if (getEnvValue('NEXT_PUBLIC_TRANSACTION_INTERPRETATION_ENABLED') === 'true') {
return Object.freeze({
title,
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 ...@@ -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_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.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_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_ENABLED=true
#meta #meta
NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true NEXT_PUBLIC_OG_IMAGE_URL=https://github.com/blockscout/frontend-configs/blob/main/configs/og-images/eth.jpg?raw=true
...@@ -438,6 +438,7 @@ const schema = yup ...@@ -438,6 +438,7 @@ const schema = yup
return isNoneSchema.isValidSync(data) || isArrayOfWalletsSchema.isValidSync(data); return isNoneSchema.isValidSync(data) || isArrayOfWalletsSchema.isValidSync(data);
}), }),
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: yup.boolean(), NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET: yup.boolean(),
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_ENABLED: yup.boolean(),
NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string<AdTextProviders>().oneOf(SUPPORTED_AD_TEXT_PROVIDERS), NEXT_PUBLIC_AD_TEXT_PROVIDER: yup.string<AdTextProviders>().oneOf(SUPPORTED_AD_TEXT_PROVIDERS),
NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(), NEXT_PUBLIC_PROMOTE_BLOCKSCOUT_IN_TITLE: yup.boolean(),
NEXT_PUBLIC_OG_DESCRIPTION: yup.string(), NEXT_PUBLIC_OG_DESCRIPTION: yup.string(),
......
...@@ -44,6 +44,7 @@ Please be aware that all environment variables prefixed with `NEXT_PUBLIC_` will ...@@ -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) - [Solidity to UML diagrams](ENVS.md#solidity-to-uml-diagrams)
- [Blockchain statistics](ENVS.md#blockchain-statistics) - [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) - [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) - [Verified tokens info](ENVS.md#verified-tokens-info)
- [Bridged tokens](ENVS.md#bridged-tokens) - [Bridged tokens](ENVS.md#bridged-tokens)
- [Safe{Core} address tags](ENVS.md#safecore-address-tags) - [Safe{Core} address tags](ENVS.md#safecore-address-tags)
...@@ -473,6 +474,16 @@ This feature is **enabled by default** with the `['metamask']` value. To switch ...@@ -473,6 +474,16 @@ This feature is **enabled by default** with the `['metamask']` value. To switch
&nbsp; &nbsp;
### Transaction interpretation
<!-- Nikita will provide feature description -->
| Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_TRANSACTION_INTERPRETATION_ENABLED | `boolean`| Set to `true` to enable transaction interpretation | - | - | `true` |
&nbsp;
### Verified tokens info ### Verified tokens info
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
......
<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>
...@@ -65,6 +65,7 @@ import type { ...@@ -65,6 +65,7 @@ import type {
TransactionsResponseWatchlist, TransactionsResponseWatchlist,
TransactionsSorting, TransactionsSorting,
} from 'types/api/transaction'; } from 'types/api/transaction';
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters } from 'types/api/txsFilters'; import type { TTxsFilters } from 'types/api/txsFilters';
import type { TxStateChanges } from 'types/api/txStateChanges'; import type { TxStateChanges } from 'types/api/txStateChanges';
import type { VerifiedContractsSorting } from 'types/api/verifiedContracts'; import type { VerifiedContractsSorting } from 'types/api/verifiedContracts';
...@@ -246,6 +247,10 @@ export const RESOURCES = { ...@@ -246,6 +247,10 @@ export const RESOURCES = {
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
filterFields: [], filterFields: [],
}, },
tx_interpretation: {
path: '/api/v2/transactions/:hash/summary',
pathParams: [ 'hash' as const ],
},
withdrawals: { withdrawals: {
path: '/api/v2/withdrawals', path: '/api/v2/withdrawals',
filterFields: [], filterFields: [],
...@@ -651,6 +656,7 @@ Q extends 'tx_logs' ? LogsResponseTx : ...@@ -651,6 +656,7 @@ Q extends 'tx_logs' ? LogsResponseTx :
Q extends 'tx_token_transfers' ? TokenTransferResponse : Q extends 'tx_token_transfers' ? TokenTransferResponse :
Q extends 'tx_raw_trace' ? RawTracesResponse : Q extends 'tx_raw_trace' ? RawTracesResponse :
Q extends 'tx_state_changes' ? TxStateChanges : Q extends 'tx_state_changes' ? TxStateChanges :
Q extends 'tx_interpretation' ? TxInterpretationResponse :
Q extends 'addresses' ? AddressesResponse : Q extends 'addresses' ? AddressesResponse :
Q extends 'address' ? Address : Q extends 'address' ? Address :
Q extends 'address_counters' ? AddressCounters : Q extends 'address_counters' ? AddressCounters :
......
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',
},
},
} ],
},
};
import type { RawTracesResponse } from 'types/api/rawTrace'; import type { RawTracesResponse } from 'types/api/rawTrace';
import type { Transaction } from 'types/api/transaction'; import type { Transaction } from 'types/api/transaction';
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import { ADDRESS_PARAMS } from './addressParams'; import { ADDRESS_PARAMS } from './addressParams';
...@@ -59,3 +60,31 @@ export const TX_ZKEVM_L2: Transaction = { ...@@ -59,3 +60,31 @@ export const TX_ZKEVM_L2: Transaction = {
}; };
export const TX_RAW_TRACE: RawTracesResponse = []; export const TX_RAW_TRACE: RawTracesResponse = [];
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: {
name: 'Museion',
type: 'ERC-20',
symbol: 'MUSA',
address: '0x486a3c5f34cDc4EF133f248f1C81168D78da52e8',
holders: '1152',
decimals: '18',
icon_url: null,
total_supply: '210000000000000000000000000',
exchange_rate: null,
circulating_market_cap: null,
},
},
},
} ],
},
};
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;
}
import { Box, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
...@@ -7,7 +8,7 @@ import config from 'configs/app'; ...@@ -7,7 +8,7 @@ import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app'; import { useAppContext } from 'lib/contexts/app';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx'; import { TX, TX_INTERPRETATION } from 'stubs/tx';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import TxEntity from 'ui/shared/entities/tx/TxEntity'; import TxEntity from 'ui/shared/entities/tx/TxEntity';
...@@ -17,6 +18,8 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -17,6 +18,8 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery'; import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxInterpretation from 'ui/tx/interpretation/TxInterpretation';
import { checkTemplate as checkInterpretationTemplate } from 'ui/tx/interpretation/utils';
import TxDetails from 'ui/tx/TxDetails'; import TxDetails from 'ui/tx/TxDetails';
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped'; import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
import TxInternals from 'ui/tx/TxInternals'; import TxInternals from 'ui/tx/TxInternals';
...@@ -39,6 +42,16 @@ const TransactionPageContent = () => { ...@@ -39,6 +42,16 @@ const TransactionPageContent = () => {
}, },
}); });
const hasInterpretationFeature = config.features.txInterpretation.isEnabled;
const txInterpretationQuery = useApiQuery('tx_interpretation', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash) && hasInterpretationFeature,
placeholderData: TX_INTERPRETATION,
},
});
const tabs: Array<RoutedTab> = [ 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 ? config.features.suave.isEnabled && data?.wrapped ?
...@@ -73,12 +86,19 @@ const TransactionPageContent = () => { ...@@ -73,12 +86,19 @@ const TransactionPageContent = () => {
}; };
}, [ appProps.referrer ]); }, [ appProps.referrer ]);
const hasInterpretation =
hasInterpretationFeature && (txInterpretationQuery.isPlaceholderData ||
(txInterpretationQuery.data?.data.summaries[0] && checkInterpretationTemplate(txInterpretationQuery.data.data.summaries[0])));
const titleSecondRow = ( const titleSecondRow = (
<> <Box display={{ base: 'block', lg: 'flex' }} alignItems="center" w="100%">
<TxEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } mr={ 2 } fontFamily="heading"/> { hasInterpretationFeature && <TxInterpretation query={ txInterpretationQuery } mr={{ base: 0, lg: 6 }}/> }
{ !data?.tx_tag && <AccountActionsMenu mr={{ base: 0, lg: 3 }}/> } { !hasInterpretation && <TxEntity hash={ hash } noLink noCopy={ false } fontWeight={ 500 } mr={{ base: 0, lg: 2 }} fontFamily="heading"/> }
<NetworkExplorers type="tx" pathParam={ hash } ml={{ base: 3, lg: 'auto' }}/> <Flex alignItems="center" justifyContent={{ base: 'start', lg: 'space-between' }} flexGrow={ 1 }>
</> { !data?.tx_tag && <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>
); );
return ( return (
......
import { test, expect } from '@playwright/experimental-ct-react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { ResourceError } from 'lib/api/resources';
import { txInterpretation as txInterpretationMock } from 'mocks/txs/txInterpretation';
import TestApp from 'playwright/TestApp';
import TxInterpretation from './TxInterpretation';
test('base view +@mobile +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<TxInterpretation query={{ data: txInterpretationMock } as UseQueryResult<TxInterpretationResponse, ResourceError>}/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Skeleton, Text, Icon, chakra } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TxInterpretationResponse, TxInterpretationVariable } from 'types/api/txInterpretation';
import actionIcon from 'icons/action.svg';
import type { ResourceError } from 'lib/api/resources';
import dayjs from 'lib/date/dayjs';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import { extractVariables, getStringChunks } from './utils';
type Props = {
query: UseQueryResult<TxInterpretationResponse, ResourceError>;
className?: string;
}
const TxInterpretationElementByType = ({ type, value }: TxInterpretationVariable) => {
switch (type) {
case 'address':
return <AddressEntity address={ value } truncation="constant" sx={{ ':not(:first-child)': { marginLeft: 1 } }}/>;
case 'token':
return <TokenEntity token={ value } onlySymbol width="fit-content" sx={{ ':not(:first-child)': { marginLeft: 1 } }}/>;
case 'currency':
return <Text>{ BigNumber(value).toFormat() }</Text>;
case 'timestamp':
// timestamp is in unix format
return <Text>{ dayjs(Number(value) * 1000).format('llll') }</Text>;
case 'string':
default: {
return <Text>{ value.toString() }</Text>;
}
}
};
const TxInterpretation = ({ query, className }: Props) => {
if (!query.data?.data.summaries[0]) {
return null;
}
const template = query.data.data.summaries[0].summary_template;
const variables = query.data.data.summaries[0].summary_template_variables;
const variablesNames = extractVariables(template);
const chunks = getStringChunks(template);
return (
<Skeleton display="flex" flexWrap="wrap" alignItems="center" isLoaded={ !query.isPlaceholderData } className={ className }>
<Icon as={ actionIcon } boxSize={ 5 } color="text_secondary" mr={ 2 }/>
{ chunks.map((chunk, index) => {
return (
<>
<Text whiteSpace="pre">{ chunk }</Text>
{ index < chunks.length - 1 && <TxInterpretationElementByType { ...variables[variablesNames[index]] }/> }
</>
);
}) }
</Skeleton>
);
};
export default chakra(TxInterpretation);
import { extractVariables, checkTemplate } from './utils';
const template = '{action_type} {source_amount} Ether into {destination_amount} {destination_token}';
it('extracts variables names', () => {
const result = extractVariables(template);
expect(result).toEqual([ 'action_type', 'source_amount', 'destination_amount', 'destination_token' ]);
});
it('check template true', () => {
const variables = {
action_type: { type: 'string' as const, value: 'Wrap' },
source_amount: { type: 'currency' as const, value: '0.7' },
destination_amount: { type: 'currency' as const, value: '0.7' },
destination_token: {
type: 'token' as const,
value: {
name: 'Duck',
type: 'ERC-20' as const,
symbol: 'DUCK',
address: '0x486a3c5f34cDc4EF133f248f1C81168D78da52e8',
holders: '1152',
decimals: '18',
icon_url: null,
total_supply: '210000000000000000000000000',
exchange_rate: null,
circulating_market_cap: null,
},
},
};
const result = checkTemplate({ summary_template: template, summary_template_variables: variables });
expect(result).toBe(true);
});
it('check template false', () => {
const variables = {
action_type: { type: 'string' as const, value: 'Wrap' },
source_amount: { type: 'currency' as const, value: '0.7' },
destination_amount: { type: 'currency' as const, value: '0.7' },
};
const result = checkTemplate({ summary_template: template, summary_template_variables: variables });
expect(result).toBe(false);
});
import type { TxInterpretationSummary } from 'types/api/txInterpretation';
// 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 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);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function checkTemplate(summary: TxInterpretationSummary) {
const variablesNames = extractVariables(summary.summary_template);
for (const name of variablesNames) {
if (!summary.summary_template_variables[name]) {
return false;
}
}
return true;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment