Commit 8563cf20 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Add support for Ton Application Chains (#2722)

* operation lifecycle accordion

* operation entity

* adapt layout to mobile device

* basic operations view

* pass response data to operation details

* update types package

* tac operation status and links to tx on ton

* feature config, docs and operation badge

* add info to tx page

* add operations to search

* search input on operations page

* tests

* update tooltips and temporary fix of ts

* add InterFallback typeface with → glyph

* design and api updates

* support new API version

* add time format toggle

* add shield to entity icon

* minor fixes

* update screenshots

* clean up

* fix screenshot
parent 720fc065
......@@ -33,6 +33,7 @@ on:
- shibarium
- scroll_sepolia
- stability
- tac_turin
- zkevm
- zilliqa_prototestnet
- zksync
......
......@@ -383,6 +383,7 @@
"scroll_sepolia",
"shibarium",
"stability_testnet",
"tac_turin",
"zkevm",
"zilliqa_prototestnet",
"zksync",
......
......@@ -112,6 +112,17 @@ const statsApi = (() => {
});
})();
const tacApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST');
if (!apiHost) {
return;
}
return Object.freeze({
endpoint: apiHost,
});
})();
const visualizeApi = (() => {
const apiHost = getEnvValue('NEXT_PUBLIC_VISUALIZE_API_HOST');
if (!apiHost) {
......@@ -136,6 +147,7 @@ const apis: Apis = Object.freeze({
metadata: metadataApi,
rewards: rewardsApi,
stats: statsApi,
tac: tacApi,
visualize: visualizeApi,
});
......
......@@ -36,6 +36,7 @@ export { default as saveOnGas } from './saveOnGas';
export { default as sol2uml } from './sol2uml';
export { default as stats } from './stats';
export { default as suave } from './suave';
export { default as tac } from './tac';
export { default as txInterpretation } from './txInterpretation';
export { default as userOps } from './userOps';
export { default as addressProfileAPI } from './addressProfileAPI';
......
import type { Feature } from './types';
import apis from '../apis';
import { getEnvValue } from '../utils';
const title = 'Ton Application Chain (TAC)';
const tonExplorerUrl = getEnvValue('NEXT_PUBLIC_TAC_TON_EXPLORER_URL');
const config: Feature<{ explorerUrl: string }> = (() => {
if (apis.tac && tonExplorerUrl) {
return Object.freeze({
title,
isEnabled: true,
explorerUrl: tonExplorerUrl,
});
}
return Object.freeze({
title,
isEnabled: false,
});
})();
export default config;
......@@ -53,6 +53,7 @@ NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=http://localhost:3006
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=http://localhost:3007
NEXT_PUBLIC_NAME_SERVICE_API_HOST=http://localhost:3008
NEXT_PUBLIC_REWARDS_SERVICE_API_HOST=http://localhost:3009
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=http://localhost:3100
NEXT_PUBLIC_RE_CAPTCHA_APP_SITE_KEY=xxx
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=xxx
NEXT_PUBLIC_VIEWS_ADDRESS_FORMAT=['base16','bech32']
......
# Set of ENVs for TAC Turin network explorer
# https://tac-turin.blockscout.com
# This is an auto-generated file. To update all values, run "yarn dev:preset:sync --name=tac_turin"
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=https://tac-operation-lifecycle.k8s-dev.blockscout.com
NEXT_PUBLIC_TAC_TON_EXPLORER_URL=https://testnet.tonscan.org
# Local ENVs
NEXT_PUBLIC_APP_PROTOCOL=http
NEXT_PUBLIC_APP_HOST=localhost
NEXT_PUBLIC_APP_PORT=3000
NEXT_PUBLIC_APP_ENV=development
NEXT_PUBLIC_API_WEBSOCKET_PROTOCOL=ws
# Instance ENVs
NEXT_PUBLIC_ADMIN_SERVICE_API_HOST=https://admin-rs.services.blockscout.com
NEXT_PUBLIC_API_BASE_PATH=/
NEXT_PUBLIC_API_HOST=tac-turin.blockscout.com
NEXT_PUBLIC_API_SPEC_URL=https://raw.githubusercontent.com/blockscout/blockscout-api-v2-swagger/main/swagger.yaml
NEXT_PUBLIC_CONTRACT_INFO_API_HOST=https://contracts-info.services.blockscout.com
NEXT_PUBLIC_FOOTER_LINKS=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/footer-links/tac.json
NEXT_PUBLIC_GAME_BADGE_CLAIM_LINK=https://badges.blockscout.com/mint/sherblockHolmesBadge
NEXT_PUBLIC_GAS_TRACKER_UNITS=[ 'gwei' ]
NEXT_PUBLIC_GRAPHIQL_TRANSACTION=0x9df2e91d1eed5637f0ffb9423b1fe34ff477942c2a3e64cfa46a95be81892214
NEXT_PUBLIC_HOMEPAGE_CHARTS=['daily_txs']
NEXT_PUBLIC_HOMEPAGE_HERO_BANNER_CONFIG={'background':['no-repeat center/100% auto url(https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-skins/tac.jpg)'],'text_color':['rgba(242,235,255,1)'],'button':{'_default':{'background':['rgba(30,23,44,1)']},'_hover':{'background':['rgba(66,14,70,1)']}}}
NEXT_PUBLIC_IS_TESTNET=true
NEXT_PUBLIC_METADATA_SERVICE_API_HOST=https://metadata.services.blockscout.com
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=TAC
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=TAC
NEXT_PUBLIC_NETWORK_ICON=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/tac-light.svg
NEXT_PUBLIC_NETWORK_ICON_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-icons/tac-dark.svg
NEXT_PUBLIC_NETWORK_ID=2390
NEXT_PUBLIC_NETWORK_LOGO=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/tac-light.svg
NEXT_PUBLIC_NETWORK_LOGO_DARK=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/network-logos/tac-dark.svg
NEXT_PUBLIC_NETWORK_NAME=TAC Turin
NEXT_PUBLIC_NETWORK_RPC_URL=https://turin.rpc.tac.build
NEXT_PUBLIC_NETWORK_SHORT_NAME=TAC Turin
NEXT_PUBLIC_OG_ENHANCED_DATA_ENABLED=true
NEXT_PUBLIC_OG_IMAGE_URL=https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/og-images/tac-turin.png
NEXT_PUBLIC_STATS_API_BASE_PATH=/stats-service
NEXT_PUBLIC_STATS_API_HOST=https://tac-turin.blockscout.com
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER=blockscout
NEXT_PUBLIC_VIEWS_TOKEN_SCAM_TOGGLE_ENABLED=true
NEXT_PUBLIC_VISUALIZE_API_HOST=https://visualizer.services.blockscout.com
\ No newline at end of file
......@@ -279,6 +279,23 @@ const beaconChainSchema = yup
}),
});
const tacSchema = yup
.object()
.shape({
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST: yup.string().test(urlTest),
NEXT_PUBLIC_TAC_TON_EXPLORER_URL: yup
.string()
.when('NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST', {
is: (value: string) => Boolean(value),
then: (schema) => schema.test(urlTest),
otherwise: (schema) => schema.test(
'not-exist',
'NEXT_PUBLIC_TAC_TON_EXPLORER_URL can only be used with NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST',
value => value === undefined,
),
}),
});
const parentChainCurrencySchema = yup
.object()
.shape({
......@@ -1088,6 +1105,7 @@ const schema = yup
.concat(celoSchema)
.concat(beaconChainSchema)
.concat(bridgedTokensSchema)
.concat(sentrySchema);
.concat(sentrySchema)
.concat(tacSchema);
export default schema;
NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST=https://tac-operation-lifecycle.blockscout.com
NEXT_PUBLIC_TAC_TON_EXPLORER_URL=https://tonscan.org
\ No newline at end of file
......@@ -63,6 +63,8 @@ All json-like values should be single-quoted. If it contains a hash (`#`) or a d
- [Address profile API](ENVS.md#address-profile-api)
- [Address XStar XHS score](ENVS.md#address-xstar-xhs-score)
- [SUAVE chain](ENVS.md#suave-chain)
- [Celo chain](ENVS.md#celo-chain)
- [Ton Application Chain (TAC)](ENVS.md#ton-application-chain-tac)
- [MetaSuites extension](ENVS.md#metasuites-extension)
- [Validators list](ENVS.md#validators-list)
- [Sentry error monitoring](ENVS.md#sentry-error-monitoring)
......@@ -773,6 +775,17 @@ For blockchains that use the Celo platform. _Note_, that once the Celo mainnet b
&nbsp;
### Ton Application Chain (TAC)
For Ton Application Chains, this feature enables additional views, such as a list of cross-chain operations and a detailed page for a specific cross-chain operation, as well as extra fields on the transaction page.
| Variable | Type| Description | Compulsoriness | Default value | Example value | Version |
| --- | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST | `string` | URL for the TAC Operation Lifecycle service. | Required | - | `https://tac-operation-lifecycle.blockscout.com` | v2.1.0+ |
| NEXT_PUBLIC_TAC_TON_EXPLORER_URL | `string` | URL of the Ton chain explorer. This is used to build links to transactions and addresses on the Ton chain. | Required | - | `https://tonscan.org` | v2.1.0+ |
&nbsp;
### MetaSuites extension
Enables [MetaSuites browser extension](https://github.com/blocksecteam/metasuites) to integrate with the app views.
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="#91019B" fill-rule="evenodd" d="M20 10c0 5.523-4.477 10-10 10S0 15.523 0 10 4.477 0 10 0s10 4.477 10 10Zm-9.598 6.938V7.425c0-.231.187-.418.418-.418h5.214c.533 0 .862.58.589 1.037l-5.445 9.109c-.218.364-.776.21-.776-.215Zm-.804-4.373V3.05c0-.424-.559-.578-.776-.214l-5.445 9.108a.686.686 0 0 0 .589 1.037H9.18a.418.418 0 0 0 .418-.417Z" clip-rule="evenodd"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="#0098EA" d="M10 20c5.523 0 10-4.477 10-10S15.523 0 10 0 0 4.477 0 10s4.477 10 10 10Z"/>
<path fill="#fff" d="M13.414 5.581H6.585c-1.255 0-2.051 1.355-1.42 2.45l4.215 7.305a.716.716 0 0 0 1.24 0l4.215-7.305c.63-1.093-.165-2.45-1.42-2.45Zm-4.037 7.564-.918-1.776-2.215-3.961a.387.387 0 0 1 .34-.579h2.792v6.317Zm4.377-5.738L11.54 11.37l-.918 1.775V6.828h2.792c.306 0 .486.325.34.579Z"/>
</svg>
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.7 4a3.3 3.3 0 0 1 .99 6.447v9.105A3.301 3.301 0 0 1 21.7 26a3.3 3.3 0 0 1-3.26-3.809L8.502 17.46a3.297 3.297 0 0 1-2.203.84A3.29 3.29 0 0 1 3 15.019l.004-.17A3.293 3.293 0 0 1 6.3 11.735l.17.005a3.299 3.299 0 0 1 2.355 1.166l9.666-4.834A3.3 3.3 0 0 1 21.7 4Zm0 17.38a1.32 1.32 0 1 0 0 2.641 1.32 1.32 0 0 0 0-2.641ZM9.585 14.74c.003.036.009.072.01.109l.005.17c0 .251-.03.496-.084.731l9.802 4.667c.38-.396.856-.697 1.392-.865v-9.105a3.293 3.293 0 0 1-1.193-.675L9.585 14.74ZM6.3 13.715c-.74 0-1.32.594-1.32 1.303 0 .708.58 1.301 1.32 1.301.739 0 1.32-.593 1.32-1.301 0-.71-.581-1.303-1.32-1.303Zm15.4-7.737a1.32 1.32 0 1 0 0 2.642 1.32 1.32 0 0 0 0-2.642Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 0a3 3 0 0 1 .9 5.862v8.275A3.002 3.002 0 0 1 17 20a3 3 0 0 1-2.965-3.463L5 12.234A2.993 2.993 0 0 1 3 13c-1.657 0-3-1.336-3-2.983l.004-.155A2.993 2.993 0 0 1 3 7.032l.154.004a3 3 0 0 1 2.142 1.06L14.085 3.7A3 3 0 0 1 17 0Zm0 15.8a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4ZM5.985 9.763c.003.033.01.066.011.1l.004.154c0 .228-.028.45-.077.664l8.913 4.244a3 3 0 0 1 1.264-.788V5.862a2.992 2.992 0 0 1-1.083-.615L5.985 9.763ZM3 8.832c-.672 0-1.2.54-1.2 1.185 0 .644.528 1.183 1.2 1.183.672 0 1.2-.539 1.2-1.183 0-.645-.528-1.185-1.2-1.185ZM17 1.8a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x=".8" y=".8" width="18.4" height="18.4" rx="5.2" stroke="currentColor" stroke-width="1.6"/>
<path d="M9.293 8.182a1 1 0 0 0 1.414 0l2.627-2.627a.786.786 0 1 1 1.11 1.111l-2.626 2.627a1 1 0 0 0 0 1.414l2.627 2.627a.785.785 0 1 1-1.111 1.11l-2.627-2.626a1 1 0 0 0-1.414 0l-2.627 2.627a.786.786 0 1 1-1.11-1.111l2.626-2.627a1 1 0 0 0 0-1.414L5.555 6.666a.786.786 0 1 1 1.111-1.11l2.627 2.626Z" fill="currentColor"/>
</svg>
......@@ -14,6 +14,12 @@ import type { RewardsApiResourceName, RewardsApiResourcePayload } from './servic
import { REWARDS_API_RESOURCES } from './services/rewards';
import type { StatsApiResourceName, StatsApiResourcePayload } from './services/stats';
import { STATS_API_RESOURCES } from './services/stats';
import { TAC_OPERATION_LIFECYCLE_API_RESOURCES } from './services/tac-operation-lifecycle';
import type {
TacOperationLifecycleApiPaginationFilters,
TacOperationLifecycleApiResourceName,
TacOperationLifecycleApiResourcePayload,
} from './services/tac-operation-lifecycle';
import type { IsPaginated } from './services/utils';
import { VISUALIZE_API_RESOURCES } from './services/visualize';
import type { VisualizeApiResourceName, VisualizeApiResourcePayload } from './services/visualize';
......@@ -26,6 +32,7 @@ export const RESOURCES = {
metadata: METADATA_API_RESOURCES,
rewards: REWARDS_API_RESOURCES,
stats: STATS_API_RESOURCES,
tac: TAC_OPERATION_LIFECYCLE_API_RESOURCES,
visualize: VISUALIZE_API_RESOURCES,
} satisfies Record<ApiName, Record<string, ApiResource>>;
......@@ -46,6 +53,7 @@ R extends GeneralApiResourceName ? GeneralApiResourcePayload<R> :
R extends MetadataApiResourceName ? MetadataApiResourcePayload<R> :
R extends RewardsApiResourceName ? RewardsApiResourcePayload<R> :
R extends StatsApiResourceName ? StatsApiResourcePayload<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiResourcePayload<R> :
R extends VisualizeApiResourceName ? VisualizeApiResourcePayload<R> :
never;
/* eslint-enable @stylistic/indent */
......@@ -77,6 +85,7 @@ export type PaginationFilters<R extends ResourceName> =
R extends BensApiResourceName ? BensApiPaginationFilters<R> :
R extends GeneralApiResourceName ? GeneralApiPaginationFilters<R> :
R extends ContractInfoApiResourceName ? ContractInfoApiPaginationFilters<R> :
R extends TacOperationLifecycleApiResourceName ? TacOperationLifecycleApiPaginationFilters<R> :
never;
/* eslint-enable @stylistic/indent */
......
import type { ApiResource } from '../types';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
export const TAC_OPERATION_LIFECYCLE_API_RESOURCES = {
operations: {
path: '/api/v1/tac/operations',
paginated: true,
filterFields: [ 'q' ],
},
operation: {
path: '/api/v1/tac/operations/:id',
pathParams: [ 'id' ],
},
operation_by_tx_hash: {
path: '/api/v1/tac/operations\\:byTx/:tx_hash',
pathParams: [ 'tx_hash' ],
},
stat_operations: {
path: '/api/v1/stat/operations',
},
} satisfies Record<string, ApiResource>;
export type TacOperationLifecycleApiResourceName = `tac:${ keyof typeof TAC_OPERATION_LIFECYCLE_API_RESOURCES }`;
/* eslint-disable @stylistic/indent */
export type TacOperationLifecycleApiResourcePayload<R extends TacOperationLifecycleApiResourceName> =
R extends 'tac:operations' ? tac.OperationsResponse :
R extends 'tac:operation' ? tac.OperationDetails :
R extends 'tac:operation_by_tx_hash' ? tac.OperationsFullResponse :
R extends 'tac:stat_operations' ? tac.GetOperationStatisticsResponse :
never;
/* eslint-enable @stylistic/indent */
/* eslint-disable @stylistic/indent */
export type TacOperationLifecycleApiPaginationFilters<R extends TacOperationLifecycleApiResourceName> =
R extends 'tac:operations' ? { q: string } :
never;
/* eslint-enable @stylistic/indent */
export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize';
export type ApiName = 'general' | 'admin' | 'bens' | 'contractInfo' | 'metadata' | 'rewards' | 'stats' | 'visualize' | 'tac';
export interface ApiResource {
path: string;
......
......@@ -44,6 +44,12 @@ export default function useNavItems(): ReturnType {
icon: 'transactions',
isActive: pathname === '/txs' || pathname === '/tx/[hash]',
};
const operations: NavItem | null = config.features.tac.isEnabled ? {
text: 'Operations',
nextRoute: { pathname: '/operations' as const },
icon: 'operation',
isActive: pathname === '/operations' || pathname === '/operation/[id]',
} : null;
const internalTxs: NavItem | null = {
text: 'Internal transactions',
nextRoute: { pathname: '/internal-txs' as const },
......@@ -186,6 +192,7 @@ export default function useNavItems(): ReturnType {
} else {
blockchainNavItems = [
txs,
operations,
internalTxs,
userOps,
blocks,
......
......@@ -61,6 +61,8 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/pools': 'Root page',
'/pools/[hash]': 'Regular page',
'/interop-messages': 'Root page',
'/operations': 'Root page',
'/operation/[id]': 'Regular page',
// service routes, added only to make typescript happy
'/login': 'Regular page',
......
......@@ -64,6 +64,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/pools': DEFAULT_TEMPLATE,
'/pools/[hash]': DEFAULT_TEMPLATE,
'/interop-messages': DEFAULT_TEMPLATE,
'/operations': DEFAULT_TEMPLATE,
'/operation/[id]': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE,
......
......@@ -61,6 +61,8 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/pools': '%network_name% DEX pools',
'/pools/[hash]': '%network_name% pool details',
'/interop-messages': '%network_name% interop messages',
'/operations': '%network_name% operations',
'/operation/[id]': '%network_name% operation %id%',
// service routes, added only to make typescript happy
'/login': '%network_name% login',
......
......@@ -59,6 +59,8 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/pools': 'DEX pools',
'/pools/[hash]': 'Pool details',
'/interop-messages': 'Interop messages',
'/operations': 'Operations',
'/operation/[id]': 'Operation details',
// service routes, added only to make typescript happy
'/login': 'Login',
......
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { rightLineArrow } from 'toolkit/utils/htmlEntities';
export function getTacOperationStatus(type: tac.OperationType) {
switch (type) {
case tac.OperationType.TON_TAC_TON:
return `TON ${ rightLineArrow } TAC ${ rightLineArrow } TON`;
case tac.OperationType.TAC_TON:
return `TAC ${ rightLineArrow } TON`;
case tac.OperationType.TON_TAC:
return `TON ${ rightLineArrow } TAC`;
case tac.OperationType.ERROR:
return 'Error';
case tac.OperationType.ROLLBACK:
return 'Rollback';
case tac.OperationType.PENDING:
return 'Pending';
default:
return null;
}
}
export function getTacOperationStage(data: tac.OperationDetails, txHash: string) {
const currentStep = data.status_history.find((step) => step.transactions.some((tx) => tx.hash.toLowerCase() === txHash.toLowerCase()));
if (!currentStep) {
return null;
}
return STATUS_LABELS[currentStep.type];
}
export const STATUS_SEQUENCE: Array<tac.OperationStage_StageType> = [
tac.OperationStage_StageType.COLLECTED_IN_TAC,
tac.OperationStage_StageType.INCLUDED_IN_TAC_CONSENSUS,
tac.OperationStage_StageType.EXECUTED_IN_TAC,
tac.OperationStage_StageType.COLLECTED_IN_TON,
tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS,
tac.OperationStage_StageType.EXECUTED_IN_TON,
];
export const STATUS_LABELS: Record<tac.OperationStage_StageType, string> = {
[tac.OperationStage_StageType.COLLECTED_IN_TAC]: 'Collected in TAC',
[tac.OperationStage_StageType.INCLUDED_IN_TAC_CONSENSUS]: 'Included in TAC consensus',
[tac.OperationStage_StageType.EXECUTED_IN_TAC]: 'Executed in TAC',
[tac.OperationStage_StageType.COLLECTED_IN_TON]: 'Collected in TON',
[tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS]: 'Included in TON consensus',
[tac.OperationStage_StageType.EXECUTED_IN_TON]: 'Executed in TON',
[tac.OperationStage_StageType.UNRECOGNIZED]: 'Unknown',
};
export const sortStatusHistory = (a: tac.OperationStage, b: tac.OperationStage) => {
const aIndex = STATUS_SEQUENCE.indexOf(a.type);
const bIndex = STATUS_SEQUENCE.indexOf(b.type);
return aIndex - bIndex;
};
import * as tac from '@blockscout/tac-operation-lifecycle-types';
export const tacOperation: tac.OperationDetails = {
operation_id: '0x35f5d9c2bf07477ede48935c7130945faf17a3e5f69a7d20ce3725676513095c',
type: tac.OperationType.TON_TAC_TON,
timestamp: '2025-05-08T07:20:05.000Z',
sender: {
address: 'EQBnVg4x6uTCa8jlrh8YXyWpnJJ3oxxrdBQ2+Zw8yaoxnXTt',
blockchain: tac.BlockchainType.TON,
},
status_history: [
{
type: tac.OperationStage_StageType.COLLECTED_IN_TON,
is_exist: true,
is_success: true,
timestamp: '2025-05-08T07:20:05.000Z',
transactions: [
{
hash: '0x77e3c6bef84681157dda17dec60f680a1ff6caaedec2e94c23f4ec44aa62aba8',
type: tac.BlockchainType.TON,
},
],
note: undefined,
},
{
type: tac.OperationStage_StageType.INCLUDED_IN_TON_CONSENSUS,
is_exist: true,
is_success: true,
timestamp: '2025-05-08T07:25:35.000Z',
transactions: [
{
hash: '0xafc8a8e04739b4996e9b5ef6c91673fb421d00ed42be4404d6fca6a915899235',
type: tac.BlockchainType.TAC,
},
{
hash: '0xafc8a8e04739b4996e9b5ef6c91673fb421d00ed42be4404d6fca6a915899236',
type: tac.BlockchainType.TON,
},
],
note: undefined,
},
{
type: tac.OperationStage_StageType.EXECUTED_IN_TON,
is_exist: true,
is_success: false,
timestamp: '2025-05-08T07:26:14.000Z',
transactions: [
{
hash: '0xa9c6087ee95ede3cb0bcba7119a7f7b0ee3fc91d04faa1bb1ecd94ed83ef8161',
type: tac.BlockchainType.TAC,
},
],
note: 'ProxyCallError: UniswapV2Router: Insufficient output amount',
},
],
};
......@@ -9,8 +9,11 @@ import type {
SearchResultBlob,
SearchResultDomain,
SearchResultMetadataTag,
SearchResultTacOperation,
} from 'types/api/search';
import * as tacOperationMock from 'mocks/operations/tac';
export const token1: SearchResultToken = {
address_hash: '0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
address_url: '/address/0x377c5F2B300B25a534d4639177873b7fEAA56d4B',
......@@ -184,6 +187,11 @@ export const metatag3: SearchResultMetadataTag = {
},
};
export const tacOperation1: SearchResultTacOperation = {
type: 'tac_operation',
tac_operation: tacOperationMock.tacOperation,
};
export const baseResponse: SearchResult = {
items: [
token1,
......@@ -195,7 +203,7 @@ export const baseResponse: SearchResult = {
blob1,
domain1,
metatag1,
tacOperation1,
],
next_page_params: null,
};
......@@ -109,6 +109,7 @@ export function app(): CspDev.DirectiveDescriptor {
'font-src': [
KEY_WORDS.DATA,
KEY_WORDS.SELF,
...MAIN_DOMAINS,
...(externalFontsDomains || []),
],
......
......@@ -380,6 +380,16 @@ export const mud: GetServerSideProps<Props> = async(context) => {
return base(context);
};
export const tac: GetServerSideProps<Props> = async(context) => {
if (!config.features.tac.isEnabled) {
return {
notFound: true,
};
}
return base(context);
};
export const interopMessages: GetServerSideProps<Props> = async(context) => {
const rollupFeature = config.features.rollup;
if (!rollupFeature.isEnabled || !rollupFeature.interopEnabled) {
......
@font-face {
font-family: 'InterFallback';
src: url('/static/fonts/Inter-fallback.woff2') format('woff2');
unicode-range: U+2192; /* Only the right arrow */
font-display: swap;
}
\ No newline at end of file
......@@ -53,6 +53,8 @@ declare module "nextjs-routes" {
| DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains">
| DynamicRoute<"/op/[hash]", { "hash": string }>
| DynamicRoute<"/operation/[id]", { "id": string }>
| StaticRoute<"/operations">
| StaticRoute<"/ops">
| StaticRoute<"/output-roots">
| DynamicRoute<"/pools/[hash]", { "hash": string }>
......
......@@ -30,6 +30,7 @@ import Web3ModalProvider from 'ui/shared/Web3ModalProvider';
import 'lib/setLocale';
// import 'focus-visible/dist/focus-visible';
import 'nextjs/global.css';
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout;
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import type { Props } from 'nextjs/getServerSideProps';
import PageNextJs from 'nextjs/PageNextJs';
const TacOperation = dynamic(() => import('ui/pages/TacOperation'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/operation/[id]" query={ props.query }>
<TacOperation/>
</PageNextJs>
);
};
export default Page;
export { tac as getServerSideProps } from 'nextjs/getServerSideProps';
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const TacOperations = dynamic(() => import('ui/pages/TacOperations'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/operations">
<TacOperations/>
</PageNextJs>
);
};
export default Page;
export { tac as getServerSideProps } from 'nextjs/getServerSideProps';
......@@ -105,4 +105,8 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
[ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ],
[ 'NEXT_PUBLIC_INTEROP_ENABLED', 'true' ],
],
tac: [
[ 'NEXT_PUBLIC_TAC_OPERATION_LIFECYCLE_API_HOST', 'http://localhost:3100' ],
[ 'NEXT_PUBLIC_TAC_TON_EXPLORER_URL', 'https://testnet.tonscan.org' ],
],
};
import './fonts.css';
import './index.css';
import '../nextjs/global.css';
import { beforeMount } from '@playwright/experimental-ct-react/hooks';
import MockDate from 'mockdate';
import * as router from 'next/router';
......
......@@ -29,6 +29,8 @@
| "brands/graph"
| "brands/safe"
| "brands/solidity_scan"
| "brands/tac"
| "brands/ton"
| "burger"
| "certified"
| "check"
......@@ -117,6 +119,8 @@
| "networks/logo-placeholder"
| "nft_shield"
| "open-link"
| "operation_slim"
| "operation"
| "output_roots"
| "payment_link"
| "plus"
......@@ -182,6 +186,7 @@
| "user_op_slim"
| "user_op"
| "validator"
| "verification-steps/error"
| "verification-steps/finalized"
| "verification-steps/unfinalized"
| "verified_slim"
......
This diff is collapsed.
To add new glyphs to the Inter fallback font:
1. Download and install [FontForge](https://fontforge.org/en-US/downloads/mac-dl/).
2. Download the [Inter](https://rsms.me/inter/) typefaces.
3. In FontForge, open the Inter Regular typeface from the web directory.
4. Find the desired glyph (e.g., a missing symbol or character).
5. Copy the glyph (Edit > Copy or Cmd+C).
6. Open the `Inter-fallback.sfd` file in FontForge.
7. Go to the correct Unicode slot for the glyph (use Encoding > Goto or right-click > Goto, then enter the Unicode value).
8. Paste the glyph into the slot (Edit > Paste or Cmd+V).
9. Generate a new .ttf file
10. To use [pyftsubset](https://fonttools.readthedocs.io/en/latest/subset/index.html) tool run `brew install fonttools`
11. Adjust the range of included glyphs and run the script
```bash
pyftsubset <path-to-ttf-file> \
--output-file=./public/static/fonts/Inter-fallback.woff2 \
--flavor=woff2 \
--unicodes=U+2192
```
\ No newline at end of file
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { ADDRESS_HASH } from './addressParams';
export const TAC_OPERATION: tac.OperationBriefDetails = {
operation_id: '0x4d3d36b7fcab0a2f93f24bf313ebfe9cc0b2c7157d2aef7e7f7d5835528428c6',
type: tac.OperationType.PENDING,
timestamp: '2025-05-05T12:32:22.000Z',
sender: {
address: '0x4d3d36b7fcab0a2f93f24bf313ebfe9cc0b2c7157d2aef7e7f7d5835528428c6',
blockchain: tac.BlockchainType.TAC,
},
};
export const TAC_OPERATION_DETAILS: tac.OperationDetails = {
operation_id: '0x6e7cdeea3f39e7664597a44ddb33ce47ba061cbee2992e2c7b0e3f9294ff8b30',
type: tac.OperationType.PENDING,
timestamp: '2025-05-05T12:32:22.000Z',
sender: {
address: ADDRESS_HASH,
blockchain: tac.BlockchainType.TAC,
},
status_history: [
{
type: tac.OperationStage_StageType.COLLECTED_IN_TAC,
is_exist: true,
is_success: true,
timestamp: '2025-05-05T12:32:22.000Z',
transactions: [
{
hash: '0x064e57a9f43d032ac0c1cb0d7883b0d783a9fa5d207a39563a6ed06c5dc17622',
type: tac.BlockchainType.TON,
},
],
note: undefined,
},
],
};
......@@ -4,7 +4,7 @@ import type { ExcludeUndefined } from 'types/utils';
import config from 'configs/app';
export const BODY_TYPEFACE = config.UI.fonts.body?.name ?? 'Inter';
export const BODY_TYPEFACE = config.UI.fonts.body?.name ?? 'Inter, InterFallback';
export const HEADING_TYPEFACE = config.UI.fonts.heading?.name ?? 'Poppins';
export const fonts: ExcludeUndefined<ThemingConfig['tokens']>['fonts'] = {
......
import type { SystemConfig } from '@chakra-ui/react';
import addressEntity from './globals/address-entity';
import entity from './globals/entity';
import recaptcha from './globals/recaptcha';
import scrollbar from './globals/scrollbar';
......@@ -51,6 +52,7 @@ const globalCss: SystemConfig['globalCss'] = {
},
...recaptcha,
...scrollbar,
...entity,
...addressEntity,
};
......
......@@ -18,6 +18,10 @@ const styles = {
bgColor: 'address.highlighted.bg',
zIndex: -1,
},
'& .entity__shield': {
borderColor: 'address.highlighted.bg',
bgColor: 'address.highlighted.bg',
},
},
},
'.address-entity_no-copy': {
......
const styles = {
'.entity__shield': {
bgColor: 'global.body.bg',
borderColor: 'global.body.bg',
},
};
export default styles;
......@@ -26,6 +26,7 @@ const PRESETS = {
scroll_sepolia: 'https://scroll-sepolia.blockscout.com',
shibarium: 'https://www.shibariumscan.io',
stability_testnet: 'https://stability-testnet.blockscout.com',
tac_turin: 'https://tac-turin.blockscout.com',
zkevm: 'https://zkevm.blockscout.com',
zksync: 'https://zksync.blockscout.com',
zilliqa_prototestnet: 'https://zilliqa-prototestnet.blockscout.com',
......
import type * as bens from '@blockscout/bens-types';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import type { TokenType } from 'types/api/token';
import type { AddressMetadataTagApi } from './addressMetadata';
......@@ -14,6 +15,7 @@ export const SEARCH_RESULT_TYPES = {
user_operation: 'user_operation',
blob: 'blob',
metadata_tag: 'metadata_tag',
tac_operation: 'tac_operation',
} as const;
export type SearchResultType = typeof SEARCH_RESULT_TYPES[keyof typeof SEARCH_RESULT_TYPES];
......@@ -56,6 +58,11 @@ export interface SearchResultAddressOrContract extends SearchResultAddressData {
ens_info?: SearchResultEnsInfo;
}
export interface SearchResultTacOperation {
type: 'tac_operation';
tac_operation: tac.OperationDetails;
}
export interface SearchResultMetadataTag extends SearchResultAddressData {
type: 'metadata_tag';
ens_info?: SearchResultEnsInfo;
......@@ -120,7 +127,8 @@ export type SearchResultItem =
SearchResultUserOp |
SearchResultBlob |
SearchResultDomain |
SearchResultMetadataTag;
SearchResultMetadataTag |
SearchResultTacOperation;
export interface SearchResult {
items: Array<SearchResultItem>;
......
import { Grid } from '@chakra-ui/react';
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { sortStatusHistory } from 'lib/operations/tac';
import * as DetailedInfo from 'ui/shared/DetailedInfo/DetailedInfo';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import AddressEntityTacTon from 'ui/shared/entities/address/AddressEntityTacTon';
import TacOperationLifecycleAccordion from './TacOperationLifecycleAccordion';
interface Props {
isLoading?: boolean;
data: tac.OperationDetails;
}
const TacOperationDetails = ({ isLoading, data }: Props) => {
const statusHistory = data.status_history.filter((item) => item.is_exist).sort(sortStatusHistory);
return (
<Grid
columnGap={ 8 }
rowGap={ 3 }
templateColumns={{ base: 'minmax(0, 1fr)', lg: '210px minmax(728px, auto)' }}
>
{ data?.sender && (
<>
<DetailedInfo.ItemLabel
hint="The address on the source chain that starts a cross‑chain operation"
isLoading={ isLoading }
>
Sender
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<AddressEntityTacTon
address={{ hash: data.sender.address }}
chainType={ data.sender.blockchain }
isLoading={ isLoading }
/>
</DetailedInfo.ItemValue>
</>
) }
{ data.timestamp && (
<>
<DetailedInfo.ItemLabel
hint="Block time on the source chain when a cross‑chain operation is formed and sent"
isLoading={ isLoading }
>
Timestamp
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<DetailedInfoTimestamp timestamp={ data.timestamp } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
</>
) }
{ statusHistory.length > 0 && (
<>
<DetailedInfo.ItemLabel
hint="Stages of a cross‑chain operation"
isLoading={ isLoading }
>
Lifecycle
</DetailedInfo.ItemLabel>
<DetailedInfo.ItemValue>
<TacOperationLifecycleAccordion data={ statusHistory } isLoading={ isLoading }/>
</DetailedInfo.ItemValue>
</>
) }
</Grid>
);
};
export default React.memo(TacOperationDetails);
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { AccordionItem, AccordionRoot } from 'toolkit/chakra/accordion';
import TacOperationLifecycleAccordionItemContent from './TacOperationLifecycleAccordionItemContent';
import TacOperationLifecycleAccordionItemTrigger from './TacOperationLifecycleAccordionItemTrigger';
interface Props {
data: tac.OperationDetails['status_history'];
isLoading?: boolean;
}
const TacOperationLifecycleAccordion = ({ data, isLoading }: Props) => {
return (
<AccordionRoot maxW="800px" display="flex" flexDirection="column" rowGap={ 6 } lazyMount>
{ data.map((item, index) => {
const isLast = index === data.length - 1;
return (
<AccordionItem key={ index } value={ item.type } borderBottomWidth="0px">
<TacOperationLifecycleAccordionItemTrigger
status={ item.type }
isFirst={ index === 0 }
isLast={ isLast }
isLoading={ isLoading }
isSuccess={ item.is_success ?? false }
/>
<TacOperationLifecycleAccordionItemContent
isLast={ isLast }
data={ item }
/>
</AccordionItem>
);
}) }
</AccordionRoot>
);
};
export default React.memo(TacOperationLifecycleAccordion);
import { AccordionItemContent, Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import DetailedInfoTimestamp from 'ui/shared/DetailedInfo/DetailedInfoTimestamp';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityTon from 'ui/shared/entities/tx/TxEntityTon';
import StatusTag from 'ui/shared/statusTag/StatusTag';
interface Props {
isLast: boolean;
data: tac.OperationStage;
}
const TacOperationLifecycleAccordionItemContent = ({ isLast, data }: Props) => {
return (
<AccordionItemContent
ml={{ base: 0, lg: '9px' }}
pl={{ base: 0, lg: '17px' }}
pt={ 2 }
borderLeftWidth={{ base: 0, lg: isLast ? undefined : '2px' }}
borderColor="border.divider"
>
<Grid
gridTemplateColumns="112px 1fr"
alignItems="flex-start"
columnGap={ 3 }
rowGap={ 1 }
bgColor={{ _light: 'blackAlpha.50', _dark: 'whiteAlpha.50' }}
p="6px"
pl="18px"
textStyle="sm"
borderBottomLeftRadius="base"
borderBottomRightRadius="base"
>
<GridItem color="text.secondary" py="6px">
Status
</GridItem>
<GridItem py={ 1 }>
<StatusTag type={ data.is_success ? 'ok' : 'error' } text={ data.is_success ? 'Success' : 'Failed' }/>
</GridItem>
{ data.timestamp && (
<>
<GridItem color="text.secondary" py="6px">
Timestamp
</GridItem>
<GridItem
display="inline-flex"
flexWrap="wrap"
alignItems="center"
py="6px"
>
<DetailedInfoTimestamp timestamp={ data.timestamp } noIcon isLoading={ false }/>
</GridItem>
</>
) }
<GridItem color="text.secondary" py="6px">
Transactions
</GridItem>
<GridItem
display="flex"
flexDirection="column"
rowGap={ 3 }
py="6px"
width="100%"
overflow="hidden"
>
{
data.transactions.map((tx) => {
if (tx.type === tac.BlockchainType.TON) {
return <TxEntityTon key={ tx.hash } hash={ tx.hash } noCopy={ false }/>;
}
return <TxEntity key={ tx.hash } hash={ tx.hash } icon={{ name: 'brands/tac' }} noCopy={ false }/>;
})
}
</GridItem>
{ data.note && (
<>
<GridItem color="text.secondary" py="6px">
Note
</GridItem>
<GridItem
display="inline-flex"
alignItems="center"
py="6px"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{ data.note }
</GridItem>
</>
) }
</Grid>
</AccordionItemContent>
);
};
export default React.memo(TacOperationLifecycleAccordionItemContent);
import { HStack } from '@chakra-ui/react';
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { STATUS_LABELS } from 'lib/operations/tac';
import { AccordionItemTrigger } from 'toolkit/chakra/accordion';
import { Skeleton } from 'toolkit/chakra/skeleton';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
status: tac.OperationStage_StageType;
isFirst: boolean;
isLast: boolean;
isLoading?: boolean;
isSuccess: boolean;
}
const TacOperationLifecycleAccordionItemTrigger = ({ status, isFirst, isLast, isSuccess, isLoading }: Props) => {
return (
<AccordionItemTrigger
position="relative"
pt={ isFirst ? 0 : 1 }
pb={ 1 }
_before={ !isFirst ? {
position: 'absolute',
left: '9px',
bottom: 'calc(100% - 6px)',
width: '0',
height: '30px',
borderColor: 'border.divider',
borderLeftWidth: '2px',
content: '""',
} : undefined }
_after={ !isLast ? {
position: 'absolute',
left: '9px',
top: 'calc(100% - 6px)',
width: '0',
height: '6px',
borderColor: 'border.divider',
borderLeftWidth: '2px',
content: '""',
} : undefined }
_open={{
_after: {
height: { base: '14px', lg: '6px' },
},
}}
disabled={ isLoading }
noIndicator={ isLoading }
>
<HStack gap={ 2 } color={ isSuccess ? 'green.500' : 'red.600' }>
<IconSvg name={ isSuccess ? 'verification-steps/finalized' : 'verification-steps/error' } boxSize={ 5 } isLoading={ isLoading }/>
<Skeleton loading={ isLoading }>
{ STATUS_LABELS[status] }
</Skeleton>
</HStack>
</AccordionItemTrigger>
);
};
export default React.memo(TacOperationLifecycleAccordionItemTrigger);
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import AddressEntityTacTon from 'ui/shared/entities/address/AddressEntityTacTon';
import OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
import TacOperationStatus from 'ui/shared/statusTag/TacOperationStatus';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
type Props = { item: tac.OperationBriefDetails; isLoading?: boolean };
const TacOperationsListItem = ({ item, isLoading }: Props) => {
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Operation</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<OperationEntity
id={ item.operation_id }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Age</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TimeWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Status</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<TacOperationStatus status={ item.type } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
{ item.sender && (
<>
<ListItemMobileGrid.Label isLoading={ isLoading }>Sender</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<AddressEntityTacTon
address={{ hash: item.sender.address }}
chainType={ item.sender.blockchain }
isLoading={ isLoading }
/>
</ListItemMobileGrid.Value>
</>
) }
</ListItemMobileGrid.Container>
);
};
export default TacOperationsListItem;
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { AddressHighlightProvider } from 'lib/contexts/addressHighlight';
import { TableBody, TableColumnHeader, TableHeaderSticky, TableRoot, TableRow } from 'toolkit/chakra/table';
import TimeFormatToggle from 'ui/shared/time/TimeFormatToggle';
import TacOperationsTableItem from './TacOperationsTableItem';
type Props = {
items: Array<tac.OperationBriefDetails>;
isLoading?: boolean;
};
const TacOperationsTable = ({ items, isLoading }: Props) => {
return (
<AddressHighlightProvider>
<TableRoot minW="950px">
<TableHeaderSticky top={ 68 }>
<TableRow>
<TableColumnHeader w="100%">Operation</TableColumnHeader>
<TableColumnHeader w="200px">
Timestamp
<TimeFormatToggle/>
</TableColumnHeader>
<TableColumnHeader w="200px">Status</TableColumnHeader>
<TableColumnHeader w="250px">Sender</TableColumnHeader>
</TableRow>
</TableHeaderSticky>
<TableBody>
{ items.map((item, index) => (
<TacOperationsTableItem key={ String(item.operation_id) + (isLoading ? index : '') } item={ item } isLoading={ isLoading }/>
)) }
</TableBody>
</TableRoot>
</AddressHighlightProvider>
);
};
export default TacOperationsTable;
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { TableCell, TableRow } from 'toolkit/chakra/table';
import AddressEntityTacTon from 'ui/shared/entities/address/AddressEntityTacTon';
import OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import TacOperationStatus from 'ui/shared/statusTag/TacOperationStatus';
import TimeWithTooltip from 'ui/shared/time/TimeWithTooltip';
interface Props {
item: tac.OperationBriefDetails;
isLoading?: boolean;
}
const TacOperationsTableItem = ({ item, isLoading }: Props) => {
return (
<TableRow>
<TableCell verticalAlign="middle">
<OperationEntity
id={ item.operation_id }
isLoading={ isLoading }
/>
</TableCell>
<TableCell verticalAlign="middle">
<TimeWithTooltip
timestamp={ item.timestamp }
isLoading={ isLoading }
color="text.secondary"
/>
</TableCell>
<TableCell verticalAlign="middle">
<TacOperationStatus status={ item.type } isLoading={ isLoading }/>
</TableCell>
<TableCell verticalAlign="middle" pr={ 12 }>
{ item.sender ? (
<AddressEntityTacTon
address={{ hash: item.sender.address }}
chainType={ item.sender.blockchain }
truncation="constant"
isLoading={ isLoading }
w="fit-content"
/>
) : '-' }
</TableCell>
</TableRow>
);
};
export default TacOperationsTableItem;
......@@ -118,6 +118,23 @@ test('search by tx hash +@mobile', async({ render, mockApiResponse }) => {
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by tac operation hash +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.tac);
const hooksConfig = {
router: {
query: { q: searchMock.tacOperation1.tac_operation.operation_id },
},
};
const data = {
items: [ searchMock.tacOperation1 ],
next_page_params: null,
};
await mockApiResponse('general:search', data, { queryParams: { q: searchMock.tacOperation1.tac_operation.operation_id } });
const component = await render(<SearchResults/>, { hooksConfig });
await expect(component.locator('main')).toHaveScreenshot();
});
test('search by blob hash +@mobile', async({ render, mockApiResponse, mockEnvs }) => {
const hooksConfig = {
router: {
......
......@@ -108,6 +108,9 @@ const SearchResultsPageContent = () => {
if (!config.features.nameService.isEnabled && item.type === 'ens_domain') {
return false;
}
if (!config.features.tac.isEnabled && item.type === 'tac_operation') {
return false;
}
return true;
});
......
import React from 'react';
import * as tacOperationMock from 'mocks/operations/tac';
import { ENVS_MAP } from 'playwright/fixtures/mockEnvs';
import { test, expect } from 'playwright/lib';
import TacOperation from './TacOperation';
test('base view +@dark-mode +@mobile', async({ render, mockTextAd, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.tac);
await mockTextAd();
await mockApiResponse('tac:operation', tacOperationMock.tacOperation, {
pathParams: { id: tacOperationMock.tacOperation.operation_id },
});
const component = await render(
<TacOperation/>,
{ hooksConfig: {
router: {
query: { id: tacOperationMock.tacOperation.operation_id },
isReady: true,
},
} },
);
await component.getByRole('button', { name: 'Collected in TON' }).click();
await component.getByRole('button', { name: 'Included in TON consensus' }).click();
await component.getByRole('button', { name: 'Executed in TON' }).click();
await expect(component).toHaveScreenshot();
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TAC_OPERATION_DETAILS } from 'stubs/operations';
import TacOperationDetails from 'ui/operation/tac/TacOperationDetails';
import TextAd from 'ui/shared/ad/TextAd';
import OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
import TacOperationTag from 'ui/shared/TacOperationTag';
const TacOperation = () => {
const appProps = useAppContext();
const router = useRouter();
const id = getQueryParamString(router.query.id);
const query = useApiQuery('tac:operation', {
pathParams: { id },
queryOptions: {
placeholderData: TAC_OPERATION_DETAILS,
},
});
throwOnResourceLoadError(query);
const backLink = React.useMemo(() => {
const hasGoBackLink = appProps.referrer && appProps.referrer.endsWith('/operations');
if (!hasGoBackLink) {
return;
}
return {
label: 'Back to operations list',
url: appProps.referrer,
};
}, [ appProps.referrer ]);
const titleContentAfter = query.data ? (
<TacOperationTag type={ query.data.type } loading={ query.isPlaceholderData }/>
) : null;
const titleSecondRow = (
<OperationEntity id={ id } noLink variant="subheading"/>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="Operation details"
backLink={ backLink }
contentAfter={ titleContentAfter }
isLoading={ query.isPlaceholderData }
secondRow={ titleSecondRow }
/>
{ query.data && (
<TacOperationDetails isLoading={ query.isPlaceholderData } data={ query.data }/>
) }
</>
);
};
export default React.memo(TacOperation);
import { Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import useDebounce from 'lib/hooks/useDebounce';
import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TAC_OPERATION } from 'stubs/operations';
import { generateListStub } from 'stubs/utils';
import { FilterInput } from 'toolkit/components/filters/FilterInput';
import { apos } from 'toolkit/utils/htmlEntities';
import TacOperationsListItem from 'ui/operations/tac/TacOperationsListItem';
import TacOperationsTable from 'ui/operations/tac/TacOperationsTable';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
const TacOperations = () => {
const router = useRouter();
const [ searchTerm, setSearchTerm ] = React.useState(getQueryParamString(router.query.q) || undefined);
const debouncedSearchTerm = useDebounce(searchTerm || '', 300);
const isMobile = useIsMobile();
const { isError, isPlaceholderData, data, pagination, onFilterChange } = useQueryWithPages({
resourceName: 'tac:operations',
filters: { q: debouncedSearchTerm },
options: {
placeholderData: generateListStub<'tac:operations'>(
TAC_OPERATION,
50,
{ next_page_params: undefined },
),
},
});
const handleSearchTermChange = React.useCallback((value: string) => {
onFilterChange({ q: value });
setSearchTerm(value);
}, [ onFilterChange ]);
const filterInput = (
<FilterInput
w={{ base: '100%', lg: '460px' }}
size="sm"
onChange={ handleSearchTermChange }
placeholder="Search by operation, tx hash, sender"
initialValue={ searchTerm }
/>
);
const actionBar = (
<>
<Box gap={ 3 } mb={ 6 } display={{ base: 'flex', lg: 'none' }}>
{ filterInput }
</Box>
{ (!isMobile || pagination.isVisible) && (
<ActionBar mt={ -6 }>
<Box gap={ 3 } display={{ base: 'none', lg: 'flex' }}>
{ filterInput }
</Box>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) }
</>
);
const content = data?.items ? (
<>
<Box hideFrom="lg">
{ data.items.map(((item, index) => (
<TacOperationsListItem
key={ String(item.operation_id) + (isPlaceholderData ? index : '') }
isLoading={ isPlaceholderData }
item={ item }
/>
))) }
</Box>
<Box hideBelow="lg">
<TacOperationsTable
items={ data.items }
isLoading={ isPlaceholderData }
/>
</Box>
</>
) : null;
return (
<>
<PageTitle title="Operations" withTextAd/>
<DataListDisplay
isError={ isError }
itemsNum={ data?.items?.length }
emptyText="There are no operations."
filterProps={{
emptyFilteredText: `Couldn${ apos }t find any operation that matches your query.`,
hasActiveFilters: Boolean(searchTerm),
}}
actionBar={ actionBar }
>
{ content }
</DataListDisplay>
</>
);
};
export default React.memo(TacOperations);
......@@ -5,6 +5,7 @@ import type { TabItemRegular } from 'toolkit/components/AdaptiveTabs/types';
import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types';
import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/contexts/app';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
......@@ -31,6 +32,7 @@ import useTxQuery from 'ui/tx/useTxQuery';
const txInterpretation = config.features.txInterpretation;
const rollupFeature = config.features.rollup;
const tacFeature = config.features.tac;
const TransactionPageContent = () => {
const router = useRouter();
......@@ -38,6 +40,14 @@ const TransactionPageContent = () => {
const hash = getQueryParamString(router.query.hash);
const txQuery = useTxQuery();
const tacOperationQuery = useApiQuery('tac:operation_by_tx_hash', {
pathParams: { tx_hash: hash },
queryOptions: {
enabled: tacFeature.isEnabled,
},
});
const { data, isPlaceholderData, isError, error, errorUpdateCount } = txQuery;
const showDegradedView = publicClient && ((isError && error.status !== 422) || isPlaceholderData) && errorUpdateCount > 0;
......@@ -45,7 +55,7 @@ const TransactionPageContent = () => {
const tabs: Array<TabItemRegular> = (() => {
const detailsComponent = showDegradedView ?
<TxDetailsDegraded hash={ hash } txQuery={ txQuery }/> :
<TxDetails txQuery={ txQuery }/>;
<TxDetails txQuery={ txQuery } tacOperationQuery={ tacFeature.isEnabled ? tacOperationQuery : undefined }/>;
return [
{
......@@ -89,7 +99,7 @@ const TransactionPageContent = () => {
const tags = (
<EntityTags
isLoading={ isPlaceholderData }
isLoading={ isPlaceholderData || (tacFeature.isEnabled && tacOperationQuery.isPlaceholderData) }
tags={ txTags }
/>
);
......
......@@ -23,6 +23,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
......@@ -31,6 +32,7 @@ import IconSvg from 'ui/shared/IconSvg';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import type { SearchResultAppItem } from 'ui/shared/search/utils';
import { getItemCategory, searchItemTitles } from 'ui/shared/search/utils';
import TacOperationTag from 'ui/shared/TacOperationTag';
import SearchResultEntityTag from './SearchResultEntityTag';
......@@ -214,6 +216,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr
);
}
case 'tac_operation': {
return (
<OperationEntity.Container>
<OperationEntity.Icon/>
<OperationEntity.Link
isLoading={ isLoading }
id={ data.tac_operation.operation_id }
onClick={ handleLinkClick }
>
<OperationEntity.Content
asProp="mark"
id={ data.tac_operation.operation_id }
textStyle="sm"
fontWeight={ 700 }
mr={ 2 }
/>
</OperationEntity.Link>
<TacOperationTag type={ data.tac_operation.type }/>
</OperationEntity.Container>
);
}
case 'blob': {
return (
<BlobEntity.Container>
......@@ -325,6 +349,11 @@ const SearchResultListItem = ({ data, searchTerm, isLoading, addressFormat }: Pr
<Text color="text.secondary">{ dayjs(data.timestamp).format('llll') }</Text>
);
}
case 'tac_operation': {
return (
<Text color="text.secondary">{ dayjs(data.tac_operation.timestamp).format('llll') }</Text>
);
}
case 'user_operation': {
return (
......
......@@ -24,6 +24,7 @@ import * as AddressEntity from 'ui/shared/entities/address/AddressEntity';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import * as BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as EnsEntity from 'ui/shared/entities/ens/EnsEntity';
import * as OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
import * as UserOpEntity from 'ui/shared/entities/userOp/UserOpEntity';
......@@ -31,6 +32,7 @@ import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import IconSvg from 'ui/shared/IconSvg';
import type { SearchResultAppItem } from 'ui/shared/search/utils';
import { getItemCategory, searchItemTitles } from 'ui/shared/search/utils';
import TacOperationTag from 'ui/shared/TacOperationTag';
import SearchResultEntityTag from './SearchResultEntityTag';
......@@ -324,6 +326,35 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading, addressFormat }: P
);
}
case 'tac_operation': {
return (
<>
<TableCell colSpan={ 2 } fontSize="sm">
<OperationEntity.Container>
<OperationEntity.Icon/>
<OperationEntity.Link
isLoading={ isLoading }
id={ data.tac_operation.operation_id }
onClick={ handleLinkClick }
>
<OperationEntity.Content
asProp="mark"
id={ data.tac_operation.operation_id }
textStyle="sm"
fontWeight={ 700 }
mr={ 2 }
/>
</OperationEntity.Link>
<TacOperationTag type={ data.tac_operation.type }/>
</OperationEntity.Container>
</TableCell>
<TableCell fontSize="sm" verticalAlign="middle" isNumeric>
<Text color="text.secondary">{ dayjs(data.tac_operation.timestamp).format('llll') }</Text>
</TableCell>
</>
);
}
case 'blob': {
return (
<TableCell colSpan={ 3 } fontSize="sm">
......
......@@ -9,13 +9,14 @@ type Props = {
// should be string, will be fixed on the back-end
timestamp: string | number;
isLoading?: boolean;
noIcon?: boolean;
};
const DetailedInfoTimestamp = ({ timestamp, isLoading }: Props) => {
const DetailedInfoTimestamp = ({ timestamp, isLoading, noIcon }: Props) => {
return (
<>
<IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isLoading }/>
<Skeleton loading={ isLoading } ml={ 2 }>
{ !noIcon && <IconSvg name="clock" boxSize={ 5 } color="gray.500" isLoading={ isLoading } mr={ 2 }/> }
<Skeleton loading={ isLoading }>
{ dayjs(timestamp).fromNow() }
</Skeleton>
<TextSeparator color="gray.500"/>
......
......@@ -10,7 +10,7 @@ export const href = config.app.spriteHash ? `/icons/sprite.${ config.app.spriteH
export { IconName };
interface Props extends HTMLChakraProps<'div'> {
export interface Props extends HTMLChakraProps<'div'> {
name: IconName;
isLoading?: boolean;
}
......
import React from 'react';
import type * as tac from '@blockscout/tac-operation-lifecycle-types';
import { getTacOperationStatus } from 'lib/operations/tac';
import type { BadgeProps } from 'toolkit/chakra/badge';
import { Badge } from 'toolkit/chakra/badge';
interface Props extends BadgeProps {
type: tac.OperationType;
}
const TacOperationTag = ({ type, ...rest }: Props) => {
const text = getTacOperationStatus(type);
if (!text) {
return null;
}
return <Badge { ...rest }>{ text }</Badge>;
};
export default React.memo(TacOperationTag);
......@@ -56,6 +56,46 @@ test.describe('contract', () => {
});
});
test.describe('shield', () => {
const ICON_URL = 'https://images.com/icons/shield.png';
test.use({ viewport: { width: 500, height: 200 } });
test('regular address with image', async({ render, page, mockAssetResponse }) => {
await mockAssetResponse(ICON_URL, './playwright/mocks/image_svg.svg');
await render(
<AddressEntity
address={{ ...addressMock.withoutName }}
icon={{
shield: { src: ICON_URL },
hint: 'Address on TON',
}}
/>,
);
await page.locator('img').first().hover();
await page.locator('div').filter({ hasText: 'Address on TON' }).first().waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot();
});
test('contract with icon', async({ render, page }) => {
await render(
<AddressEntity
address={{ ...addressMock.contract, is_verified: true, implementations: null }}
icon={{
shield: { name: 'brands/ton' },
hint: 'Address on TON',
hintPostfix: ' on TON',
}}
/>,
);
await page.getByRole('img').first().hover();
await page.locator('div').filter({ hasText: 'Verified contract on TON' }).first().waitFor({ state: 'visible' });
await expect(page).toHaveScreenshot();
});
});
test.describe('proxy contract', () => {
test.use({ viewport: { width: 500, height: 300 } });
......
......@@ -36,18 +36,17 @@ const Link = chakra((props: LinkProps) => {
);
});
type IconProps = Pick<EntityProps, 'address' | 'isSafeAddress'> & EntityBase.IconBaseProps & {
tooltipInteractive?: boolean;
};
type IconProps = Pick<EntityProps, 'address' | 'isSafeAddress'> & EntityBase.IconBaseProps;
const Icon = (props: IconProps) => {
if (props.noIcon) {
return null;
}
const marginRight = props.marginRight ?? (props.shield ? '18px' : '8px');
const styles = {
...getIconProps(props.variant),
marginRight: props.marginRight ?? 2,
marginRight,
};
if (props.isLoading) {
......@@ -69,35 +68,40 @@ const Icon = (props: IconProps) => {
const isProxy = Boolean(props.address.implementations?.length);
const isVerified = isProxy ? props.address.is_verified && props.address.implementations?.every(({ name }) => Boolean(name)) : props.address.is_verified;
const contractIconName: EntityBase.IconBaseProps['name'] = props.address.is_verified ? 'contracts/verified' : 'contracts/regular';
const label = (isVerified ? 'verified ' : '') + (isProxy ? 'proxy contract' : 'contract');
const label = (isVerified ? 'verified ' : '') + (isProxy ? 'proxy contract' : 'contract') + props.hintPostfix;
return (
<Tooltip content={ label.slice(0, 1).toUpperCase() + label.slice(1) } interactive={ props.tooltipInteractive }>
<span>
<EntityBase.Icon
{ ...props }
name={ isProxy ? 'contracts/proxy' : contractIconName }
color={ isVerified ? 'green.500' : undefined }
borderRadius={ 0 }
/>
</span>
</Tooltip>
<EntityBase.Icon
{ ...props }
name={ isProxy ? 'contracts/proxy' : contractIconName }
color={ isVerified ? 'green.500' : undefined }
borderRadius={ 0 }
hint={ label.slice(0, 1).toUpperCase() + label.slice(1) }
/>
);
}
const label = (() => {
if (isDelegatedAddress) {
return props.address.is_verified ? 'EOA + verified code' : 'EOA + code';
return (props.address.is_verified ? 'EOA + verified code' : 'EOA + code') + props.hintPostfix;
}
return props.hint;
})();
return (
<Tooltip content={ label } disabled={ !label } interactive={ props.tooltipInteractive }>
<Tooltip
content={ label }
disabled={ !label }
interactive={ props.tooltipInteractive }
positioning={ props.shield ? { offset: { mainAxis: 8 } } : undefined }
>
<Flex marginRight={ styles.marginRight } position="relative">
<AddressIdenticon
size={ props.variant === 'heading' ? 30 : 20 }
hash={ getDisplayedAddress(props.address) }
/>
{ props.shield && <EntityBase.IconShield { ...props.shield }/> }
{ isDelegatedAddress && <AddressIconDelegated isVerified={ Boolean(props.address.is_verified) }/> }
</Flex>
</Tooltip>
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { route } from 'nextjs-routes';
import config from 'configs/app';
import * as AddressEntity from './AddressEntity';
const tacFeature = config.features.tac;
interface Props extends AddressEntity.EntityProps {
chainType: tac.BlockchainType | null;
}
const AddressEntityTacTon = (props: Props) => {
if (!tacFeature.isEnabled) {
return null;
}
const href = (() => {
switch (props.chainType) {
case tac.BlockchainType.TON:
return tacFeature.explorerUrl + route({
pathname: '/address/[hash]',
query: {
...props.query,
hash: props.address.hash,
},
});
case tac.BlockchainType.TAC:
return route({
pathname: '/address/[hash]',
query: {
...props.query,
hash: props.address.hash,
},
});
default:
return null;
}
})();
if (!href) {
return null;
}
return (
<AddressEntity.default
{ ...props }
href={ href }
isExternal={ props.chainType === tac.BlockchainType.TON }
icon={ props.chainType === tac.BlockchainType.TON ? {
shield: { name: 'brands/ton' },
hint: 'Address on TON',
hintPostfix: ' on TON',
} : undefined }
/>
);
};
export default chakra(AddressEntityTacTon);
import { chakra, Flex } from '@chakra-ui/react';
import { Box, chakra, Flex } from '@chakra-ui/react';
import type { IconProps } from '@chakra-ui/react';
import React from 'react';
import type { ImageProps } from 'toolkit/chakra/image';
import { Image } from 'toolkit/chakra/image';
import type { LinkProps } from 'toolkit/chakra/link';
import { Link as LinkToolkit } from 'toolkit/chakra/link';
import { Skeleton } from 'toolkit/chakra/skeleton';
import { Tooltip } from 'toolkit/chakra/tooltip';
import type { Props as CopyToClipboardProps } from 'ui/shared/CopyToClipboard';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import HashStringShorten from 'ui/shared/HashStringShorten';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import type { IconName } from 'ui/shared/IconSvg';
import type { IconName, Props as IconSvgProps } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
import TruncatedValue from 'ui/shared/TruncatedValue';
......@@ -87,29 +90,81 @@ const Link = chakra(({ isLoading, children, isExternal, onClick, href, noLink, v
interface EntityIconProps extends Pick<IconProps, 'color' | 'borderRadius' | 'marginRight' | 'boxSize'> {
name?: IconName;
shield?: IconShieldProps;
hint?: string;
hintPostfix?: string;
tooltipInteractive?: boolean;
}
export interface IconBaseProps extends Pick<EntityBaseProps, 'isLoading' | 'noIcon' | 'variant'>, EntityIconProps {}
const Icon = ({ isLoading, noIcon, variant, name, color, borderRadius, marginRight, boxSize }: IconBaseProps) => {
const Icon = ({ isLoading, noIcon, variant, name, color, borderRadius, marginRight, boxSize, shield, hint, tooltipInteractive }: IconBaseProps) => {
if (noIcon || !name) {
return null;
}
const styles = getIconProps(variant);
return (
const iconElement = (
<IconSvg
name={ name }
boxSize={ boxSize ?? styles.boxSize }
isLoading={ isLoading }
borderRadius={ borderRadius ?? 'base' }
display="block"
mr={ marginRight ?? 2 }
mr={ marginRight ?? (shield ? '18px' : '8px') }
color={ color ?? { _light: 'gray.500', _dark: 'gray.400' } }
minW={ 0 }
flexShrink={ 0 }
/>
);
const iconElementWithHint = hint ? (
<Tooltip
content={ hint }
interactive={ tooltipInteractive }
positioning={ shield ? { offset: { mainAxis: 8 } } : undefined }
>
{ iconElement }
</Tooltip>
) : iconElement;
if (!shield) {
return iconElementWithHint;
}
return (
<Box position="relative">
{ iconElementWithHint }
<IconShield { ...shield }/>
</Box>
);
};
type IconShieldProps = (ImageProps | IconSvgProps);
const IconShield = (props: IconShieldProps) => {
const styles = {
position: 'absolute',
top: '6px',
left: '12px',
boxSize: '18px',
borderRadius: 'full',
borderWidth: '1px',
borderStyle: 'solid',
// The colors can be changed on hover, if address is highlighted
// Because the highlighted styles are described as CSS classes, we must do the same for the shield border color.
// borderColor: 'global.body.bg',
// backgroundColor: 'global.body.bg',
};
if ('src' in props) {
return <Image className="entity__shield" { ...styles } { ...props }/>;
}
const svgProps = props as IconSvgProps;
return <IconSvg className="entity__shield" { ...styles } { ...svgProps }/>;
};
export interface ContentBaseProps extends Pick<EntityBaseProps, 'className' | 'isLoading' | 'truncation' | 'tailLength' | 'noTooltip' | 'variant'> {
......@@ -210,6 +265,7 @@ export {
Container,
Link,
Icon,
IconShield,
Copy,
Content,
};
import { chakra } from '@chakra-ui/react';
import React from 'react';
import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components';
import { distributeEntityProps } from '../base/utils';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'id'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/operation/[id]', query: { id: props.id } });
return (
<EntityBase.Link
{ ...props }
href={ props.href ?? defaultHref }
>
{ props.children }
</EntityBase.Link>
);
});
const Icon = (props: EntityBase.IconBaseProps) => {
return (
<EntityBase.Icon
{ ...props }
name={ props.name ?? 'operation_slim' }
borderRadius="none"
/>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'id'>;
const Content = chakra((props: ContentProps) => {
return (
<EntityBase.Content
{ ...props }
text={ props.id }
/>
);
});
type CopyProps = Omit<EntityBase.CopyBaseProps, 'text'> & Pick<EntityProps, 'id'>;
const Copy = ({ id, ...props }: CopyProps) => {
return (
<EntityBase.Copy
{ ...props }
text={ id }
/>
);
};
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
id: string;
}
const OperationEntity = (props: EntityProps) => {
const partsProps = distributeEntityProps(props);
const content = <Content { ...partsProps.content }/>;
return (
<Container { ...partsProps.container }>
<Icon { ...partsProps.icon }/>
{ props.noLink ? content : <Link { ...partsProps.link }>{ content }</Link> }
<Copy { ...partsProps.copy }/>
</Container>
);
};
export default React.memo(OperationEntity);
export {
Container,
Link,
Icon,
Content,
Copy,
};
import { chakra } from '@chakra-ui/react';
import React from 'react';
import config from 'configs/app';
import { stripTrailingSlash } from 'toolkit/utils/url';
import * as TxEntity from './TxEntity';
const tacFeature = config.features.tac;
const TxEntityTon = (props: TxEntity.EntityProps) => {
if (!tacFeature.isEnabled) {
return null;
}
const formattedHash = props.hash.replace(/^0x/, '');
const defaultHref = `${ stripTrailingSlash(tacFeature.explorerUrl) }/tx/${ formattedHash }`;
return <TxEntity.default { ...props } hash={ formattedHash } href={ props.href ?? defaultHref } icon={{ name: 'brands/ton' }} isExternal/>;
};
export default chakra(TxEntityTon);
......@@ -3,7 +3,7 @@ import type { SearchResultItem } from 'types/client/search';
import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob' | 'domain' | 'tac_operation';
export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap =
......@@ -23,6 +23,7 @@ export const searchCategories: Array<{ id: Category; title: string }> = [
{ id: 'public_tag', title: 'Public tags' },
{ id: 'transaction', title: 'Transactions' },
{ id: 'block', title: 'Blocks' },
{ id: 'tac_operation', title: 'Operations' },
];
if (config.features.userOps.isEnabled) {
......@@ -48,6 +49,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
blob: { itemTitle: 'Blob', itemTitleShort: 'Blob' },
tac_operation: { itemTitle: 'Operations', itemTitleShort: 'Operations' },
};
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
......@@ -84,5 +86,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'ens_domain': {
return 'domain';
}
case 'tac_operation': {
return 'tac_operation';
}
}
}
......@@ -41,7 +41,7 @@ const ArbitrumL2MessageStatus = ({ status, isLoading }: Props) => {
break;
}
return <StatusTag type={ type } text={ text } isLoading={ isLoading }/>;
return <StatusTag type={ type } text={ text } loading={ isLoading }/>;
};
export default ArbitrumL2MessageStatus;
......@@ -22,7 +22,7 @@ const ArbitrumL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break;
}
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>;
return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
};
export default ArbitrumL2TxnBatchStatus;
......@@ -31,7 +31,7 @@ const InteropMessageStatus = ({ status, isLoading }: Props) => {
break;
}
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>;
return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
};
export default InteropMessageStatus;
......@@ -20,7 +20,7 @@ const ZkEvmL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break;
}
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>;
return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
};
export default ZkEvmL2TxnBatchStatus;
import { chakra } from '@chakra-ui/react';
import React from 'react';
import capitalizeFirstLetter from 'lib/capitalizeFirstLetter';
......@@ -10,15 +9,13 @@ import IconSvg from 'ui/shared/IconSvg';
export type StatusTagType = 'ok' | 'error' | 'pending';
export interface Props {
export interface Props extends BadgeProps {
type: 'ok' | 'error' | 'pending';
text: string;
errorText?: string | null;
isLoading?: boolean;
className?: string;
}
const StatusTag = ({ type, text, errorText, isLoading, className }: Props) => {
const StatusTag = ({ type, text, errorText, ...rest }: Props) => {
let icon: IconName;
let colorPalette: BadgeProps['colorPalette'];
......@@ -43,11 +40,11 @@ const StatusTag = ({ type, text, errorText, isLoading, className }: Props) => {
return (
<Tooltip content={ errorText } disabled={ !errorText }>
<Badge colorPalette={ colorPalette } loading={ isLoading } className={ className } startElement={ startElement }>
<Badge colorPalette={ colorPalette } startElement={ startElement } { ...rest }>
{ capitalizedText }
</Badge>
</Tooltip>
);
};
export default chakra(StatusTag);
export default StatusTag;
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { test, expect } from 'playwright/lib';
import TacOperationStatus from './TacOperationStatus';
const STATUSES: Array<tac.OperationType> = [
tac.OperationType.TON_TAC_TON,
tac.OperationType.TAC_TON,
tac.OperationType.TON_TAC,
tac.OperationType.ERROR,
tac.OperationType.PENDING,
];
test.use({ viewport: { width: 200, height: 50 } });
STATUSES.forEach((status) => {
test(`${ status }`, async({ render }) => {
const component = await render(<TacOperationStatus status={ status }/>);
await expect(component).toHaveScreenshot();
});
});
import React from 'react';
import * as tac from '@blockscout/tac-operation-lifecycle-types';
import { getTacOperationStatus } from 'lib/operations/tac';
import { Tooltip } from 'toolkit/chakra/tooltip';
import StatusTag from './StatusTag';
interface Props {
status: tac.OperationType;
isLoading?: boolean;
noTooltip?: boolean;
}
const TacOperationStatus = ({ status, isLoading, noTooltip }: Props) => {
const text = getTacOperationStatus(status);
if (!text) {
return null;
}
switch (status) {
case tac.OperationType.ERROR:
return <StatusTag type="error" text={ text } loading={ isLoading }/>;
case tac.OperationType.ROLLBACK:
return (
<Tooltip
// eslint-disable-next-line max-len
content="The cross‑chain operation was reverted and the original assets and state were returned to the sender after a failure on the destination chain"
disabled={ noTooltip }
>
<StatusTag type="error" text={ text } loading={ isLoading }/>
</Tooltip>
);
case tac.OperationType.PENDING: {
return <StatusTag type="pending" text={ text } loading={ isLoading }/>;
}
default: {
return <StatusTag type="ok" text={ text } loading={ isLoading }/>;
}
}
};
export default React.memo(TacOperationStatus);
......@@ -34,7 +34,7 @@ const TxStatus = ({ status, errorText, isLoading }: Props) => {
break;
}
return <StatusTag type={ type } text={ text } errorText={ errorText } isLoading={ isLoading }/>;
return <StatusTag type={ type } text={ text } errorText={ errorText } loading={ isLoading }/>;
};
export default TxStatus;
......@@ -12,11 +12,11 @@ interface Props {
const ValidatorStabilityStatus = ({ state, isLoading }: Props) => {
switch (state) {
case 'active':
return <StatusTag type="ok" text="Active" isLoading={ isLoading }/>;
return <StatusTag type="ok" text="Active" loading={ isLoading }/>;
case 'probation':
return <StatusTag type="pending" text="Probation" isLoading={ isLoading }/>;
return <StatusTag type="pending" text="Probation" loading={ isLoading }/>;
case 'inactive':
return <StatusTag type="error" text="Inactive" isLoading={ isLoading }/>;
return <StatusTag type="error" text="Inactive" loading={ isLoading }/>;
}
};
......
......@@ -23,7 +23,7 @@ const ZkEvmL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break;
}
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>;
return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
};
export default ZkEvmL2TxnBatchStatus;
......@@ -22,7 +22,7 @@ const ZkSyncL2TxnBatchStatus = ({ status, isLoading }: Props) => {
break;
}
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>;
return <StatusTag type={ type } text={ status } loading={ isLoading }/>;
};
export default ZkSyncL2TxnBatchStatus;
......@@ -13,7 +13,7 @@ const UserOpStatus = ({ status, isLoading }: Props) => {
}
return (
<StatusTag isLoading={ isLoading } type={ status === true ? 'ok' : 'error' } text={ status === true ? 'Success' : 'Failed' }/>
<StatusTag loading={ isLoading } type={ status === true ? 'ok' : 'error' } text={ status === true ? 'Success' : 'Failed' }/>
);
};
......
......@@ -124,6 +124,17 @@ test('search by tx hash +@mobile', async({ render, page, mockApiResponse }) => {
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by tac operation hash +@mobile', async({ render, page, mockApiResponse }) => {
const apiUrl = await mockApiResponse('general:quick_search', [
searchMock.tacOperation1,
], { queryParams: { q: searchMock.tacOperation1.tac_operation.operation_id } });
await render(<SearchBar/>);
await page.getByPlaceholder(/search/i).fill(searchMock.tacOperation1.tac_operation.operation_id);
await page.waitForResponse(apiUrl);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by blob hash +@mobile', async({ render, page, mockApiResponse, mockEnvs }) => {
await mockEnvs(ENVS_MAP.dataAvailability);
const apiUrl = await mockApiResponse('general:quick_search', [
......
......@@ -11,6 +11,7 @@ import SearchBarSuggestBlock from './SearchBarSuggestBlock';
import SearchBarSuggestDomain from './SearchBarSuggestDomain';
import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel';
import SearchBarSuggestTacOperation from './SearchBarSuggestTacOperation';
import SearchBarSuggestToken from './SearchBarSuggestToken';
import SearchBarSuggestTx from './SearchBarSuggestTx';
import SearchBarSuggestUserOp from './SearchBarSuggestUserOp';
......@@ -56,6 +57,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm
case 'ens_domain': {
return route({ pathname: '/address/[hash]', query: { hash: data.address_hash } });
}
case 'tac_operation': {
return route({ pathname: '/operation/[id]', query: { id: data.tac_operation.operation_id } });
}
}
})();
......@@ -108,6 +112,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick, addressForm
case 'ens_domain': {
return <SearchBarSuggestDomain data={ data } searchTerm={ searchTerm } isMobile={ isMobile } addressFormat={ addressFormat }/>;
}
case 'tac_operation': {
return <SearchBarSuggestTacOperation data={ data } searchTerm={ searchTerm } isMobile={ isMobile } addressFormat={ addressFormat }/>;
}
}
})();
......
import { Text, Flex, chakra } from '@chakra-ui/react';
import React from 'react';
import type { ItemsProps } from './types';
import type { SearchResultTacOperation } from 'types/api/search';
import dayjs from 'lib/date/dayjs';
import * as OperationEntity from 'ui/shared/entities/operation/OperationEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import TacOperationTag from 'ui/shared/TacOperationTag';
const SearchBarSuggestTacOperation = ({ data, isMobile }: ItemsProps<SearchResultTacOperation>) => {
const icon = <OperationEntity.Icon/>;
const hash = (
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 } mr={ 2 }>
<HashStringShortenDynamic hash={ data.tac_operation.operation_id } noTooltip/>
</chakra.mark>
);
const status = <TacOperationTag type={ data.tac_operation.type }/>;
const date = dayjs(data.tac_operation.timestamp).format('llll');
if (isMobile) {
return (
<>
<Flex alignItems="center">
{ icon }
{ hash }
{ status }
</Flex>
<Text color="text.secondary">{ date }</Text>
</>
);
}
return (
<Flex columnGap={ 2 }>
<Flex alignItems="center" minW={ 0 }>
{ icon }
{ hash }
{ status }
</Flex>
<Text color="text.secondary" textAlign="end" flexShrink={ 0 } ml="auto">{ date }</Text>
</Flex>
);
};
export default React.memo(SearchBarSuggestTacOperation);
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment