Commit b1530fc6 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Interop (#2585)

* interop

* fixes

* fix skeleton

* tests

* review fixes
parent 41c90dae
...@@ -35,6 +35,7 @@ const config: Feature<{ ...@@ -35,6 +35,7 @@ const config: Feature<{
type: RollupType; type: RollupType;
homepage: { showLatestBlocks: boolean }; homepage: { showLatestBlocks: boolean };
outputRootsEnabled: boolean; outputRootsEnabled: boolean;
interopEnabled: boolean;
L2WithdrawalUrl: string | undefined; L2WithdrawalUrl: string | undefined;
parentChain: ParentChain; parentChain: ParentChain;
DA: { DA: {
...@@ -50,6 +51,7 @@ const config: Feature<{ ...@@ -50,6 +51,7 @@ const config: Feature<{
type, type,
L2WithdrawalUrl: type === 'optimistic' ? L2WithdrawalUrl : undefined, L2WithdrawalUrl: type === 'optimistic' ? L2WithdrawalUrl : undefined,
outputRootsEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED') === 'true', outputRootsEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED') === 'true',
interopEnabled: type === 'optimistic' && getEnvValue('NEXT_PUBLIC_INTEROP_ENABLED') === 'true',
homepage: { homepage: {
showLatestBlocks: getEnvValue('NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS') === 'true', showLatestBlocks: getEnvValue('NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS') === 'true',
}, },
......
...@@ -346,6 +346,17 @@ const rollupSchema = yup ...@@ -346,6 +346,17 @@ const rollupSchema = yup
value => value === undefined, value => value === undefined,
), ),
}), }),
NEXT_PUBLIC_INTEROP_ENABLED: yup
.boolean()
.when('NEXT_PUBLIC_ROLLUP_TYPE', {
is: 'optimistic',
then: (schema) => schema,
otherwise: (schema) => schema.test(
'not-exist',
'NEXT_PUBLIC_INTEROP_ENABLED can only be used if NEXT_PUBLIC_ROLLUP_TYPE is set to \'optimistic\' ',
value => value === undefined,
),
}),
NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME: yup NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME: yup
.string() .string()
.when('NEXT_PUBLIC_ROLLUP_TYPE', { .when('NEXT_PUBLIC_ROLLUP_TYPE', {
......
...@@ -5,3 +5,4 @@ NEXT_PUBLIC_FAULT_PROOF_ENABLED=true ...@@ -5,3 +5,4 @@ NEXT_PUBLIC_FAULT_PROOF_ENABLED=true
NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS=true NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS=true
NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED=false
NEXT_PUBLIC_ROLLUP_PARENT_CHAIN={'baseUrl':'https://explorer.duckchain.io'} NEXT_PUBLIC_ROLLUP_PARENT_CHAIN={'baseUrl':'https://explorer.duckchain.io'}
NEXT_PUBLIC_INTEROP_ENABLED=true
\ No newline at end of file
...@@ -114,6 +114,11 @@ module.exports = { ...@@ -114,6 +114,11 @@ module.exports = {
return null; return null;
} }
break; break;
case '/interop-messages':
if (process.env.NEXT_PUBLIC_INTEROP_ENABLED !== 'true') {
return null;
}
break;
case '/pools': case '/pools':
if (process.env.NEXT_PUBLIC_DEX_POOLS_ENABLED !== 'true') { if (process.env.NEXT_PUBLIC_DEX_POOLS_ENABLED !== 'true') {
return null; return null;
......
...@@ -466,6 +466,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi ...@@ -466,6 +466,7 @@ This feature is **enabled by default** with the `coinzilla` ads provider. To swi
| NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL | `string` | URL for L2 -> L1 withdrawals (Optimistic stack only) | Required for `optimistic` rollups | - | `https://app.optimism.io/bridge/withdraw` | v1.24.0+ | | NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL | `string` | URL for L2 -> L1 withdrawals (Optimistic stack only) | Required for `optimistic` rollups | - | `https://app.optimism.io/bridge/withdraw` | v1.24.0+ |
| NEXT_PUBLIC_FAULT_PROOF_ENABLED | `boolean` | Set to `true` for chains with fault proof system enabled (Optimistic stack only) | - | - | `true` | v1.31.0+ | | NEXT_PUBLIC_FAULT_PROOF_ENABLED | `boolean` | Set to `true` for chains with fault proof system enabled (Optimistic stack only) | - | - | `true` | v1.31.0+ |
| NEXT_PUBLIC_HAS_MUD_FRAMEWORK | `boolean` | Set to `true` for instances that use MUD framework (Optimistic stack only) | - | - | `true` | v1.33.0+ | | NEXT_PUBLIC_HAS_MUD_FRAMEWORK | `boolean` | Set to `true` for instances that use MUD framework (Optimistic stack only) | - | - | `true` | v1.33.0+ |
| NEXT_PUBLIC_INTEROP_ENABLED | `boolean` | Enables "Interop messages" page (Optimistic stack only) | - | `false` | `true` | v1.39.0+ |
| NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS | `boolean` | Set to `true` to display "Latest blocks" widget instead of "Latest batches" on the home page | - | - | `true` | v1.36.0+ | | NEXT_PUBLIC_ROLLUP_HOMEPAGE_SHOW_LATEST_BLOCKS | `boolean` | Set to `true` to display "Latest blocks" widget instead of "Latest batches" on the home page | - | - | `true` | v1.36.0+ |
| NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED | `boolean` | Enables "Output roots" page (Optimistic stack only) | - | `false` | `true` | v1.37.0+ | | NEXT_PUBLIC_ROLLUP_OUTPUT_ROOTS_ENABLED | `boolean` | Enables "Output roots" page (Optimistic stack only) | - | `false` | `true` | v1.37.0+ |
| NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME | `string` | Set to customize L1 transaction status labels in the UI (e.g., "Sent to <chain-name>"). This setting is applicable only for Arbitrum-based chains. **DEPRECATED** _Use `NEXT_PUBLIC_ROLLUP_PARENT_CHAIN` instead_ | - | - | `DuckChain` | v1.37.0+ | | NEXT_PUBLIC_ROLLUP_PARENT_CHAIN_NAME | `string` | Set to customize L1 transaction status labels in the UI (e.g., "Sent to <chain-name>"). This setting is applicable only for Arbitrum-based chains. **DEPRECATED** _Use `NEXT_PUBLIC_ROLLUP_PARENT_CHAIN` instead_ | - | - | `DuckChain` | v1.37.0+ |
......
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 30 30">
<path fill="currentColor" fill-rule="evenodd" d="M8.455 6c.602 0 1.09.488 1.09 1.09v.864a6 6 0 0 0 10.91 0v-.863a1.091 1.091 0 0 1 2.181 0v.65a7.1 7.1 0 0 0 2.551 1.596 1.09 1.09 0 0 1-.738 2.053 9.3 9.3 0 0 1-1.813-.885v6.404h3.273a1.09 1.09 0 1 1 0 2.182h-3.273v3.273a1.09 1.09 0 0 1-2.181 0V19.09H9.545v3.273a1.09 1.09 0 1 1-2.181 0V19.09H4.09a1.09 1.09 0 1 1 0-2.182h3.273v-6.404a9.3 9.3 0 0 1-1.813.885 1.09 1.09 0 0 1-.738-2.053 7.1 7.1 0 0 0 2.55-1.596v-.65A1.094 1.094 0 0 1 8.456 6m7.636 10.91h4.363v-5.357a8.17 8.17 0 0 1-4.363 2.01zm-2.182-3.347v3.346H9.545v-5.356a8.18 8.18 0 0 0 4.364 2.01" clip-rule="evenodd"/>
</svg>
...@@ -82,6 +82,7 @@ import type { ...@@ -82,6 +82,7 @@ import type {
} from 'types/api/ens'; } from 'types/api/ens';
import type { IndexingStatus } from 'types/api/indexingStatus'; import type { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { InteropMessageListResponse } from 'types/api/interop';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log'; import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { MudWorldsResponse } from 'types/api/mudWorlds'; import type { MudWorldsResponse } from 'types/api/mudWorlds';
import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves'; import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves';
...@@ -1243,6 +1244,15 @@ export const RESOURCES = { ...@@ -1243,6 +1244,15 @@ export const RESOURCES = {
block_countdown: { block_countdown: {
path: '/api', path: '/api',
}, },
// INTEROP
optimistic_l2_interop_messages: {
path: '/api/v2/optimism/interop/messages',
filterFields: [],
},
optimistic_l2_interop_messages_count: {
path: '/api/v2/optimism/interop/messages/count',
},
}; };
export type ResourceName = keyof typeof RESOURCES; export type ResourceName = keyof typeof RESOURCES;
...@@ -1296,7 +1306,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward ...@@ -1296,7 +1306,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'block_election_reward
'watchlist' | 'private_tags_address' | 'private_tags_tx' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'validators_zilliqa' | 'noves_address_history' | 'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators_stability' | 'validators_blackfort' | 'validators_zilliqa' | 'noves_address_history' |
'token_transfers_all' | 'scroll_l2_txn_batches' | 'scroll_l2_txn_batch_txs' | 'scroll_l2_txn_batch_blocks' | 'token_transfers_all' | 'scroll_l2_txn_batches' | 'scroll_l2_txn_batch_txs' | 'scroll_l2_txn_batch_blocks' |
'scroll_l2_deposits' | 'scroll_l2_withdrawals' | 'advanced_filter' | 'pools'; 'scroll_l2_deposits' | 'scroll_l2_withdrawals' | 'advanced_filter' | 'pools' | 'optimistic_l2_interop_messages';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -1473,6 +1483,8 @@ Q extends 'optimistic_l2_output_roots_count' ? number : ...@@ -1473,6 +1483,8 @@ Q extends 'optimistic_l2_output_roots_count' ? number :
Q extends 'optimistic_l2_withdrawals_count' ? number : Q extends 'optimistic_l2_withdrawals_count' ? number :
Q extends 'optimistic_l2_deposits_count' ? number : Q extends 'optimistic_l2_deposits_count' ? number :
Q extends 'optimistic_l2_dispute_games_count' ? number : Q extends 'optimistic_l2_dispute_games_count' ? number :
Q extends 'optimistic_l2_interop_messages' ? InteropMessageListResponse :
Q extends 'optimistic_l2_interop_messages_count' ? number :
Q extends 'shibarium_withdrawals' ? ShibariumWithdrawalsResponse : Q extends 'shibarium_withdrawals' ? ShibariumWithdrawalsResponse :
Q extends 'shibarium_deposits' ? ShibariumDepositsResponse : Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number : Q extends 'shibarium_withdrawals_count' ? number :
......
...@@ -115,6 +115,13 @@ export default function useNavItems(): ReturnType { ...@@ -115,6 +115,13 @@ export default function useNavItems(): ReturnType {
const rollupFeature = config.features.rollup; const rollupFeature = config.features.rollup;
const rollupInteropMessages = rollupFeature.isEnabled && rollupFeature.interopEnabled ? {
text: 'Interop messages',
nextRoute: { pathname: '/interop-messages' as const },
icon: 'interop',
isActive: pathname === '/interop-messages',
} : null;
if (rollupFeature.isEnabled && ( if (rollupFeature.isEnabled && (
rollupFeature.type === 'optimistic' || rollupFeature.type === 'optimistic' ||
rollupFeature.type === 'arbitrum' || rollupFeature.type === 'arbitrum' ||
...@@ -127,7 +134,8 @@ export default function useNavItems(): ReturnType { ...@@ -127,7 +134,8 @@ export default function useNavItems(): ReturnType {
internalTxs, internalTxs,
rollupDeposits, rollupDeposits,
rollupWithdrawals, rollupWithdrawals,
], rollupInteropMessages,
].filter(Boolean),
[ [
blocks, blocks,
rollupTxnBatches, rollupTxnBatches,
......
...@@ -60,6 +60,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -60,6 +60,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/advanced-filter': 'Root page', '/advanced-filter': 'Root page',
'/pools': 'Root page', '/pools': 'Root page',
'/pools/[hash]': 'Regular page', '/pools/[hash]': 'Regular page',
'/interop-messages': 'Root page',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Regular page', '/login': 'Regular page',
......
...@@ -63,6 +63,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -63,6 +63,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/advanced-filter': DEFAULT_TEMPLATE, '/advanced-filter': DEFAULT_TEMPLATE,
'/pools': DEFAULT_TEMPLATE, '/pools': DEFAULT_TEMPLATE,
'/pools/[hash]': DEFAULT_TEMPLATE, '/pools/[hash]': DEFAULT_TEMPLATE,
'/interop-messages': DEFAULT_TEMPLATE,
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': DEFAULT_TEMPLATE, '/login': DEFAULT_TEMPLATE,
......
...@@ -60,6 +60,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -60,6 +60,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/advanced-filter': '%network_name% advanced filter', '/advanced-filter': '%network_name% advanced filter',
'/pools': '%network_name% DEX pools', '/pools': '%network_name% DEX pools',
'/pools/[hash]': '%network_name% pool details', '/pools/[hash]': '%network_name% pool details',
'/interop-messages': '%network_name% interop messages',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': '%network_name% login', '/login': '%network_name% login',
......
...@@ -58,6 +58,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -58,6 +58,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/advanced-filter': 'Advanced filter', '/advanced-filter': 'Advanced filter',
'/pools': 'DEX pools', '/pools': 'DEX pools',
'/pools/[hash]': 'Pool details', '/pools/[hash]': 'Pool details',
'/interop-messages': 'Interop messages',
// service routes, added only to make typescript happy // service routes, added only to make typescript happy
'/login': 'Login', '/login': 'Login',
......
import type { ChainInfo, InteropMessage } from 'types/api/interop';
export const chain: ChainInfo = {
chain_id: 1,
chain_name: 'Ethereum',
chain_logo: 'https://example.com/logo.png',
instance_url: 'https://example.com',
};
export const interopMessageIn: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Relayed',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
init_chain: chain,
};
export const interopMessageIn1: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Sent',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
init_chain: null,
};
export const interopMessageOut: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Relayed',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
relay_chain: chain,
};
export const interopMessageOut1: InteropMessage = {
init_transaction_hash: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
nonce: 1,
payload: 'payload',
relay_transaction_hash: '0x62d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
sender: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
status: 'Failed',
target: '0x047A81aFB05D9B1f8844bf60fcA05DCCFbC584B9',
timestamp: '2022-10-10T14:34:30.000000Z',
relay_chain: null,
};
...@@ -3,6 +3,7 @@ import type { Transaction } from 'types/api/transaction'; ...@@ -3,6 +3,7 @@ import type { Transaction } from 'types/api/transaction';
import * as addressMock from 'mocks/address/address'; import * as addressMock from 'mocks/address/address';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag'; import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import * as interopMock from 'mocks/interop/interop';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer'; import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import * as decodedInputDataMock from 'mocks/txs/decodedInputData'; import * as decodedInputDataMock from 'mocks/txs/decodedInputData';
...@@ -423,3 +424,29 @@ export const withRecipientContract = { ...@@ -423,3 +424,29 @@ export const withRecipientContract = {
...withRecipientEns, ...withRecipientEns,
to: addressMock.contract, to: addressMock.contract,
}; };
export const withInteropInMessage: Transaction = {
...base,
op_interop: {
init_chain: interopMock.chain,
nonce: 1,
payload: '0x',
init_transaction_hash: '0x01a8c328b0370068aaaef49c107f70901cd79adcda81e3599a88855532122e09',
sender: addressMock.hash,
status: 'Sent',
target: addressMock.hash,
},
};
export const withInteropOutMessage: Transaction = {
...base,
op_interop: {
relay_chain: interopMock.chain,
nonce: 1,
payload: '0xfa4b78b90000000000000000000000000000000000000000000000000000000005001bcfe835d1028984e9e6e7d016b77164eacbcc6cc061e9333c0b37982b504f7ea791000000000000000000000000a79b29ad7e0196c95b87f4663ded82fbf2e3add8',
relay_transaction_hash: '0x01a8c328b0370068aaaef49c107f70901cd79adcda81e3599a88855532122e09',
sender: addressMock.hash,
status: 'Sent',
target: addressMock.hash,
},
};
...@@ -380,6 +380,17 @@ export const mud: GetServerSideProps<Props> = async(context) => { ...@@ -380,6 +380,17 @@ export const mud: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const interopMessages: GetServerSideProps<Props> = async(context) => {
const rollupFeature = config.features.rollup;
if (!rollupFeature.isEnabled || !rollupFeature.interopEnabled) {
return {
notFound: true,
};
}
return base(context);
};
export const pools: GetServerSideProps<Props> = async(context) => { export const pools: GetServerSideProps<Props> = async(context) => {
if (!config.features.pools.isEnabled) { if (!config.features.pools.isEnabled) {
return { return {
......
...@@ -46,6 +46,7 @@ declare module "nextjs-routes" { ...@@ -46,6 +46,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/graphiql"> | StaticRoute<"/graphiql">
| StaticRoute<"/"> | StaticRoute<"/">
| StaticRoute<"/internal-txs"> | StaticRoute<"/internal-txs">
| StaticRoute<"/interop-messages">
| StaticRoute<"/login"> | StaticRoute<"/login">
| StaticRoute<"/mud-worlds"> | StaticRoute<"/mud-worlds">
| DynamicRoute<"/name-domains/[name]", { "name": string }> | DynamicRoute<"/name-domains/[name]", { "name": string }>
......
import type { NextPage } from 'next';
import dynamic from 'next/dynamic';
import React from 'react';
import PageNextJs from 'nextjs/PageNextJs';
const InteropMessages = dynamic(() => import('ui/pages/InteropMessages'), { ssr: false });
const Page: NextPage = () => {
return (
<PageNextJs pathname="/interop-messages">
<InteropMessages/>
</PageNextJs>
);
};
export default Page;
export { interopMessages as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -97,4 +97,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = { ...@@ -97,4 +97,10 @@ export const ENVS_MAP: Record<string, Array<[string, string]>> = {
externalTxs: [ externalTxs: [
[ 'NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG', '{"chain_name": "Solana", "chain_logo_url": "http://example.url", "explorer_url_template": "https://scan.io/tx/{hash}"}' ], [ 'NEXT_PUBLIC_TX_EXTERNAL_TRANSACTIONS_CONFIG', '{"chain_name": "Solana", "chain_logo_url": "http://example.url", "explorer_url_template": "https://scan.io/tx/{hash}"}' ],
], ],
interop: [
[ 'NEXT_PUBLIC_ROLLUP_TYPE', 'optimistic' ],
[ 'NEXT_PUBLIC_ROLLUP_L1_BASE_URL', 'https://localhost:3101' ],
[ 'NEXT_PUBLIC_ROLLUP_L2_WITHDRAWAL_URL', 'https://localhost:3102' ],
[ 'NEXT_PUBLIC_INTEROP_ENABLED', 'true' ],
],
}; };
...@@ -84,6 +84,7 @@ ...@@ -84,6 +84,7 @@
| "integration/full" | "integration/full"
| "integration/partial" | "integration/partial"
| "internal_txns" | "internal_txns"
| "interop"
| "key" | "key"
| "lightning_navbar" | "lightning_navbar"
| "lightning" | "lightning"
......
import type { InteropMessage } from 'types/api/interop';
import { ADDRESS_HASH } from './addressParams';
import { TX_HASH } from './tx';
export const INTEROP_MESSAGE: InteropMessage = {
init_transaction_hash: TX_HASH,
nonce: 52,
payload: '0x4f0edcc90000000000000000000000007da521cbbe62e89cd75e0993c78b8c68c25f696b',
relay_chain: {
chain_id: 420120000,
chain_name: 'Optimism Testnet',
chain_logo: null,
instance_url: 'https://optimism-interop-alpha-0.blockscout.com/',
},
relay_transaction_hash: TX_HASH,
sender: ADDRESS_HASH,
status: 'Relayed',
target: ADDRESS_HASH,
timestamp: '2025-02-20T01:05:14.000000Z',
};
export interface ChainInfo {
chain_id: number;
chain_name: string | null;
chain_logo: string | null;
instance_url: string;
}
export type MessageStatus = 'Sent' | 'Relayed' | 'Failed';
export interface InteropMessage {
init_transaction_hash: string;
init_chain?: ChainInfo | null;
nonce: number;
payload: string;
relay_chain?: ChainInfo | null;
relay_transaction_hash: string | null;
sender: string;
status: MessageStatus;
target: string;
timestamp: string;
}
export interface InteropMessageListResponse {
items: Array<InteropMessage>;
next_page_params?: {
init_transaction_hash: string;
items_count: number;
timestamp: number;
};
}
...@@ -3,6 +3,7 @@ import type { ArbitrumBatchStatus, ArbitrumL2TxData } from './arbitrumL2'; ...@@ -3,6 +3,7 @@ import type { ArbitrumBatchStatus, ArbitrumL2TxData } from './arbitrumL2';
import type { BlockTransactionsResponse } from './block'; import type { BlockTransactionsResponse } from './block';
import type { DecodedInput } from './decodedInput'; import type { DecodedInput } from './decodedInput';
import type { Fee } from './fee'; import type { Fee } from './fee';
import type { ChainInfo, MessageStatus } from './interop';
import type { NovesTxTranslation } from './noves'; import type { NovesTxTranslation } from './noves';
import type { OptimisticL2WithdrawalStatus } from './optimisticL2'; import type { OptimisticL2WithdrawalStatus } from './optimisticL2';
import type { ScrollL2BlockStatus } from './scrollL2'; import type { ScrollL2BlockStatus } from './scrollL2';
...@@ -107,6 +108,8 @@ export type Transaction = { ...@@ -107,6 +108,8 @@ export type Transaction = {
scroll?: ScrollTransactionData; scroll?: ScrollTransactionData;
// EIP-7702 // EIP-7702
authorization_list?: Array<TxAuthorization>; authorization_list?: Array<TxAuthorization>;
// Interop
op_interop?: InteropTransactionInfo;
}; };
type ArbitrumTransactionData = { type ArbitrumTransactionData = {
...@@ -215,3 +218,15 @@ export interface TxAuthorization { ...@@ -215,3 +218,15 @@ export interface TxAuthorization {
chain_id: number; chain_id: number;
nonce: number; nonce: number;
} }
export interface InteropTransactionInfo {
nonce: number;
payload: string;
init_chain?: ChainInfo | null;
relay_chain?: ChainInfo | null;
init_transaction_hash?: string;
relay_transaction_hash?: string;
sender: string;
status: MessageStatus;
target: string;
}
import {
chakra,
PopoverTrigger,
PopoverContent,
PopoverBody,
Text,
Flex,
} from '@chakra-ui/react';
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import AdditionalInfoButton from 'ui/shared/AdditionalInfoButton';
import Popover from 'ui/shared/chakra/Popover';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
type Props = {
payload: InteropMessage['payload'];
isLoading?: boolean;
className?: string;
};
const InteropMessageAdditionalInfo = ({ payload, isLoading, className }: Props) => {
return (
<Popover placement="right-start" openDelay={ 300 } isLazy>
{ ({ isOpen, onClose }) => (
<>
<PopoverTrigger>
<AdditionalInfoButton isOpen={ isOpen } isLoading={ isLoading } className={ className }/>
</PopoverTrigger>
<PopoverContent border="1px solid" borderColor="divider">
<PopoverBody fontWeight={ 400 } fontSize="sm">
<Flex alignItems="center" justifyContent="space-between" mb={ 3 }>
<Text color="text_secondary" fontWeight="600">Message payload</Text>
<CopyToClipboard text={ payload } onClick={ onClose }/>
</Flex>
<Text>
{ payload }
</Text>
</PopoverBody>
</PopoverContent>
</>
) }
</Popover>
);
};
export default React.memo(chakra(InteropMessageAdditionalInfo));
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import type { EntityProps } from 'ui/shared/entities/tx/TxEntity';
import TxEntityInterop from 'ui/shared/entities/tx/TxEntityInterop';
type Props = {
relay_transaction_hash?: string | null;
relay_chain?: ChainInfo | null;
truncation?: EntityProps['truncation'];
isLoading?: boolean;
};
const InteropMessageDestinationTx = (props: Props) => {
if (props.relay_chain !== undefined) {
return (
<TxEntityInterop
chain={ props.relay_chain }
hash={ props.relay_transaction_hash }
isLoading={ props.isLoading }
truncation={ props.truncation || 'constant' }
/>
);
}
if (!props.relay_transaction_hash) {
return 'N/A';
}
return (
<TxEntity
hash={ props.relay_transaction_hash }
isLoading={ props.isLoading }
noIcon
truncation={ props.truncation || 'constant' }
/>
);
};
export default InteropMessageDestinationTx;
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import type { EntityProps } from 'ui/shared/entities/tx/TxEntity';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import TxEntityInterop from 'ui/shared/entities/tx/TxEntityInterop';
type Props = {
init_transaction_hash?: string | null;
init_chain?: ChainInfo | null;
isLoading?: boolean;
truncation?: EntityProps['truncation'];
};
const InteropMessageSourceTx = (props: Props) => {
if (props.init_chain !== undefined) {
return (
<TxEntityInterop
chain={ props.init_chain }
hash={ props.init_transaction_hash }
isLoading={ props.isLoading }
truncation={ props.truncation || 'constant' }
/>
);
}
if (!props.init_transaction_hash) {
return 'N/A';
}
return (
<TxEntity
hash={ props.init_transaction_hash }
isLoading={ props.isLoading }
noIcon
truncation={ props.truncation || 'constant' }
/>
);
};
export default InteropMessageSourceTx;
import { Flex, HStack, Text, Grid } from '@chakra-ui/react';
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import AddressEntityInterop from 'ui/shared/entities/address/AddressEntityInterop';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import InteropMessageStatus from 'ui/shared/statusTag/InteropMessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import InteropMessageAdditionalInfo from './InteropMessageAdditionalInfo';
import InteropMessageDestinationTx from './InteropMessageDestinationTx';
import InteropMessageSourceTx from './InteropMessageSourceTx';
interface Props {
item: InteropMessage;
isLoading?: boolean;
}
const InteropMessagesListItem = ({ item, isLoading }: Props) => {
return (
<ListItemMobile rowGap={ 2 }>
<Flex alignItems="center" justifyContent="space-between" w="100%">
<InteropMessageStatus status={ item.status } isLoading={ isLoading }/>
<InteropMessageAdditionalInfo payload={ item.payload } isLoading={ isLoading }/>
</Flex>
<Flex alignItems="flex-start" flexDirection="column" gap={ 2 } w="100%">
<HStack w="100%">
<Text fontWeight={ 500 } flexGrow={ 1 }>#{ item.nonce }</Text>
<TimeAgoWithTooltip timestamp={ item.timestamp } isLoading={ isLoading } color="text_secondary"/>
</HStack>
<Grid templateColumns="120px 1fr" rowGap={ 2 }>
<Text as="span" variant="secondary">Source tx</Text>
<InteropMessageSourceTx { ...item } isLoading={ isLoading }/>
<Text as="span" variant="secondary">Destination tx</Text>
<InteropMessageDestinationTx { ...item } isLoading={ isLoading }/>
</Grid>
<Flex gap={ 2 } justifyContent="space-between" mt={ 2 }>
{ item.init_chain !== undefined ? (
<AddressEntityInterop
chain={ item.init_chain }
address={{ hash: item.sender }}
isLoading={ isLoading }
truncation="constant"
/>
) : (
<AddressEntity address={{ hash: item.sender }} isLoading={ isLoading } truncation="constant"/>
) }
<AddressFromToIcon
isLoading={ isLoading }
type={ item.init_chain !== undefined ? 'in' : 'out' }
/>
{ item.relay_chain !== undefined ? (
<AddressEntityInterop
chain={ item.relay_chain }
address={{ hash: item.target }}
isLoading={ isLoading }
truncation="constant"
/>
) : (
<AddressEntity address={{ hash: item.target }} isLoading={ isLoading } truncation="constant"/>
) }
</Flex>
</Flex>
</ListItemMobile>
);
};
export default React.memo(InteropMessagesListItem);
import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import { default as Thead } from 'ui/shared/TheadSticky';
import InteropMessagesTableItem from './InteropMessagesTableItem';
interface Props {
items?: Array<InteropMessage>;
top: number;
isLoading?: boolean;
}
const InteropMessagesTable = ({ items, top, isLoading }: Props) => {
return (
<Table style={{ tableLayout: 'auto' }} size="sm">
<Thead top={ top }>
<Tr>
<Th></Th>
<Th>Message</Th>
<Th>Age</Th>
<Th>Status</Th>
<Th>Source tx</Th>
<Th>Destination tx</Th>
<Th>Sender</Th>
<Th>In/Out</Th>
<Th>Target</Th>
</Tr>
</Thead>
<Tbody>
{ items?.map((item, index) => (
<InteropMessagesTableItem
key={ item.init_transaction_hash + '_' + index }
item={ item }
isLoading={ isLoading }
/>
)) }
</Tbody>
</Table>
);
};
export default React.memo(InteropMessagesTable);
import { Tr, Td } from '@chakra-ui/react';
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import AddressFromToIcon from 'ui/shared/address/AddressFromToIcon';
import Skeleton from 'ui/shared/chakra/Skeleton';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import AddressEntityInterop from 'ui/shared/entities/address/AddressEntityInterop';
import InteropMessageStatus from 'ui/shared/statusTag/InteropMessageStatus';
import TimeAgoWithTooltip from 'ui/shared/TimeAgoWithTooltip';
import InteropMessageAdditionalInfo from './InteropMessageAdditionalInfo';
import InteropMessageDestinationTx from './InteropMessageDestinationTx';
import InteropMessageSourceTx from './InteropMessageSourceTx';
interface Props {
item: InteropMessage;
isLoading?: boolean;
}
const InteropMessagesTableItem = ({ item, isLoading }: Props) => {
return (
<Tr>
<Td>
<InteropMessageAdditionalInfo payload={ item.payload } isLoading={ isLoading }/>
</Td>
<Td>
<Skeleton isLoaded={ !isLoading } fontWeight="700">
{ item.nonce }
</Skeleton>
</Td>
<Td>
<TimeAgoWithTooltip timestamp={ item.timestamp } isLoading={ isLoading } color="text_secondary"/>
</Td>
<Td>
<InteropMessageStatus status={ item.status } isLoading={ isLoading }/>
</Td>
<Td>
<InteropMessageSourceTx { ...item } isLoading={ isLoading }/>
</Td>
<Td>
<InteropMessageDestinationTx { ...item } isLoading={ isLoading }/>
</Td>
<Td>
{ item.init_chain !== undefined ?
<AddressEntityInterop address={{ hash: item.target }} isLoading={ isLoading } truncation="constant" chain={ item.init_chain }/> :
<AddressEntity address={{ hash: item.target }} isLoading={ isLoading } truncation="constant"/>
}
</Td>
<Td>
<AddressFromToIcon
isLoading={ isLoading }
type={ item.init_chain !== undefined ? 'in' : 'out' }
/>
</Td>
<Td>
{ item.relay_chain !== undefined ?
<AddressEntityInterop address={{ hash: item.target }} isLoading={ isLoading } truncation="constant" chain={ item.relay_chain }/> :
<AddressEntity address={{ hash: item.target }} isLoading={ isLoading } truncation="constant"/>
}
</Td>
</Tr>
);
};
export default React.memo(InteropMessagesTableItem);
import React from 'react';
import * as interopMessageMock from 'mocks/interop/interop';
import { test, expect } from 'playwright/lib';
import InteropMessages from './InteropMessages';
test('default view +@mobile', async({ render, mockTextAd, mockAssetResponse, mockApiResponse }) => {
await mockTextAd();
await mockAssetResponse(interopMessageMock.chain.chain_logo as string, './playwright/mocks/image_s.jpg');
await mockApiResponse('optimistic_l2_interop_messages', {
items: [
interopMessageMock.interopMessageIn,
interopMessageMock.interopMessageIn1,
interopMessageMock.interopMessageOut,
interopMessageMock.interopMessageOut1,
],
next_page_params: {
init_transaction_hash: '1',
items_count: 4,
timestamp: 1719456000,
},
});
await mockApiResponse('optimistic_l2_interop_messages_count', 4000000);
const component = await render(<InteropMessages/>);
await expect(component).toHaveScreenshot();
});
import { Show, Hide } from '@chakra-ui/react';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import { INTEROP_MESSAGE } from 'stubs/interop';
import { generateListStub } from 'stubs/utils';
import InteropMessagesListItem from 'ui/interopMessages/InteropMessagesListItem';
import InteropMessagesTable from 'ui/interopMessages/InteropMessagesTable';
import { ACTION_BAR_HEIGHT_DESKTOP } from 'ui/shared/ActionBar';
import Skeleton from 'ui/shared/chakra/Skeleton';
import DataListDisplay from 'ui/shared/DataListDisplay';
import PageTitle from 'ui/shared/Page/PageTitle';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import StickyPaginationWithText from 'ui/shared/StickyPaginationWithText';
const InteropMessages = () => {
const interopMessagesQuery = useQueryWithPages({
resourceName: 'optimistic_l2_interop_messages',
options: {
placeholderData: generateListStub<'optimistic_l2_interop_messages'>(INTEROP_MESSAGE, 50, { next_page_params: {
init_transaction_hash: '',
items_count: 50,
timestamp: 0,
} }),
},
});
const countQuery = useApiQuery('optimistic_l2_interop_messages_count', {
queryOptions: {
placeholderData: 1927029,
},
});
const text = (() => {
if (countQuery.isError) {
return null;
}
return (
<Skeleton
isLoaded={ !countQuery.isPlaceholderData }
display="inline-block"
>
A total of { countQuery.data?.toLocaleString() } messages found
</Skeleton>
);
})();
const actionBar = <StickyPaginationWithText text={ text } pagination={ interopMessagesQuery.pagination }/>;
const content = (
<>
<Show below="lg" ssr={ false }>
{ interopMessagesQuery.data?.items.map((item, index) => (
<InteropMessagesListItem
key={ item.init_transaction_hash + (interopMessagesQuery.isPlaceholderData ? index : '') }
item={ item }
isLoading={ interopMessagesQuery.isPlaceholderData }
/>
)) }
</Show>
<Hide below="lg" ssr={ false }>
<InteropMessagesTable
items={ interopMessagesQuery.data?.items }
top={ interopMessagesQuery.pagination.isVisible ? ACTION_BAR_HEIGHT_DESKTOP : 0 }
isLoading={ interopMessagesQuery.isPlaceholderData }
/>
</Hide>
</>
);
return (
<>
<PageTitle
title="Interop messages"
withTextAd
/>
<DataListDisplay
isError={ interopMessagesQuery.isError }
items={ interopMessagesQuery.data?.items }
emptyText="There are no interop messages."
content={ content }
actionBar={ actionBar }
/>
</>
);
};
export default InteropMessages;
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import type { EntityTag as TEntityTag } from 'ui/shared/EntityTags/types';
import type { RoutedTab } from 'ui/shared/Tabs/types'; import type { RoutedTab } from 'ui/shared/Tabs/types';
import config from 'configs/app'; import config from 'configs/app';
...@@ -31,6 +32,7 @@ import TxUserOps from 'ui/tx/TxUserOps'; ...@@ -31,6 +32,7 @@ import TxUserOps from 'ui/tx/TxUserOps';
import useTxQuery from 'ui/tx/useTxQuery'; import useTxQuery from 'ui/tx/useTxQuery';
const txInterpretation = config.features.txInterpretation; const txInterpretation = config.features.txInterpretation;
const rollupFeature = config.features.rollup;
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
...@@ -78,10 +80,21 @@ const TransactionPageContent = () => { ...@@ -78,10 +80,21 @@ const TransactionPageContent = () => {
const tabIndex = useTabIndexFromQuery(tabs); const tabIndex = useTabIndexFromQuery(tabs);
const txTags: Array<TEntityTag> = data?.transaction_tag ?
[ { slug: data.transaction_tag, name: data.transaction_tag, tagType: 'private_tag' as const, ordinal: 1 } ] : [];
if (rollupFeature.isEnabled && rollupFeature.interopEnabled && data?.op_interop) {
if (data.op_interop.init_chain !== undefined) {
txTags.push({ slug: 'relay_tx', name: 'Relay tx', tagType: 'custom' as const, ordinal: 0 });
}
if (data.op_interop.relay_chain !== undefined) {
txTags.push({ slug: 'init_tx', name: 'Source tx', tagType: 'custom' as const, ordinal: 0 });
}
}
const tags = ( const tags = (
<EntityTags <EntityTags
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
tags={ data?.transaction_tag ? [ { slug: data.transaction_tag, name: data.transaction_tag, tagType: 'private_tag' as const } ] : [] } tags={ txTags }
/> />
); );
......
import React from 'react';
import * as addressMock from 'mocks/address/address';
import * as interopMock from 'mocks/interop/interop';
import { test, expect } from 'playwright/lib';
import AddressEntityInterop from './AddressEntityInterop';
test.use({ viewport: { width: 180, height: 140 } });
test('with chain icon', async({ render, mockAssetResponse }) => {
await mockAssetResponse('https://example.com/logo.png', './playwright/mocks/image_svg.svg');
const component = await render(
<AddressEntityInterop
address={ addressMock.withoutName }
icon={{ size: 'md' }}
chain={ interopMock.chain }
/>,
);
await expect(component).toHaveScreenshot();
});
test('with chain icon stub +@dark-mode', async({ render }) => {
const component = await render(
<AddressEntityInterop
address={ addressMock.withoutName }
icon={{ size: 'md' }}
chain={{ ...interopMock.chain, chain_logo: null }}
/>,
);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, Flex, Image, Tooltip, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import { route } from 'nextjs-routes';
import IconSvg from 'ui/shared/IconSvg';
import { distributeEntityProps } from '../base/utils';
import * as AddressEntity from './AddressEntity';
interface Props extends AddressEntity.EntityProps {
chain: ChainInfo | null;
}
const IconStub = () => {
const bgColor = useColorModeValue('gray.100', 'gray.700');
return (
<Flex
position="absolute"
bottom="-2px"
right="4px"
alignItems="center"
justifyContent="center"
borderRadius="base"
background={ bgColor }
width="14px"
height="14px"
border="1px solid"
borderColor="background"
>
<IconSvg
name="networks/icon-placeholder"
width="10px"
height="10px"
color="text_secondary"
/>
</Flex>
);
};
const AddressEntryInterop = (props: Props) => {
const partsProps = distributeEntityProps(props);
const href = props.chain?.instance_url ? props.chain.instance_url.replace(/\/$/, '') + route({
pathname: '/address/[hash]',
query: {
...props.query,
hash: props.address.hash,
},
}) : null;
const addressIcon = (
<Box position="relative">
<AddressEntity.Icon { ...partsProps.icon }/>
{ !props.isLoading && (
props.chain?.chain_logo ? (
<Image
position="absolute"
bottom="-3px"
right="4px"
src={ props.chain.chain_logo }
alt={ props.chain.chain_name || 'external chain logo' }
width="14px"
height="14px"
borderRadius="base"
/>
) : (
<IconStub/>
)
) }
</Box>
);
return (
<AddressEntity.Container>
{ props.chain && (
<Tooltip label={ `Address on ${ props.chain.chain_name ? props.chain.chain_name : 'external chain' } (chain id ${ props.chain.chain_id })` }>
{ addressIcon }
</Tooltip>
) }
{ !props.chain && addressIcon }
{ href ? (
<AddressEntity.Link { ...partsProps.link } href={ href } isExternal>
<AddressEntity.Content { ...partsProps.content }/>
</AddressEntity.Link>
) : (
<AddressEntity.Content { ...partsProps.content }/>
) }
<AddressEntity.Copy { ...partsProps.copy }/>
</AddressEntity.Container>
);
};
export default chakra(AddressEntryInterop);
import React from 'react';
import * as interopMock from 'mocks/interop/interop';
import { test, expect } from 'playwright/lib';
import TxEntityInterop from './TxEntityInterop';
const hash = '0x376db52955d5bce114d0ccea2dcf22289b4eae1b86bcae5a59bb5fdbfef48899';
test.use({ viewport: { width: 180, height: 30 } });
test('with chain icon', async({ render, mockAssetResponse }) => {
await mockAssetResponse('https://example.com/logo.png', './playwright/mocks/image_svg.svg');
const component = await render(
<TxEntityInterop
hash={ hash }
chain={ interopMock.chain }
/>,
);
await expect(component).toHaveScreenshot();
});
test('with chain icon stub +@dark-mode', async({ render }) => {
const component = await render(
<TxEntityInterop
hash={ hash }
icon={{ size: 'md' }}
chain={{ ...interopMock.chain, chain_logo: null }}
/>,
);
await expect(component).toHaveScreenshot();
});
import { Box, Image, Tooltip, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ChainInfo } from 'types/api/interop';
import { route } from 'nextjs-routes';
import stripTrailingSlash from 'lib/stripTrailingSlash';
import Skeleton from 'ui/shared/chakra/Skeleton';
import IconSvg from 'ui/shared/IconSvg';
import { distributeEntityProps } from '../base/utils';
import * as TxEntity from './TxEntity';
type Props = {
chain: ChainInfo | null;
hash?: string | null;
} & Omit<TxEntity.EntityProps, 'hash'>;
const IconStub = ({ isLoading }: { isLoading?: boolean }) => {
const bgColor = useColorModeValue('blackAlpha.100', 'whiteAlpha.100');
return (
<Skeleton
isLoaded={ !isLoading }
display="flex"
minWidth="20px"
h="20px"
borderRadius="full"
background={ bgColor }
alignItems="center"
justifyContent="center"
mr={ 2 }
>
<IconSvg
name="networks/icon-placeholder"
width="16px"
height="16px"
color="text_secondary"
display="block"
/>
</Skeleton>
);
};
const TxEntityInterop = ({ chain, hash, ...props }: Props) => {
const partsProps = distributeEntityProps(props);
const href = (chain?.instance_url && hash) ? stripTrailingSlash(chain.instance_url) + route({
pathname: '/tx/[hash]',
query: {
...props.query,
hash: hash,
},
}) : null;
return (
<TxEntity.Container { ...partsProps.container }>
{ chain && (
<Tooltip label={ `${ chain.chain_name ? chain.chain_name : 'External chain' } (chain id ${ chain.chain_id })` }>
<Box>
{ chain.chain_logo ? (
<Image
src={ chain.chain_logo }
alt={ chain.chain_name || 'external chain logo' }
width="20px"
height="20px"
mr={ 2 }
borderRadius="base"
/>
) : (
<IconStub isLoading={ props.isLoading }/>
) }
</Box>
</Tooltip>
) }
{ !chain && (
<IconStub/>
) }
{ hash && (
<>
{ href ? (
<TxEntity.Link { ...partsProps.link } hash={ hash } href={ href } isExternal>
<TxEntity.Content { ...partsProps.content } hash={ hash }/>
</TxEntity.Link>
) : (
<TxEntity.Content { ...partsProps.content } hash={ hash }/>
) }
<TxEntity.Copy { ...partsProps.copy } hash={ hash }/>
</>
) }
{ !hash && (
'N/A'
) }
</TxEntity.Container>
);
};
export default chakra(TxEntityInterop);
import React from 'react';
import type { InteropMessage } from 'types/api/interop';
import type { StatusTagType } from './StatusTag';
import StatusTag from './StatusTag';
export interface Props {
status: InteropMessage['status'];
isLoading?: boolean;
}
const InteropMessageStatus = ({ status, isLoading }: Props) => {
let type: StatusTagType;
switch (status) {
case 'Relayed': {
type = 'ok';
break;
}
case 'Failed': {
type = 'error';
break;
}
case 'Sent': {
type = 'pending';
break;
}
default:
type = 'pending';
break;
}
return <StatusTag type={ type } text={ status } isLoading={ isLoading }/>;
};
export default InteropMessageStatus;
import { Grid, Link, useColorModeValue, Text, Flex, Box } from '@chakra-ui/react';
import React from 'react';
import type { InteropTransactionInfo } from 'types/api/transaction';
import config from 'configs/app';
import InteropMessageDestinationTx from 'ui/interopMessages/InteropMessageDestinationTx';
import InteropMessageSourceTx from 'ui/interopMessages/InteropMessageSourceTx';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import * as DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import AddressEntityInterop from 'ui/shared/entities/address/AddressEntityInterop';
import InteropMessageStatus from 'ui/shared/statusTag/InteropMessageStatus';
const rollupFeature = config.features.rollup;
type Props = {
data?: InteropTransactionInfo;
isLoading: boolean;
};
const TxDetailsInterop = ({ data, isLoading }: Props) => {
const hasInterop = rollupFeature.isEnabled && rollupFeature.interopEnabled;
const [ isExpanded, setIsExpanded ] = React.useState(false);
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const toggleDetails = React.useCallback(() => {
setIsExpanded(!isExpanded);
}, [ isExpanded ]);
if (!hasInterop || !data) {
return null;
}
const detailsLink = (
<Link
display="inline-block"
fontSize="sm"
textDecorationLine="underline"
textDecorationStyle="dashed"
color="text_secondary"
onClick={ toggleDetails }
ml={ 3 }
>
{ isExpanded ? 'Hide details' : 'View details' }
</Link>
);
const details = (
<Grid
gridTemplateColumns="100px 1fr"
fontSize="sm"
lineHeight={ 5 }
bgColor={ bgColor }
px={ 4 }
py={ 2 }
mt={ 3 }
w="100%"
rowGap={ 4 }
borderRadius="md"
>
<Text color="text_secondary">Message id</Text>
<Text>{ data.nonce }</Text>
<Text color="text_secondary">Interop status</Text>
<Box>
<InteropMessageStatus status={ data.status }/>
</Box>
<Text color="text_secondary">Sender</Text>
{ data.init_chain !== undefined ? (
<AddressEntityInterop
chain={ data.init_chain }
address={{ hash: data.sender }}
isLoading={ isLoading }
truncation="constant"
/>
) : (
<AddressEntity address={{ hash: data.sender }} isLoading={ isLoading } truncation="constant"/>
) }
<Text color="text_secondary">Target</Text>
{ data.relay_chain !== undefined ? (
<AddressEntityInterop
chain={ data.relay_chain }
address={{ hash: data.target }}
isLoading={ isLoading }
truncation="constant"
/>
) : (
<AddressEntity address={{ hash: data.target }} isLoading={ isLoading } truncation="constant"/>
) }
<Text color="text_secondary">Payload</Text>
<Flex overflow="hidden">
<Text
wordBreak="break-all"
whiteSpace="normal"
overflow="hidden"
flex="1"
>
{ data.payload }
</Text>
<CopyToClipboard text={ data.payload }/>
</Flex>
</Grid>
);
if (data.init_chain !== undefined) {
return (
<>
<DetailsInfoItem.Label
hint="The originating transaction that initiated the cross-L2 message on the source chain"
isLoading={ isLoading }
>
Interop source tx
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<InteropMessageSourceTx { ...data } isLoading={ isLoading }/>
{ detailsLink }
{ isExpanded && details }
</DetailsInfoItem.Value>
</>
);
}
if (data.relay_chain !== undefined) {
return (
<>
<DetailsInfoItem.Label
hint="The transaction that relays the cross-L2 message to its destination chain"
isLoading={ isLoading }
>
Interop relay tx
</DetailsInfoItem.Label>
<DetailsInfoItem.Value>
<InteropMessageDestinationTx { ...data } isLoading={ isLoading }/>
{ detailsLink }
{ isExpanded && details }
</DetailsInfoItem.Value>
</>
);
}
return null;
};
export default TxDetailsInterop;
...@@ -127,11 +127,40 @@ test('arbitrum L1 status', async({ render, mockEnvs }) => { ...@@ -127,11 +127,40 @@ test('arbitrum L1 status', async({ render, mockEnvs }) => {
await expect(statusElement).toHaveScreenshot(); await expect(statusElement).toHaveScreenshot();
}); });
test('with external txs +@mobile', async({ render, mockEnvs, mockApiResponse, mockAssetResponse }) => { test('with external txs +@mobile', async({ page, render, mockEnvs, mockApiResponse, mockAssetResponse }) => {
await mockEnvs(ENVS_MAP.externalTxs); await mockEnvs(ENVS_MAP.externalTxs);
await mockApiResponse('tx_external_transactions', [ 'tx1', 'tx2', 'tx3' ], { pathParams: { hash: txMock.base.hash } }); await mockApiResponse('tx_external_transactions', [ 'tx1', 'tx2', 'tx3' ], { pathParams: { hash: txMock.base.hash } });
await mockAssetResponse('http://example.url', './playwright/mocks/image_s.jpg'); await mockAssetResponse('http://example.url', './playwright/mocks/image_s.jpg');
const component = await render(<TxInfo data={ txMock.base } isLoading={ false }/>); const component = await render(<TxInfo data={ txMock.base } isLoading={ false }/>);
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
});
test('with interop message in +@mobile', async({ render, page, mockEnvs, mockAssetResponse }) => {
await mockEnvs(ENVS_MAP.interop);
await mockAssetResponse('https://example.com/logo.png', './playwright/mocks/image_s.jpg');
const component = await render(<TxInfo data={ txMock.withInteropInMessage } isLoading={ false }/>);
await page.getByText('View details').first().click();
await expect(page.getByText('Interop status')).toBeVisible();
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
});
test('with interop message out +@mobile', async({ page, render, mockEnvs, mockAssetResponse }) => {
await mockEnvs(ENVS_MAP.interop);
await mockAssetResponse('https://example.com/logo.png', './playwright/mocks/image_s.jpg');
const component = await render(<TxInfo data={ txMock.withInteropOutMessage } isLoading={ false }/>);
await component.getByText('View details').first().click();
await expect(component.getByText('Interop status')).toBeVisible();
await expect(component).toHaveScreenshot({
mask: [ page.locator(pwConfig.adsBannerSelector) ],
maskColor: pwConfig.maskColor,
});
}); });
...@@ -38,6 +38,7 @@ import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider'; ...@@ -38,6 +38,7 @@ import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem'; import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import DetailsTimestamp from 'ui/shared/DetailsTimestamp'; import DetailsTimestamp from 'ui/shared/DetailsTimestamp';
import AddressEntity from 'ui/shared/entities/address/AddressEntity'; import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import AddressEntityInterop from 'ui/shared/entities/address/AddressEntityInterop';
import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2'; import BatchEntityL2 from 'ui/shared/entities/block/BatchEntityL2';
import BlockEntity from 'ui/shared/entities/block/BlockEntity'; import BlockEntity from 'ui/shared/entities/block/BlockEntity';
import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1'; import TxEntityL1 from 'ui/shared/entities/tx/TxEntityL1';
...@@ -64,11 +65,10 @@ import TxExternalTxs from 'ui/tx/TxExternalTxs'; ...@@ -64,11 +65,10 @@ import TxExternalTxs from 'ui/tx/TxExternalTxs';
import TxSocketAlert from 'ui/tx/TxSocketAlert'; import TxSocketAlert from 'ui/tx/TxSocketAlert';
import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo'; import ZkSyncL2TxnBatchHashesInfo from 'ui/txnBatches/zkSyncL2/ZkSyncL2TxnBatchHashesInfo';
import TxDetailsInterop from './TxDetailsInterop';
import TxDetailsWithdrawalStatusArbitrum from './TxDetailsWithdrawalStatusArbitrum'; import TxDetailsWithdrawalStatusArbitrum from './TxDetailsWithdrawalStatusArbitrum';
import TxInfoScrollFees from './TxInfoScrollFees'; import TxInfoScrollFees from './TxInfoScrollFees';
const rollupFeature = config.features.rollup;
interface Props { interface Props {
data: Transaction | undefined; data: Transaction | undefined;
isLoading: boolean; isLoading: boolean;
...@@ -76,6 +76,7 @@ interface Props { ...@@ -76,6 +76,7 @@ interface Props {
} }
const externalTxFeature = config.features.externalTxs; const externalTxFeature = config.features.externalTxs;
const rollupFeature = config.features.rollup;
const TxInfo = ({ data, isLoading, socketStatus }: Props) => { const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
const [ isExpanded, setIsExpanded ] = React.useState(false); const [ isExpanded, setIsExpanded ] = React.useState(false);
...@@ -141,6 +142,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -141,6 +142,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</Tooltip> </Tooltip>
) : null; ) : null;
const hasInterop = rollupFeature.isEnabled && rollupFeature.interopEnabled && data.op_interop;
return ( return (
<Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}> <Grid columnGap={ 8 } rowGap={{ base: 3, lg: 3 }} templateColumns={{ base: 'minmax(0, 1fr)', lg: 'max-content minmax(728px, auto)' }}>
...@@ -158,6 +161,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -158,6 +161,8 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
</GridItem> </GridItem>
) } ) }
<TxDetailsInterop data={ data.op_interop } isLoading={ isLoading }/>
<DetailsInfoItem.Label <DetailsInfoItem.Label
hint="Unique character string (TxID) assigned to every verified transaction" hint="Unique character string (TxID) assigned to every verified transaction"
isLoading={ isLoading } isLoading={ isLoading }
...@@ -499,6 +504,29 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -499,6 +504,29 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
{ data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash } isOverflow={ data.token_transfers_overflow }/> } { data.token_transfers && <TxDetailsTokenTransfers data={ data.token_transfers } txHash={ data.hash } isOverflow={ data.token_transfers_overflow }/> }
{ hasInterop && data.op_interop?.target && (
<>
<DetailsInfoItem.Label
isLoading={ isLoading }
hint="The target address where this cross-chain transaction is executed"
>
Interop target
</DetailsInfoItem.Label>
<DetailsInfoItem.Value flexWrap="nowrap">
{ data.op_interop?.relay_chain !== undefined ? (
<AddressEntityInterop
chain={ data.op_interop.relay_chain }
address={{ hash: data.op_interop.target }}
isLoading={ isLoading }
truncation="dynamic"
/>
) : (
<AddressEntity address={{ hash: data.op_interop.target }} isLoading={ isLoading } truncation="dynamic"/>
) }
</DetailsInfoItem.Value>
</>
) }
<DetailsInfoItemDivider/> <DetailsInfoItemDivider/>
{ (data.arbitrum?.commitment_transaction.hash || data.arbitrum?.confirmation_transaction.hash) && { (data.arbitrum?.commitment_transaction.hash || data.arbitrum?.confirmation_transaction.hash) &&
......
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