Commit 154cdad6 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Add Blobs support (EIP-4884) (#1672)

* blob page placeholder

* txs with blobs list view

* tx additional info popup

* blob gas info for tx and block

* update hints

* tx blobs tab

* blob details page

* add ENV to hide blob txs tab

* tx blob fees and adj for tx burnt fees

* preliminary tests

* blob data convertion

* display blob data type in lists

* download asset

* blob txs in block

* tests for blob data preview

* fixes

* fix tests

* support blobs in search

* update blob icon

* more fixes
parent 8e6a099d
import type { TxAdditionalFieldsId, TxFieldsId } from 'types/views/tx'; import type { TxAdditionalFieldsId, TxFieldsId, TxViewId } from 'types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from 'types/views/tx'; import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS, TX_VIEWS_IDS } from 'types/views/tx';
import { getEnvValue, parseEnvJson } from 'configs/app/utils'; import { getEnvValue, parseEnvJson } from 'configs/app/utils';
...@@ -33,9 +33,31 @@ const additionalFields = (() => { ...@@ -33,9 +33,31 @@ const additionalFields = (() => {
return result; return result;
})(); })();
const hiddenViews = (() => {
const envValue = getEnvValue('NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS');
if (!envValue) {
return undefined;
}
const parsedValue = parseEnvJson<Array<TxViewId>>(envValue);
if (!Array.isArray(parsedValue)) {
return undefined;
}
const result = TX_VIEWS_IDS.reduce((result, item) => {
result[item] = parsedValue.includes(item);
return result;
}, {} as Record<TxViewId, boolean>);
return result;
})();
const config = Object.freeze({ const config = Object.freeze({
hiddenFields, hiddenFields,
additionalFields, additionalFields,
hiddenViews,
}); });
export default config; export default config;
...@@ -32,8 +32,8 @@ import { ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address ...@@ -32,8 +32,8 @@ import { ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address
import { BLOCK_FIELDS_IDS } from '../../../types/views/block'; import { BLOCK_FIELDS_IDS } from '../../../types/views/block';
import type { BlockFieldId } from '../../../types/views/block'; import type { BlockFieldId } from '../../../types/views/block';
import type { NftMarketplaceItem } from '../../../types/views/nft'; import type { NftMarketplaceItem } from '../../../types/views/nft';
import type { TxAdditionalFieldsId, TxFieldsId } from '../../../types/views/tx'; import type { TxAdditionalFieldsId, TxFieldsId, TxViewId } from '../../../types/views/tx';
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from '../../../types/views/tx'; import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS, TX_VIEWS_IDS } from '../../../types/views/tx';
import { replaceQuotes } from '../../../configs/app/utils'; import { replaceQuotes } from '../../../configs/app/utils';
import * as regexp from '../../../lib/regexp'; import * as regexp from '../../../lib/regexp';
...@@ -448,6 +448,11 @@ const schema = yup ...@@ -448,6 +448,11 @@ const schema = yup
.transform(replaceQuotes) .transform(replaceQuotes)
.json() .json()
.of(yup.string<TxAdditionalFieldsId>().oneOf(TX_ADDITIONAL_FIELDS_IDS)), .of(yup.string<TxAdditionalFieldsId>().oneOf(TX_ADDITIONAL_FIELDS_IDS)),
NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS: yup
.array()
.transform(replaceQuotes)
.json()
.of(yup.string<TxViewId>().oneOf(TX_VIEWS_IDS)),
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: yup NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: yup
.array() .array()
.transform(replaceQuotes) .transform(replaceQuotes)
......
...@@ -49,6 +49,7 @@ NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=['burnt_fees','total_reward'] ...@@ -49,6 +49,7 @@ NEXT_PUBLIC_VIEWS_BLOCK_HIDDEN_FIELDS=['burnt_fees','total_reward']
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'NFT Marketplace','collection_url':'https://example.com/{hash}','instance_url':'https://example.com/{hash}/{id}','logo_url':'https://example.com/logo.png'}] NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES=[{'name':'NFT Marketplace','collection_url':'https://example.com/{hash}','instance_url':'https://example.com/{hash}/{id}','logo_url':'https://example.com/logo.png'}]
NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS=['fee_per_gas'] NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS=['fee_per_gas']
NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS=['value','fee_currency','gas_price','tx_fee','gas_fees','burnt_fees'] NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS=['value','fee_currency','gas_price','tx_fee','gas_fees','burnt_fees']
NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS=['blob_txs']
NEXT_PUBLIC_VISUALIZE_API_HOST=https://example.com NEXT_PUBLIC_VISUALIZE_API_HOST=https://example.com
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket'] NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
......
...@@ -218,6 +218,7 @@ Settings for meta tags and OG tags ...@@ -218,6 +218,7 @@ Settings for meta tags and OG tags
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS | `Array<TxFieldsId>` | Array of the transaction fields ids that should be hidden. See below the list of the possible id values. | - | - | `'["value","tx_fee"]'` | | NEXT_PUBLIC_VIEWS_TX_HIDDEN_FIELDS | `Array<TxFieldsId>` | Array of the transaction fields ids that should be hidden. See below the list of the possible id values. | - | - | `'["value","tx_fee"]'` |
| NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS | `Array<TxAdditionalFieldsId>` | Array of the additional fields ids that should be added to the transaction details. See below the list of the possible id values. | - | - | `'["fee_per_gas"]'` | | NEXT_PUBLIC_VIEWS_TX_ADDITIONAL_FIELDS | `Array<TxAdditionalFieldsId>` | Array of the additional fields ids that should be added to the transaction details. See below the list of the possible id values. | - | - | `'["fee_per_gas"]'` |
| NEXT_PUBLIC_VIEWS_TX_HIDDEN_VIEWS | `Array<TxViewId>` | Transaction views that should be hidden. See below the list of the possible id values. | - | - | `'["blob_txs"]'` |
##### Transaction fields list ##### Transaction fields list
| Id | Description | | Id | Description |
...@@ -234,6 +235,11 @@ Settings for meta tags and OG tags ...@@ -234,6 +235,11 @@ Settings for meta tags and OG tags
| --- | --- | | --- | --- |
| `fee_per_gas` | Amount of total fee divided by total amount of gas used by transaction | | `fee_per_gas` | Amount of total fee divided by total amount of gas used by transaction |
##### Transaction view list
| Id | Description |
| --- | --- |
| `blob_txs` | List of all transactions that contain blob data |
&nbsp; &nbsp;
#### NFT views #### NFT views
......
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.392.45C3.644.163 3.984 0 4.34 0h8.038a.63.63 0 0 1 .474.225L17.54 5.61a.83.83 0 0 1 .196.544v12.308c0 .408-.141.799-.393 1.087-.25.289-.592.451-.947.451H4.34c-.356 0-.696-.162-.948-.45A1.661 1.661 0 0 1 3 18.461V1.538c0-.408.141-.799.392-1.087Zm.948 1.088h6.87v4.497c0 .388.315.702.702.702h4.485v11.725H4.34V1.538Zm8.274.59 2.791 3.205h-2.791V2.128Z" fill="currentColor"/>
<path d="M8.1 16.4a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5h2.237c.74 0 1.305.164 1.697.491.397.324.595.808.595 1.452 0 .457-.101.85-.303 1.177-.202.328-.505.552-.909.674l.097-.223c.59.11 1.04.345 1.349.703.309.358.463.84.463 1.446 0 .709-.229 1.267-.686 1.674-.457.404-1.086.606-1.886.606H8.1Zm.746-1.12h1.765c.423 0 .76-.097 1.012-.291.255-.199.383-.517.383-.955 0-.476-.132-.805-.395-.988-.259-.187-.592-.28-1-.28H8.846v2.514Zm0-3.634h1.32c.373 0 .666-.075.88-.223.217-.152.325-.432.325-.84 0-.412-.108-.692-.325-.84-.213-.152-.507-.229-.88-.229h-1.32v2.132Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.038 12.116a2 2 0 0 1 .825.558c.309.358.463.84.463 1.446 0 .709-.229 1.267-.686 1.674-.457.404-1.086.606-1.886.606H8.1a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5h2.237c.74 0 1.305.164 1.697.491.397.324.595.808.595 1.452 0 .457-.101.85-.303 1.177a1.477 1.477 0 0 1-.534.515c.086.024.168.05.246.08Zm.177-.145c.32.137.588.327.8.573.347.403.51.936.51 1.576 0 .756-.245 1.372-.752 1.824-.504.445-1.185.656-2.019.656H8.1a.7.7 0 0 1-.7-.7v-7a.7.7 0 0 1 .7-.7h2.237c.765 0 1.383.17 1.825.537.454.371.667.92.667 1.606 0 .487-.108.917-.333 1.282-.08.13-.174.245-.281.346Zm-.72 1.237c-.217-.156-.506-.242-.883-.242H9.046v2.114h1.566c.393 0 .682-.09.889-.25.19-.147.305-.396.305-.796 0-.444-.122-.695-.309-.824l-.002-.002Zm.128 1.78c-.251.195-.589.292-1.011.292H8.846v-2.514h1.766c.407 0 .74.093 1 .28.262.183.394.512.394.988 0 .438-.128.756-.383.954Zm-.693-5.082c-.168-.12-.415-.192-.764-.192h-1.12v1.732h1.12c.35 0 .597-.07.765-.187.14-.098.24-.298.24-.676 0-.384-.1-.581-.238-.675l-.003-.002Zm.116 1.517c-.213.148-.507.223-.88.223h-1.32V9.514h1.32c.373 0 .666.076.88.229.217.148.326.428.326.84 0 .407-.109.687-.326.84Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" fill="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.647 1h12.705A2.65 2.65 0 0 1 19 3.647v12.706A2.65 2.65 0 0 1 16.353 19H3.647A2.65 2.65 0 0 1 1 16.353V3.647A2.65 2.65 0 0 1 3.647 1Zm12.705 16.94a1.59 1.59 0 0 0 1.588-1.588l.001-1.87-2.647-2.206-3.192 2.66-5.77-5.246-4.273 4.747v1.915a1.59 1.59 0 0 0 1.588 1.588h12.705ZM15.293 10.9l2.647 2.205.001-9.457a1.59 1.59 0 0 0-1.588-1.588H3.647A1.59 1.59 0 0 0 2.06 3.647v9.208L6.257 8.19l5.874 5.342 3.162-2.634Zm1.06-4.605a2.65 2.65 0 0 1-2.647 2.647 2.65 2.65 0 0 1-2.647-2.648 2.65 2.65 0 0 1 2.647-2.647 2.65 2.65 0 0 1 2.647 2.647Zm-1.059 0a1.59 1.59 0 1 0-1.588 1.588 1.59 1.59 0 0 0 1.588-1.588Z"/>
<path d="M19 3.647h.2-.2Zm-1.06 12.705h-.2.2Zm.001-1.87h.2v-.094l-.072-.06-.128.154Zm-2.647-2.206.128-.153-.128-.107-.128.107.128.153Zm-3.192 2.66-.134.148.129.117.133-.112-.128-.153ZM6.332 9.69l.134-.148-.149-.136-.134.15.148.134Zm-4.273 4.747-.149-.134-.051.057v.077h.2Zm15.881-1.333-.128.154.328.273v-.427h-.2ZM15.293 10.9l.128-.154-.128-.107-.128.107.128.154Zm2.648-7.252h.2-.2ZM2.06 12.855h-.2v.521l.349-.387-.15-.134ZM6.257 8.19l.134-.148-.149-.135-.134.15.149.133Zm5.874 5.342-.134.148.129.117.133-.112-.128-.153ZM16.351.8H3.648v.4h12.705V.8ZM19.2 3.647A2.85 2.85 0 0 0 16.352.8v.4A2.45 2.45 0 0 1 18.8 3.647h.4Zm0 12.706V3.647h-.4v12.706h.4ZM16.353 19.2a2.85 2.85 0 0 0 2.847-2.847h-.4a2.45 2.45 0 0 1-2.447 2.447v.4Zm-12.706 0h12.706v-.4H3.647v.4ZM.8 16.353A2.85 2.85 0 0 0 3.647 19.2v-.4A2.45 2.45 0 0 1 1.2 16.353H.8Zm0-12.706v12.706h.4V3.647H.8ZM3.647.8A2.85 2.85 0 0 0 .8 3.647h.4A2.45 2.45 0 0 1 3.647 1.2V.8ZM17.74 16.352a1.39 1.39 0 0 1-1.388 1.388v.4a1.79 1.79 0 0 0 1.788-1.788h-.4Zm.001-1.87v1.87h.4v-1.87h-.4Zm-2.575-2.052 2.647 2.205.256-.307-2.647-2.205-.256.307Zm-2.936 2.66 3.192-2.66-.256-.307-3.192 2.66.256.306ZM6.197 9.837l5.77 5.246.27-.296-5.771-5.246-.27.296Zm-3.99 4.733L6.48 9.823l-.297-.267-4.273 4.747.298.268Zm.052 1.78v-1.914h-.4v1.915h.4Zm1.388 1.39a1.39 1.39 0 0 1-1.388-1.39h-.4a1.79 1.79 0 0 0 1.788 1.79v-.4Zm12.705 0H3.647v.4h12.705v-.4Zm1.716-4.79-2.647-2.206-.256.307 2.647 2.206.256-.307Zm-.327-9.304v9.457h.4V3.647h-.4ZM16.353 2.26a1.39 1.39 0 0 1 1.388 1.388h.4a1.79 1.79 0 0 0-1.788-1.788v.4Zm-12.706 0h12.706v-.4H3.647v.4ZM2.26 3.647A1.39 1.39 0 0 1 3.647 2.26v-.4A1.79 1.79 0 0 0 1.86 3.647h.4Zm0 9.208V3.647h-.4v9.208h.4Zm3.849-4.798L1.91 12.721l.298.268 4.197-4.664-.297-.268Zm6.158 5.328L6.39 8.043l-.269.296 5.875 5.342.269-.296Zm2.899-2.64-3.162 2.634.256.307 3.162-2.634-.256-.307Zm-1.46-1.604a2.85 2.85 0 0 0 2.848-2.848h-.4a2.45 2.45 0 0 1-2.447 2.448v.4Zm-2.847-2.848a2.85 2.85 0 0 0 2.848 2.848v-.4a2.45 2.45 0 0 1-2.447-2.447h-.4Zm2.848-2.847a2.85 2.85 0 0 0-2.848 2.847h.4a2.45 2.45 0 0 1 2.448-2.447v-.4Zm2.847 2.847a2.85 2.85 0 0 0-2.847-2.847v.4a2.45 2.45 0 0 1 2.447 2.447h.4Zm-2.847-1.387a1.39 1.39 0 0 1 1.388 1.388h.4a1.79 1.79 0 0 0-1.788-1.788v.4Zm-1.389 1.388a1.39 1.39 0 0 1 1.389-1.388v-.4a1.79 1.79 0 0 0-1.789 1.788h.4Zm1.389 1.389a1.39 1.39 0 0 1-1.389-1.389h-.4a1.79 1.79 0 0 0 1.789 1.789v-.4Zm1.388-1.389a1.39 1.39 0 0 1-1.388 1.389v.4a1.79 1.79 0 0 0 1.788-1.789h-.4Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" fill="currentColor" stroke="currentColor" stroke-width=".4">
<path d="M15.063 19H4.937A3.937 3.937 0 0 1 1 15.062V4.938A3.937 3.937 0 0 1 4.938 1h5.625v1.125H4.936a2.813 2.813 0 0 0-2.812 2.813v10.125a2.812 2.812 0 0 0 2.813 2.812h10.125a2.812 2.812 0 0 0 2.812-2.813V9.439H19v5.624A3.937 3.937 0 0 1 15.062 19Z"/>
<path d="M15.063 8.875a3.938 3.938 0 1 1 0-7.875 3.938 3.938 0 0 1 0 7.875Zm0-6.75a2.812 2.812 0 1 0 0 5.624 2.812 2.812 0 0 0 0-5.624Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h20v20H0z"/>
</clipPath>
</defs>
</svg>
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 1a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h12a3 3 0 0 0 3-3V4a3 3 0 0 0-3-3H4Zm.2 1.2a2 2 0 0 0-2 2v11.6a2 2 0 0 0 2 2h11.6a2 2 0 0 0 2-2V4.2a2 2 0 0 0-2-2H4.2Z" fill="currentColor"/>
<rect x="4" y="5" width="12" height="1.4" rx=".7" fill="currentColor"/>
<rect x="4" y="8" width="8" height="1.4" rx=".7" fill="currentColor"/>
<rect x="4" y="10.8" width="12" height="1.4" rx=".7" fill="currentColor"/>
<rect x="4" y="13.6" width="9" height="1.4" rx=".7" fill="currentColor"/>
</svg>
...@@ -31,6 +31,7 @@ import type { ...@@ -31,6 +31,7 @@ import type {
AddressNFTTokensFilter, AddressNFTTokensFilter,
} from 'types/api/address'; } from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses'; import type { AddressesResponse } from 'types/api/addresses';
import type { TxBlobs, Blob } from 'types/api/blobs';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block'; import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters, BlockWithdrawalsResponse } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts'; import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
import type { BackendVersionConfig } from 'types/api/configs'; import type { BackendVersionConfig } from 'types/api/configs';
...@@ -84,9 +85,10 @@ import type { ...@@ -84,9 +85,10 @@ import type {
Transaction, Transaction,
TransactionsResponseWatchlist, TransactionsResponseWatchlist,
TransactionsSorting, TransactionsSorting,
TransactionsResponseWithBlobs,
} from 'types/api/transaction'; } from 'types/api/transaction';
import type { TxInterpretationResponse } from 'types/api/txInterpretation'; import type { TxInterpretationResponse } from 'types/api/txInterpretation';
import type { TTxsFilters } from 'types/api/txsFilters'; import type { TTxsFilters, TTxsWithBlobsFilters } from 'types/api/txsFilters';
import type { TxStateChanges } from 'types/api/txStateChanges'; import type { TxStateChanges } from 'types/api/txStateChanges';
import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps'; import type { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps';
import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse, ValidatorsSorting } from 'types/api/validators'; import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse, ValidatorsSorting } from 'types/api/validators';
...@@ -264,7 +266,7 @@ export const RESOURCES = { ...@@ -264,7 +266,7 @@ export const RESOURCES = {
block_txs: { block_txs: {
path: '/api/v2/blocks/:height_or_hash/transactions', path: '/api/v2/blocks/:height_or_hash/transactions',
pathParams: [ 'height_or_hash' as const ], pathParams: [ 'height_or_hash' as const ],
filterFields: [], filterFields: [ 'type' as const ],
}, },
block_withdrawals: { block_withdrawals: {
path: '/api/v2/blocks/:height_or_hash/withdrawals', path: '/api/v2/blocks/:height_or_hash/withdrawals',
...@@ -279,6 +281,10 @@ export const RESOURCES = { ...@@ -279,6 +281,10 @@ export const RESOURCES = {
path: '/api/v2/transactions', path: '/api/v2/transactions',
filterFields: [ 'filter' as const, 'type' as const, 'method' as const ], filterFields: [ 'filter' as const, 'type' as const, 'method' as const ],
}, },
txs_with_blobs: {
path: '/api/v2/transactions',
filterFields: [ 'type' as const ],
},
txs_watchlist: { txs_watchlist: {
path: '/api/v2/transactions/watchlist', path: '/api/v2/transactions/watchlist',
filterFields: [ ], filterFields: [ ],
...@@ -316,6 +322,10 @@ export const RESOURCES = { ...@@ -316,6 +322,10 @@ export const RESOURCES = {
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
filterFields: [], filterFields: [],
}, },
tx_blobs: {
path: '/api/v2/transactions/:hash/blobs',
pathParams: [ 'hash' as const ],
},
tx_interpretation: { tx_interpretation: {
path: '/api/v2/transactions/:hash/summary', path: '/api/v2/transactions/:hash/summary',
pathParams: [ 'hash' as const ], pathParams: [ 'hash' as const ],
...@@ -664,6 +674,12 @@ export const RESOURCES = { ...@@ -664,6 +674,12 @@ export const RESOURCES = {
pathParams: [ 'chainType' as const ], pathParams: [ 'chainType' as const ],
}, },
// BLOBS
blob: {
path: '/api/v2/blobs/:hash',
pathParams: [ 'hash' as const ],
},
// CONFIGS // CONFIGS
config_backend_version: { config_backend_version: {
path: '/api/v2/config/backend-version', path: '/api/v2/config/backend-version',
...@@ -723,8 +739,8 @@ export interface ResourceError<T = unknown> { ...@@ -723,8 +739,8 @@ export interface ResourceError<T = unknown> {
export type ResourceErrorAccount<T> = ResourceError<{ errors: T }> export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>
export type PaginatedResources = 'blocks' | 'block_txs' | export type PaginatedResources = 'blocks' | 'block_txs' |
'txs_validated' | 'txs_pending' | 'txs_watchlist' | 'txs_execution_node' | 'txs_validated' | 'txs_pending' | 'txs_with_blobs' | 'txs_watchlist' | 'txs_execution_node' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_blobs' |
'addresses' | 'addresses' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' | 'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' | 'search' |
...@@ -775,6 +791,7 @@ Q extends 'block_txs' ? BlockTransactionsResponse : ...@@ -775,6 +791,7 @@ Q extends 'block_txs' ? BlockTransactionsResponse :
Q extends 'block_withdrawals' ? BlockWithdrawalsResponse : Q extends 'block_withdrawals' ? BlockWithdrawalsResponse :
Q extends 'txs_validated' ? TransactionsResponseValidated : Q extends 'txs_validated' ? TransactionsResponseValidated :
Q extends 'txs_pending' ? TransactionsResponsePending : Q extends 'txs_pending' ? TransactionsResponsePending :
Q extends 'txs_with_blobs' ? TransactionsResponseWithBlobs :
Q extends 'txs_watchlist' ? TransactionsResponseWatchlist : Q extends 'txs_watchlist' ? TransactionsResponseWatchlist :
Q extends 'txs_execution_node' ? TransactionsResponseValidated : Q extends 'txs_execution_node' ? TransactionsResponseValidated :
Q extends 'tx' ? Transaction : Q extends 'tx' ? Transaction :
...@@ -783,6 +800,7 @@ Q extends 'tx_logs' ? LogsResponseTx : ...@@ -783,6 +800,7 @@ Q extends 'tx_logs' ? LogsResponseTx :
Q extends 'tx_token_transfers' ? TokenTransferResponse : Q extends 'tx_token_transfers' ? TokenTransferResponse :
Q extends 'tx_raw_trace' ? RawTracesResponse : Q extends 'tx_raw_trace' ? RawTracesResponse :
Q extends 'tx_state_changes' ? TxStateChanges : Q extends 'tx_state_changes' ? TxStateChanges :
Q extends 'tx_blobs' ? TxBlobs :
Q extends 'tx_interpretation' ? TxInterpretationResponse : Q extends 'tx_interpretation' ? TxInterpretationResponse :
Q extends 'addresses' ? AddressesResponse : Q extends 'addresses' ? AddressesResponse :
Q extends 'address' ? Address : Q extends 'address' ? Address :
...@@ -839,13 +857,6 @@ Q extends 'zkevm_l2_txn_batches_count' ? number : ...@@ -839,13 +857,6 @@ Q extends 'zkevm_l2_txn_batches_count' ? number :
Q extends 'zkevm_l2_txn_batch' ? ZkEvmL2TxnBatch : Q extends 'zkevm_l2_txn_batch' ? ZkEvmL2TxnBatch :
Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs : Q extends 'zkevm_l2_txn_batch_txs' ? ZkEvmL2TxnBatchTxs :
Q extends 'config_backend_version' ? BackendVersionConfig : Q extends 'config_backend_version' ? BackendVersionConfig :
Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
never; never;
// !!! IMPORTANT !!! // !!! IMPORTANT !!!
// See comment above // See comment above
...@@ -853,6 +864,7 @@ never; ...@@ -853,6 +864,7 @@ never;
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
export type ResourcePayloadB<Q extends ResourceName> = export type ResourcePayloadB<Q extends ResourceName> =
Q extends 'blob' ? Blob :
Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> : Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> :
Q extends 'marketplace_dapp' ? MarketplaceAppOverview : Q extends 'marketplace_dapp' ? MarketplaceAppOverview :
Q extends 'validators' ? ValidatorsResponse : Q extends 'validators' ? ValidatorsResponse :
...@@ -862,6 +874,13 @@ Q extends 'shibarium_deposits' ? ShibariumDepositsResponse : ...@@ -862,6 +874,13 @@ Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
Q extends 'shibarium_withdrawals_count' ? number : Q extends 'shibarium_withdrawals_count' ? number :
Q extends 'shibarium_deposits_count' ? number : Q extends 'shibarium_deposits_count' ? number :
Q extends 'contract_security_audits' ? SmartContractSecurityAudits : Q extends 'contract_security_audits' ? SmartContractSecurityAudits :
Q extends 'addresses_lookup' ? EnsAddressLookupResponse :
Q extends 'domain_info' ? EnsDomainDetailed :
Q extends 'domain_events' ? EnsDomainEventsResponse :
Q extends 'domains_lookup' ? EnsDomainLookupResponse :
Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
...@@ -874,7 +893,9 @@ export type PaginatedResponseNextPageParams<Q extends ResourceName> = Q extends ...@@ -874,7 +893,9 @@ export type PaginatedResponseNextPageParams<Q extends ResourceName> = Q extends
/* eslint-disable @typescript-eslint/indent */ /* eslint-disable @typescript-eslint/indent */
export type PaginationFilters<Q extends PaginatedResources> = export type PaginationFilters<Q extends PaginatedResources> =
Q extends 'blocks' ? BlockFilters : Q extends 'blocks' ? BlockFilters :
Q extends 'block_txs' ? TTxsWithBlobsFilters :
Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters : Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
Q extends 'txs_with_blobs' ? TTxsWithBlobsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters : Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'token_transfers' ? TokenTransferFilters : Q extends 'token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters : Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
......
import filetype from 'magic-bytes.js';
import hexToBytes from 'lib/hexToBytes';
export default function guessDataType(data: string) {
const bytes = new Uint8Array(hexToBytes(data));
return filetype(bytes)[0];
}
export { default as guessDataType } from './guessDataType';
import hexToBytes from './hexToBytes';
export default function hexToBase64(hex: string) {
const bytes = new Uint8Array(hexToBytes(hex));
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
const base64String = btoa(binary);
return base64String;
}
// hex can be with prefix - `0x{string}` - or without it - `{string}`
export default function hexToBytes(hex: string) { export default function hexToBytes(hex: string) {
const bytes = []; const bytes = [];
for (let c = 0; c < hex.length; c += 2) { const startIndex = hex.startsWith('0x') ? 2 : 0;
for (let c = startIndex; c < hex.length; c += 2) {
bytes.push(parseInt(hex.substring(c, c + 2), 16)); bytes.push(parseInt(hex.substring(c, c + 2), 16));
} }
return bytes; return bytes;
......
...@@ -37,6 +37,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = { ...@@ -37,6 +37,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/output-roots': 'Root page', '/output-roots': 'Root page',
'/batches': 'Root page', '/batches': 'Root page',
'/batches/[number]': 'Regular page', '/batches/[number]': 'Regular page',
'/blobs/[hash]': 'Regular page',
'/ops': 'Root page', '/ops': 'Root page',
'/op/[hash]': 'Regular page', '/op/[hash]': 'Regular page',
'/404': 'Regular page', '/404': 'Regular page',
......
...@@ -40,6 +40,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -40,6 +40,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/output-roots': DEFAULT_TEMPLATE, '/output-roots': DEFAULT_TEMPLATE,
'/batches': DEFAULT_TEMPLATE, '/batches': DEFAULT_TEMPLATE,
'/batches/[number]': DEFAULT_TEMPLATE, '/batches/[number]': DEFAULT_TEMPLATE,
'/blobs/[hash]': DEFAULT_TEMPLATE,
'/ops': DEFAULT_TEMPLATE, '/ops': DEFAULT_TEMPLATE,
'/op/[hash]': DEFAULT_TEMPLATE, '/op/[hash]': DEFAULT_TEMPLATE,
'/404': DEFAULT_TEMPLATE, '/404': DEFAULT_TEMPLATE,
......
...@@ -35,6 +35,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = { ...@@ -35,6 +35,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/output-roots': 'output roots', '/output-roots': 'output roots',
'/batches': 'tx batches (L2 blocks)', '/batches': 'tx batches (L2 blocks)',
'/batches/[number]': 'L2 tx batch %number%', '/batches/[number]': 'L2 tx batch %number%',
'/blobs/[hash]': 'blob %hash% details',
'/ops': 'user operations', '/ops': 'user operations',
'/op/[hash]': 'user operation %hash%', '/op/[hash]': 'user operation %hash%',
'/404': 'error - page not found', '/404': 'error - page not found',
......
...@@ -35,6 +35,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = { ...@@ -35,6 +35,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/output-roots': 'Output roots', '/output-roots': 'Output roots',
'/batches': 'Tx batches (L2 blocks)', '/batches': 'Tx batches (L2 blocks)',
'/batches/[number]': 'L2 tx batch details', '/batches/[number]': 'L2 tx batch details',
'/blobs/[hash]': 'Blob details',
'/ops': 'User operations', '/ops': 'User operations',
'/op/[hash]': 'User operation details', '/op/[hash]': 'User operation details',
'/404': '404', '/404': '404',
......
import type { Blob, TxBlobs } from 'types/api/blobs';
export const base1: Blob = {
blob_data: '0x004242004242004242004242004242004242',
hash: '0x016316f61a259aa607096440fc3eeb90356e079be01975d2fb18347bd50df33c',
kzg_commitment: '0xa95caabd009e189b9f205e0328ff847ad886e4f8e719bd7219875fbb9688fb3fbe7704bb1dfa7e2993a3dea8d0cf767d',
kzg_proof: '0x89cf91c4c8be6f2a390d4262425f79dffb74c174fb15a210182184543bf7394e5a7970a774ee8e0dabc315424c22df0f',
transaction_hashes: [
{ block_consensus: true, transaction_hash: '0x970d8c45c713a50a1fa351b00ca29a8890cac474c59cc8eee4eddec91a1729f0' },
],
};
export const base2: Blob = {
blob_data: '0x89504E470D0A1A0A0000000D494844520000003C0000003C0403',
hash: '0x0197fdb17195c176b23160f335daabd4b6a231aaaadd73ec567877c66a3affd1',
kzg_commitment: '0x89b0d8ac715ee134135471994a161ef068a784f51982fcd7161aa8e3e818eb83017ccfbfc30c89b796a2743d77554e2f',
kzg_proof: '0x8255a6c6a236483814b8e68992e70f3523f546866a9fed6b8e0ecfef314c65634113b8aa02d6c5c6e91b46e140f17a07',
transaction_hashes: [
{ block_consensus: true, transaction_hash: '0x22d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193' },
],
};
export const txBlobs: TxBlobs = {
items: [ base1, base2 ],
next_page_params: null,
};
...@@ -135,6 +135,15 @@ export const rootstock: Block = { ...@@ -135,6 +135,15 @@ export const rootstock: Block = {
minimum_gas_price: '59240000', minimum_gas_price: '59240000',
}; };
export const withBlobTxs: Block = {
...base,
blob_gas_price: '21518435987',
blob_gas_used: '393216',
burnt_blob_fees: '8461393325064192',
excess_blob_gas: '79429632',
blob_tx_count: 1,
};
export const baseListResponse: BlocksResponse = { export const baseListResponse: BlocksResponse = {
items: [ items: [
base, base,
......
...@@ -6,6 +6,7 @@ import type { ...@@ -6,6 +6,7 @@ import type {
SearchResultLabel, SearchResultLabel,
SearchResult, SearchResult,
SearchResultUserOp, SearchResultUserOp,
SearchResultBlob,
} from 'types/api/search'; } from 'types/api/search';
export const token1: SearchResultToken = { export const token1: SearchResultToken = {
...@@ -116,6 +117,12 @@ export const userOp1: SearchResultUserOp = { ...@@ -116,6 +117,12 @@ export const userOp1: SearchResultUserOp = {
url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61', url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
}; };
export const blob1: SearchResultBlob = {
blob_hash: '0x0108dd3e414da9f3255f7a831afa606e8dfaea93d082dfa9b15305583cbbdbbe',
type: 'blob' as const,
timestamp: null,
};
export const baseResponse: SearchResult = { export const baseResponse: SearchResult = {
items: [ items: [
token1, token1,
...@@ -124,6 +131,7 @@ export const baseResponse: SearchResult = { ...@@ -124,6 +131,7 @@ export const baseResponse: SearchResult = {
address1, address1,
contract1, contract1,
tx1, tx1,
blob1,
], ],
next_page_params: null, next_page_params: null,
}; };
...@@ -341,3 +341,17 @@ export const base4 = { ...@@ -341,3 +341,17 @@ export const base4 = {
...base, ...base,
hash: '0x22d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193', hash: '0x22d597ebcf3e8d60096dd0363bc2f0f5e2df27ba1dacd696c51aa7c9409f3193',
}; };
export const withBlob = {
...base,
blob_gas_price: '21518435987',
blob_gas_used: '131072',
blob_versioned_hashes: [
'0x01a8c328b0370068aaaef49c107f70901cd79adcda81e3599a88855532122e09',
'0x0197fdb17195c176b23160f335daabd4b6a231aaaadd73ec567877c66a3affd1',
],
burnt_blob_fee: '2820464441688064',
max_fee_per_blob_gas: '60000000000',
tx_types: [ 'blob_transaction' as const ],
type: 3,
};
...@@ -201,3 +201,13 @@ export const gasTracker: GetServerSideProps<Props> = async(context) => { ...@@ -201,3 +201,13 @@ export const gasTracker: GetServerSideProps<Props> = async(context) => {
return base(context); return base(context);
}; };
export const blobs: GetServerSideProps<Props> = async(context) => {
if (config.UI.views.tx.hiddenViews?.blob_txs) {
return {
notFound: true,
};
}
return base(context);
};
...@@ -28,6 +28,7 @@ declare module "nextjs-routes" { ...@@ -28,6 +28,7 @@ declare module "nextjs-routes" {
| StaticRoute<"/auth/unverified-email"> | StaticRoute<"/auth/unverified-email">
| DynamicRoute<"/batches/[number]", { "number": string }> | DynamicRoute<"/batches/[number]", { "number": string }>
| StaticRoute<"/batches"> | StaticRoute<"/batches">
| DynamicRoute<"/blobs/[hash]", { "hash": string }>
| DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }> | DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }>
| StaticRoute<"/blocks"> | StaticRoute<"/blocks">
| StaticRoute<"/contract-verification"> | StaticRoute<"/contract-verification">
......
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 Blob = dynamic(() => import('ui/pages/Blob'), { ssr: false });
const Page: NextPage<Props> = (props: Props) => {
return (
<PageNextJs pathname="/blobs/[hash]" query={ props }>
<Blob/>
</PageNextJs>
);
};
export default Page;
export { blobs as getServerSideProps } from 'nextjs/getServerSideProps';
...@@ -11,6 +11,10 @@ ...@@ -11,6 +11,10 @@
| "arrows/north-east" | "arrows/north-east"
| "arrows/south-east" | "arrows/south-east"
| "arrows/up-down" | "arrows/up-down"
| "blob"
| "blobs/image"
| "blobs/raw"
| "blobs/text"
| "block_slim" | "block_slim"
| "block" | "block"
| "brands/safe" | "brands/safe"
......
import type { Blob, TxBlob } from 'types/api/blobs';
import { TX_HASH } from './tx';
const BLOB_HASH = '0x0137cd898a9aaa92bbe94999d2a98241f5eabc829d9354160061789963f85995';
const BLOB_PROOF = '0x82683d5d6e58a76f2a607b8712cad113621d46cb86a6bcfcffb1e274a70c7308b3243c6075ee22d904fecf8d4c147c6f';
export const TX_BLOB: TxBlob = {
blob_data: '0x010203040506070809101112',
hash: BLOB_HASH,
kzg_commitment: BLOB_PROOF,
kzg_proof: BLOB_PROOF,
};
export const BLOB: Blob = {
...TX_BLOB,
transaction_hashes: [
{ block_consensus: true, transaction_hash: TX_HASH },
],
};
export interface TxBlob {
hash: string;
blob_data: string;
kzg_commitment: string;
kzg_proof: string;
}
export type TxBlobs = {
items: Array<TxBlob>;
next_page_params: null;
};
export interface Blob extends TxBlob {
transaction_hashes: Array<{
block_consensus: boolean;
transaction_hash: string;
}>;
}
...@@ -36,6 +36,12 @@ export interface Block { ...@@ -36,6 +36,12 @@ export interface Block {
bitcoin_merged_mining_merkle_proof?: string | null; bitcoin_merged_mining_merkle_proof?: string | null;
hash_for_merged_mining?: string | null; hash_for_merged_mining?: string | null;
minimum_gas_price?: string | null; minimum_gas_price?: string | null;
// BLOB FIELDS
blob_gas_price?: string;
blob_gas_used?: string;
burnt_blob_fees?: string;
excess_blob_gas?: string;
blob_tx_count?: number;
} }
export interface BlocksResponse { export interface BlocksResponse {
......
...@@ -55,6 +55,12 @@ export interface SearchResultTx { ...@@ -55,6 +55,12 @@ export interface SearchResultTx {
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
} }
export interface SearchResultBlob {
type: 'blob';
blob_hash: string;
timestamp: null;
}
export interface SearchResultUserOp { export interface SearchResultUserOp {
type: 'user_operation'; type: 'user_operation';
user_operation_hash: string; user_operation_hash: string;
...@@ -62,7 +68,8 @@ export interface SearchResultUserOp { ...@@ -62,7 +68,8 @@ export interface SearchResultUserOp {
url?: string; // not used by the frontend, we build the url ourselves url?: string; // not used by the frontend, we build the url ourselves
} }
export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp; export type SearchResultItem = SearchResultToken | SearchResultAddressOrContract | SearchResultBlock | SearchResultTx | SearchResultLabel | SearchResultUserOp |
SearchResultBlob;
export interface SearchResult { export interface SearchResult {
items: Array<SearchResultItem>; items: Array<SearchResultItem>;
...@@ -86,5 +93,5 @@ export interface SearchResultFilters { ...@@ -86,5 +93,5 @@ export interface SearchResultFilters {
export interface SearchRedirectResult { export interface SearchRedirectResult {
parameter: string | null; parameter: string | null;
redirect: boolean; redirect: boolean;
type: 'address' | 'block' | 'transaction' | 'user_operation' | null; type: 'address' | 'block' | 'transaction' | 'user_operation' | 'blob' | null;
} }
...@@ -79,6 +79,12 @@ export type Transaction = { ...@@ -79,6 +79,12 @@ export type Transaction = {
zkevm_batch_number?: number; zkevm_batch_number?: number;
zkevm_status?: typeof ZKEVM_L2_TX_STATUSES[number]; zkevm_status?: typeof ZKEVM_L2_TX_STATUSES[number];
zkevm_sequence_hash?: string; zkevm_sequence_hash?: string;
// blob tx fields
blob_versioned_hashes?: Array<string>;
blob_gas_used?: string;
blob_gas_price?: string;
burnt_blob_fee?: string;
max_fee_per_blob_gas?: string;
} }
export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ]; export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ];
...@@ -104,6 +110,15 @@ export interface TransactionsResponsePending { ...@@ -104,6 +110,15 @@ export interface TransactionsResponsePending {
} | null; } | null;
} }
export interface TransactionsResponseWithBlobs {
items: Array<Transaction>;
next_page_params: {
block_number: number;
index: number;
items_count: number;
} | null;
}
export interface TransactionsResponseWatchlist { export interface TransactionsResponseWatchlist {
items: Array<Transaction>; items: Array<Transaction>;
next_page_params: { next_page_params: {
...@@ -119,7 +134,8 @@ export type TransactionType = 'rootstock_remasc' | ...@@ -119,7 +134,8 @@ export type TransactionType = 'rootstock_remasc' |
'contract_creation' | 'contract_creation' |
'contract_call' | 'contract_call' |
'token_creation' | 'token_creation' |
'coin_transfer' 'coin_transfer' |
'blob_transaction'
export type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse; export type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse;
......
...@@ -4,6 +4,10 @@ export type TTxsFilters = { ...@@ -4,6 +4,10 @@ export type TTxsFilters = {
method?: Array<MethodFilter>; method?: Array<MethodFilter>;
} }
export type TypeFilter = 'token_transfer' | 'contract_creation' | 'contract_call' | 'coin_transfer' | 'token_creation'; export type TTxsWithBlobsFilters = {
type: 'blob_transaction';
}
export type TypeFilter = 'token_transfer' | 'contract_creation' | 'contract_call' | 'coin_transfer' | 'token_creation' | 'blob_transaction';
export type MethodFilter = 'approve' | 'transfer' | 'multicall' | 'mint' | 'commit'; export type MethodFilter = 'approve' | 'transfer' | 'multicall' | 'mint' | 'commit';
...@@ -16,3 +16,9 @@ export const TX_ADDITIONAL_FIELDS_IDS = [ ...@@ -16,3 +16,9 @@ export const TX_ADDITIONAL_FIELDS_IDS = [
] as const; ] as const;
export type TxAdditionalFieldsId = ArrayElement<typeof TX_ADDITIONAL_FIELDS_IDS>; export type TxAdditionalFieldsId = ArrayElement<typeof TX_ADDITIONAL_FIELDS_IDS>;
export const TX_VIEWS_IDS = [
'blob_txs',
] as const;
export type TxViewId = ArrayElement<typeof TX_VIEWS_IDS>;
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import BlobData from './BlobData';
test.use({ viewport: { width: 500, height: 300 } });
test('text', async({ mount }) => {
// eslint-disable-next-line max-len
const data = '0xE2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A280E2A3A4E2A1B6E2A0BFE2A0BFE2A0B7E2A3B6E2A384E2A080E2A080E2A080E2A080E2A0800AE2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A3B0E2A1BFE2A081E2A080E2A080E2A280E2A380E2A180E2A099E2A3B7E2A180E2A080E2A080E2A0800AE2A080E2A080E2A080E2A180E2A080E2A080E2A080E2A080E2A080E2A2A0E2A3BFE2A081E2A080E2A080E2A080E2A098E2A0BFE2A083E2A080E2A2B8E2A3BFE2A3BFE2A3BFE2A3BF0AE2A080E2A3A0E2A1BFE2A09BE2A2B7E2A3A6E2A180E2A080E2A080E2A088E2A3BFE2A184E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A3B8E2A3BFE2A3BFE2A3BFE2A09F0AE2A2B0E2A1BFE2A081E2A080E2A080E2A099E2A2BFE2A3A6E2A3A4E2A3A4E2A3BCE2A3BFE2A384E2A080E2A080E2A080E2A080E2A080E2A2B4E2A19FE2A09BE2A08BE2A081E2A0800AE2A3BFE2A087E2A080E2A080E2A080E2A080E2A080E2A089E2A089E2A089E2A089E2A089E2A081E2A080E2A080E2A080E2A080E2A080E2A088E2A3BFE2A180E2A080E2A080E2A0800AE2A3BFE2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A2B9E2A187E2A080E2A080E2A0800AE2A3BFE2A186E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A3BCE2A187E2A080E2A080E2A0800AE2A0B8E2A3B7E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A2A0E2A1BFE2A080E2A080E2A080E2A0800AE2A080E2A0B9E2A3B7E2A3A4E2A380E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A080E2A380E2A3B0E2A1BFE2A081E2A080E2A080E2A080E2A0800AE2A080E2A080E2A080E2A089E2A099E2A09BE2A0BFE2A0B6E2A3B6E2A3B6E2A3B6E2A3B6E2A3B6E2A0B6E2A0BFE2A09FE2A09BE2A089E2A080E2A080E2A080E2A080E2A080E2A080';
const component = await mount(
<TestApp>
<BlobData hash="0x01" data={ data }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
await component.locator('select').selectOption('UTF-8');
await expect(component).toHaveScreenshot();
});
test('image', async({ mount }) => {
// eslint-disable-next-line max-len
const data = '0x89504E470D0A1A0A0000000D494844520000003C0000003C0403000000C8D2C4410000000467414D410000B18F0BFC6105000000017352474200AECE1CE900000027504C54454C69712B6CB02A6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB02B6CB0F4205A540000000C74524E5300ED2F788CD91B99475C09B969CFA99D0000004F7A5458745261772070726F66696C65207479706520697074630000789CE3CA2C2849E6520003230B2E630B1323134B9314031320448034C3640323B35420CBD8D4C8C4CCC41CC407CB8048A04A2E0028950EE32A226D1F0000000970485973000084DF000084DF0195C81C33000000F24944415438CB636000018E983367CE482780D90CDA40F6991D0C4820152472A60ACCE6DA03629F4E40929E03961602B39964C09C0624691B24690E88F48461215D03160903B3D962C01C07842C2758C341A80643B0B40484C3646C6C5C78E6E016171723A8E215262EEE31670E161B1B7731304C05AB155EC08002C0D172E6F80206884DBB50651938CF4003FE0CBA4390E3C56064482F53525252C329CD562A2828283A0197340B22AAB0494332C311FCD2C747A547A58996C69998D8F12745B68DA0846C85331B2CEAE8E8681A81D91F8B348C4605D0527B02A4283FA88026CD05163EAAC0900ED21EC9800EC0C2110C002BBA9FE999B920330000000049454E44AE426082';
const component = await mount(
<TestApp>
<BlobData hash="0x01" data={ data }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
await component.locator('select').selectOption('Base64');
await expect(component).toHaveScreenshot();
});
import { Flex, GridItem, Select, Skeleton, Button } from '@chakra-ui/react';
import React from 'react';
import * as blobUtils from 'lib/blob';
import downloadBlob from 'lib/downloadBlob';
import hexToBase64 from 'lib/hexToBase64';
import hexToBytes from 'lib/hexToBytes';
import hexToUtf8 from 'lib/hexToUtf8';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
import BlobDataImage from './BlobDataImage';
const FORMATS = [ 'Image', 'Raw', 'UTF-8', 'Base64' ] as const;
type Format = typeof FORMATS[number];
interface Props {
data: string;
hash: string;
isLoading?: boolean;
}
const BlobData = ({ data, isLoading, hash }: Props) => {
const [ format, setFormat ] = React.useState<Format>('Raw');
const guessedType = React.useMemo(() => {
if (isLoading) {
return;
}
return blobUtils.guessDataType(data);
}, [ data, isLoading ]);
const isImage = guessedType?.mime?.startsWith('image/');
const formats = isImage ? FORMATS : FORMATS.filter((format) => format !== 'Image');
React.useEffect(() => {
if (isImage) {
setFormat('Image');
}
}, [ isImage ]);
const handleSelectChange = React.useCallback((event: React.ChangeEvent<HTMLSelectElement>) => {
setFormat(event.target.value as Format);
}, []);
const handleDownloadButtonClick = React.useCallback(() => {
const fileBlob = (() => {
switch (format) {
case 'Image': {
const bytes = new Uint8Array(hexToBytes(data));
return new Blob([ bytes ], { type: guessedType?.mime });
}
case 'UTF-8': {
return new Blob([ hexToUtf8(data) ], { type: guessedType?.mime ?? 'text/plain' });
}
case 'Base64': {
return new Blob([ hexToBase64(data) ], { type: 'application/octet-stream' });
}
case 'Raw': {
return new Blob([ data ], { type: 'application/octet-stream' });
}
}
})();
const fileName = `blob_${ hash }`;
downloadBlob(fileBlob, fileName);
}, [ data, format, guessedType, hash ]);
const content = (() => {
switch (format) {
case 'Image': {
if (!guessedType?.mime?.startsWith('image/')) {
return <RawDataSnippet data="Not an image" showCopy={ false } isLoading={ isLoading }/>;
}
const base64 = hexToBase64(data);
const imgSrc = `data:${ guessedType.mime };base64,${ base64 }`;
return <BlobDataImage src={ imgSrc }/>;
}
case 'UTF-8':
return <RawDataSnippet data={ hexToUtf8(data) } showCopy={ false } isLoading={ isLoading }/>;
case 'Base64':
return <RawDataSnippet data={ hexToBase64(data) } showCopy={ false } isLoading={ isLoading }/>;
case 'Raw':
return <RawDataSnippet data={ data } showCopy={ false } isLoading={ isLoading }/>;
default:
return <span>fallback</span>;
}
})();
return (
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 3, lg: 2 }}>
<Flex alignItems="center" mb={ 3 }>
<Skeleton fontWeight={{ base: 700, lg: 500 }} isLoaded={ !isLoading }>
Blob data
</Skeleton>
<Skeleton ml={ 5 } isLoaded={ !isLoading }>
<Select
size="xs"
borderRadius="base"
value={ format }
onChange={ handleSelectChange }
focusBorderColor="none"
w="auto"
>
{ formats.map((format) => (
<option key={ format } value={ format }>{ format }</option>
)) }
</Select>
</Skeleton>
<Skeleton ml="auto" mr={ 3 } isLoaded={ !isLoading }>
<Button
variant="outline"
size="sm"
onClick={ handleDownloadButtonClick }
>
Download
</Button>
</Skeleton>
<CopyToClipboard text={ JSON.stringify(data) } isLoading={ isLoading }/>
</Flex>
{ content }
</GridItem>
);
};
export default React.memo(BlobData);
import { Image, Center, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
interface Props {
src: string;
}
const BlobDataImage = ({ src }: Props) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
<Center
bgColor={ bgColor }
p={ 4 }
minH="200px"
w="100%"
borderRadius="md"
>
<Image
src={ src }
objectFit="contain"
maxW="100%"
maxH="100%"
objectPosition="center"
alt="Blob image representation"
/>
</Center>
);
};
export default React.memo(BlobDataImage);
import { Grid, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { Blob } from 'types/api/blobs';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import DetailsSponsoredItem from 'ui/shared/DetailsSponsoredItem';
import TxEntity from 'ui/shared/entities/tx/TxEntity';
import BlobData from './BlobData';
interface Props {
data: Blob;
isLoading?: boolean;
}
const BlobInfo = ({ data, isLoading }: Props) => {
const size = data.blob_data.replace('0x', '').length / 2;
return (
<Grid
columnGap={ 8 }
rowGap={ 3 }
templateColumns={{ base: 'minmax(0, 1fr)', lg: '216px minmax(728px, auto)' }}
>
<DetailsInfoItem
title="Proof"
hint="Zero knowledge proof. Allows for quick verification of commitment"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden" whiteSpace="pre-wrap" wordBreak="break-all" lineHeight={ 6 } my="-2px">
{ data.kzg_proof }
<CopyToClipboard text={ data.kzg_proof } isLoading={ isLoading }/>
</Skeleton>
</DetailsInfoItem>
<DetailsInfoItem
title="Commitment"
hint="Commitment to the data in the blob"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden" whiteSpace="pre-wrap" wordBreak="break-all" lineHeight={ 6 } my="-2px">
{ data.kzg_commitment }
<CopyToClipboard text={ data.kzg_commitment } isLoading={ isLoading }/>
</Skeleton>
</DetailsInfoItem>
<DetailsInfoItem
title="Size, bytes"
hint="Blob size in bytes"
isLoading={ isLoading }
>
<Skeleton isLoaded={ !isLoading } overflow="hidden" whiteSpace="pre-wrap" wordBreak="break-all">
{ size.toLocaleString() }
</Skeleton>
</DetailsInfoItem>
<DetailsInfoItemDivider/>
{ data.transaction_hashes[0] && (
<DetailsInfoItem
title="Transaction hash"
hint="Hash of the transaction with this blob"
isLoading={ isLoading }
>
<TxEntity hash={ data.transaction_hashes[0].transaction_hash } isLoading={ isLoading } noIcon noCopy={ false }/>
</DetailsInfoItem>
) }
<DetailsSponsoredItem isLoading={ isLoading }/>
<DetailsInfoItemDivider/>
<BlobData data={ data.blob_data } hash={ data.hash } isLoading={ isLoading }/>
</Grid>
);
};
export default React.memo(BlobInfo);
...@@ -51,6 +51,24 @@ test('genesis block', async({ mount, page }) => { ...@@ -51,6 +51,24 @@ test('genesis block', async({ mount, page }) => {
await expect(component).toHaveScreenshot(); await expect(component).toHaveScreenshot();
}); });
test('with blob txs', async({ mount, page }) => {
const query = {
data: blockMock.withBlobTxs,
isPending: false,
} as BlockQuery;
const component = await mount(
<TestApp>
<BlockDetails query={ query }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click();
await expect(component).toHaveScreenshot();
});
const customFieldsTest = test.extend({ const customFieldsTest = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any, context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any,
......
...@@ -28,6 +28,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet'; ...@@ -28,6 +28,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
import BlockDetailsBlobInfo from './details/BlockDetailsBlobInfo';
import type { BlockQuery } from './useBlockQuery'; import type { BlockQuery } from './useBlockQuery';
interface Props { interface Props {
...@@ -114,6 +115,31 @@ const BlockDetails = ({ query }: Props) => { ...@@ -114,6 +115,31 @@ const BlockDetails = ({ query }: Props) => {
return config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by'; return config.chain.verificationType === 'validation' ? 'Validated by' : 'Mined by';
})(); })();
const txsNum = (() => {
const blockTxsNum = (
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }) }>
{ data.tx_count } txn{ data.tx_count === 1 ? '' : 's' }
</LinkInternal>
);
const blockBlobTxsNum = data.blob_tx_count ? (
<>
<span> and </span>
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'blob_txs' } }) }>
{ data.blob_tx_count } blob txn{ data.blob_tx_count === 1 ? '' : 's' }
</LinkInternal>
</>
) : null;
return (
<>
{ blockTxsNum }
{ blockBlobTxsNum }
<span> in this block</span>
</>
);
})();
const blockTypeLabel = (() => { const blockTypeLabel = (() => {
switch (data.type) { switch (data.type) {
case 'reorg': case 'reorg':
...@@ -172,9 +198,7 @@ const BlockDetails = ({ query }: Props) => { ...@@ -172,9 +198,7 @@ const BlockDetails = ({ query }: Props) => {
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
> >
<Skeleton isLoaded={ !isPlaceholderData }> <Skeleton isLoaded={ !isPlaceholderData }>
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }) }> { txsNum }
{ data.tx_count } transaction{ data.tx_count === 1 ? '' : 's' }
</LinkInternal>
</Skeleton> </Skeleton>
</DetailsInfoItem> </DetailsInfoItem>
{ config.features.beaconChain.isEnabled && Boolean(data.withdrawals_count) && ( { config.features.beaconChain.isEnabled && Boolean(data.withdrawals_count) && (
...@@ -364,6 +388,8 @@ const BlockDetails = ({ query }: Props) => { ...@@ -364,6 +388,8 @@ const BlockDetails = ({ query }: Props) => {
<> <>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/> <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
{ !isPlaceholderData && <BlockDetailsBlobInfo data={ data }/> }
{ data.bitcoin_merged_mining_header && ( { data.bitcoin_merged_mining_header && (
<DetailsInfoItem <DetailsInfoItem
title="Bitcoin merged mining header" title="Bitcoin merged mining header"
......
import { Text, Tooltip } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { Block } from 'types/api/block';
import { WEI, WEI_IN_GWEI, ZERO } from 'lib/consts';
import { space } from 'lib/html-entities';
import { currencyUnits } from 'lib/units';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import DetailsInfoItemDivider from 'ui/shared/DetailsInfoItemDivider';
import IconSvg from 'ui/shared/IconSvg';
import Utilization from 'ui/shared/Utilization/Utilization';
interface Props {
data: Block;
}
const BlockDetailsBlobInfo = ({ data }: Props) => {
if (
!data.blob_gas_price ||
!data.blob_gas_used ||
!data.burnt_blob_fees ||
!data.excess_blob_gas
) {
return null;
}
const burntBlobFees = BigNumber(data.burnt_blob_fees || 0);
const blobFees = BigNumber(data.blob_gas_price || 0).multipliedBy(BigNumber(data.blob_gas_used || 0));
return (
<>
{ data.blob_gas_price && (
<DetailsInfoItem
title="Blob gas price"
// eslint-disable-next-line max-len
hint="Price per unit of gas used for for blob deployment. Blob gas is independent of normal gas. Both gas prices can affect the priority of transaction execution."
>
<Text>{ BigNumber(data.blob_gas_price).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.blob_gas_price).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem>
) }
{ data.blob_gas_used && (
<DetailsInfoItem
title="Blob gas used"
hint="Actual amount of gas used by the blobs in this block"
>
<Text>{ BigNumber(data.blob_gas_used).toFormat() }</Text>
</DetailsInfoItem>
) }
{ !burntBlobFees.isEqualTo(ZERO) && (
<DetailsInfoItem
title="Blob burnt fees"
hint={ `Amount of ${ currencyUnits.ether } used for blobs in this block` }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500" mr={ 2 }/>
{ burntBlobFees.dividedBy(WEI).toFixed() } { currencyUnits.ether }
{ !blobFees.isEqualTo(ZERO) && (
<Tooltip label="Blob burnt fees / Txn fees * 100%">
<div>
<Utilization ml={ 4 } value={ burntBlobFees.dividedBy(blobFees).toNumber() }/>
</div>
</Tooltip>
) }
</DetailsInfoItem>
) }
{ data.excess_blob_gas && (
<DetailsInfoItem
title="Excess blob gas"
hint="A running total of blob gas consumed in excess of the target, prior to the block."
>
<Text>{ BigNumber(data.excess_blob_gas).dividedBy(WEI).toFixed() } { currencyUnits.ether } </Text>
<Text variant="secondary" whiteSpace="pre">
{ space }({ BigNumber(data.excess_blob_gas).dividedBy(WEI_IN_GWEI).toFixed() } { currencyUnits.gwei })
</Text>
</DetailsInfoItem>
) }
<DetailsInfoItemDivider/>
</>
);
};
export default React.memo(BlockDetailsBlobInfo);
import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import type { BlockQuery } from './useBlockQuery';
interface Params {
heightOrHash: string;
blockQuery: BlockQuery;
tab: string;
}
export default function useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab }: Params) {
const apiQuery = useQueryWithPages({
resourceName: 'block_txs',
pathParams: { height_or_hash: heightOrHash },
filters: { type: 'blob_transaction' },
options: {
enabled: Boolean(tab === 'blob_txs' && !blockQuery.isPlaceholderData && blockQuery.data?.blob_tx_count),
placeholderData: generateListStub<'block_txs'>(TX, 3, { next_page_params: null }),
refetchOnMount: false,
},
});
return apiQuery;
}
...@@ -33,7 +33,7 @@ interface Params { ...@@ -33,7 +33,7 @@ interface Params {
tab: string; tab: string;
} }
export default function useBlockTxQuery({ heightOrHash, blockQuery, tab }: Params): BlockTxsQuery { export default function useBlockTxsQuery({ heightOrHash, blockQuery, tab }: Params): BlockTxsQuery {
const [ isRefetchEnabled, setRefetchEnabled ] = React.useState(false); const [ isRefetchEnabled, setRefetchEnabled ] = React.useState(false);
const apiQuery = useQueryWithPages({ const apiQuery = useQueryWithPages({
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as blobsMock from 'mocks/blobs/blobs';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import * as configs from 'playwright/utils/configs';
import Blob from './Blob';
const BLOB_API_URL = buildApiUrl('blob', { hash: blobsMock.base1.hash });
const hooksConfig = {
router: {
query: { hash: blobsMock.base1.hash },
},
};
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(BLOB_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blobsMock.base1),
}));
const component = await mount(
<TestApp>
<Blob/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import throwOnResourceLoadError from 'lib/errors/throwOnResourceLoadError';
import getQueryParamString from 'lib/router/getQueryParamString';
import { BLOB } from 'stubs/blobs';
import BlobInfo from 'ui/blob/BlobInfo';
import TextAd from 'ui/shared/ad/TextAd';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import PageTitle from 'ui/shared/Page/PageTitle';
const BlobPageContent = () => {
const router = useRouter();
const hash = getQueryParamString(router.query.hash);
const { data, isPlaceholderData, isError, error } = useApiQuery('blob', {
pathParams: { hash },
queryOptions: {
placeholderData: BLOB,
refetchOnMount: false,
},
});
const content = (() => {
if (isError) {
if (error?.status === 422 || error?.status === 404) {
throwOnResourceLoadError({ resource: 'blob', error, isError: true });
}
return <DataFetchAlert/>;
}
if (!data) {
return null;
}
return <BlobInfo data={ data } isLoading={ isPlaceholderData }/>;
})();
const titleSecondRow = (
<BlobEntity hash={ hash } noLink fontWeight={ 500 } fontFamily="heading"/>
);
return (
<>
<TextAd mb={ 6 }/>
<PageTitle
title="Blob details"
secondRow={ titleSecondRow }
/>
{ content }
</>
);
};
export default BlobPageContent;
...@@ -13,8 +13,9 @@ import useIsMobile from 'lib/hooks/useIsMobile'; ...@@ -13,8 +13,9 @@ import useIsMobile from 'lib/hooks/useIsMobile';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import BlockDetails from 'ui/block/BlockDetails'; import BlockDetails from 'ui/block/BlockDetails';
import BlockWithdrawals from 'ui/block/BlockWithdrawals'; import BlockWithdrawals from 'ui/block/BlockWithdrawals';
import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery';
import useBlockQuery from 'ui/block/useBlockQuery'; import useBlockQuery from 'ui/block/useBlockQuery';
import useBlockTxQuery from 'ui/block/useBlockTxQuery'; import useBlockTxsQuery from 'ui/block/useBlockTxsQuery';
import useBlockWithdrawalsQuery from 'ui/block/useBlockWithdrawalsQuery'; import useBlockWithdrawalsQuery from 'ui/block/useBlockWithdrawalsQuery';
import TextAd from 'ui/shared/ad/TextAd'; import TextAd from 'ui/shared/ad/TextAd';
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning'; import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
...@@ -40,8 +41,9 @@ const BlockPageContent = () => { ...@@ -40,8 +41,9 @@ const BlockPageContent = () => {
const tab = getQueryParamString(router.query.tab); const tab = getQueryParamString(router.query.tab);
const blockQuery = useBlockQuery({ heightOrHash }); const blockQuery = useBlockQuery({ heightOrHash });
const blockTxsQuery = useBlockTxQuery({ heightOrHash, blockQuery, tab }); const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab });
const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab }); const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab });
const blockBlobTxsQuery = useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab });
const tabs: Array<RoutedTab> = React.useMemo(() => ([ const tabs: Array<RoutedTab> = React.useMemo(() => ([
{ {
...@@ -64,6 +66,14 @@ const BlockPageContent = () => { ...@@ -64,6 +66,14 @@ const BlockPageContent = () => {
</> </>
), ),
}, },
blockQuery.data?.blob_tx_count ?
{
id: 'blob_txs',
title: 'Blob txns',
component: (
<TxsWithFrontendSorting query={ blockBlobTxsQuery } showBlockInfo={ false } showSocketInfo={ false }/>
),
} : null,
config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.withdrawals_count) ? config.features.beaconChain.isEnabled && Boolean(blockQuery.data?.withdrawals_count) ?
{ {
id: 'withdrawals', id: 'withdrawals',
...@@ -75,7 +85,7 @@ const BlockPageContent = () => { ...@@ -75,7 +85,7 @@ const BlockPageContent = () => {
</> </>
), ),
} : null, } : null,
].filter(Boolean)), [ blockQuery, blockTxsQuery, blockWithdrawalsQuery ]); ].filter(Boolean)), [ blockBlobTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery ]);
const hasPagination = !isMobile && ( const hasPagination = !isMobile && (
(tab === 'txs' && blockTxsQuery.pagination.isVisible) || (tab === 'txs' && blockTxsQuery.pagination.isVisible) ||
......
...@@ -158,6 +158,31 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -158,6 +158,31 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
await expect(component.locator('main')).toHaveScreenshot(); await expect(component.locator('main')).toHaveScreenshot();
}); });
test('search by blob hash +@mobile', async({ mount, page }) => {
const hooksConfig = {
router: {
query: { q: searchMock.blob1.blob_hash },
},
};
await page.route(buildApiUrl('search') + `?q=${ searchMock.blob1.blob_hash }`, (route) => route.fulfill({
status: 200,
body: JSON.stringify({
items: [
searchMock.blob1,
],
}),
}));
const component = await mount(
<TestApp>
<SearchResults/>
</TestApp>,
{ hooksConfig },
);
await expect(component.locator('main')).toHaveScreenshot();
});
const testWithUserOps = test.extend({ const testWithUserOps = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.userOps) as any, context: contextWithEnvs(configs.featureEnvs.userOps) as any,
......
...@@ -58,6 +58,11 @@ const SearchResultsPageContent = () => { ...@@ -58,6 +58,11 @@ const SearchResultsPageContent = () => {
router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } }); router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return; return;
} }
break;
}
case 'blob': {
router.replace({ pathname: '/blobs/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
return;
} }
} }
} }
......
...@@ -14,6 +14,7 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -14,6 +14,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery'; import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxBlobs from 'ui/tx/TxBlobs';
import TxDetails from 'ui/tx/TxDetails'; import TxDetails from 'ui/tx/TxDetails';
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded'; import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped'; import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
...@@ -55,6 +56,9 @@ const TransactionPageContent = () => { ...@@ -55,6 +56,9 @@ const TransactionPageContent = () => {
{ id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } : { id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } :
undefined, undefined,
{ id: 'internal', title: 'Internal txns', component: <TxInternals txQuery={ txQuery }/> }, { id: 'internal', title: 'Internal txns', component: <TxInternals txQuery={ txQuery }/> },
txQuery.data?.blob_versioned_hashes?.length ?
{ id: 'blobs', title: 'Blobs', component: <TxBlobs txQuery={ txQuery }/> } :
undefined,
{ id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery }/> }, { id: 'logs', title: 'Logs', component: <TxLogs txQuery={ txQuery }/> },
{ id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> }, { id: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> },
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> }, { id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> },
......
...@@ -7,6 +7,7 @@ import config from 'configs/app'; ...@@ -7,6 +7,7 @@ import config from 'configs/app';
import useHasAccount from 'lib/hooks/useHasAccount'; import useHasAccount from 'lib/hooks/useHasAccount';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket'; import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
import getQueryParamString from 'lib/router/getQueryParamString';
import { TX } from 'stubs/tx'; import { TX } from 'stubs/tx';
import { generateListStub } from 'stubs/utils'; import { generateListStub } from 'stubs/utils';
import PageTitle from 'ui/shared/Page/PageTitle'; import PageTitle from 'ui/shared/Page/PageTitle';
...@@ -26,11 +27,19 @@ const Transactions = () => { ...@@ -26,11 +27,19 @@ const Transactions = () => {
const verifiedTitle = config.chain.verificationType === 'validation' ? 'Validated' : 'Mined'; const verifiedTitle = config.chain.verificationType === 'validation' ? 'Validated' : 'Mined';
const router = useRouter(); const router = useRouter();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const txsQuery = useQueryWithPages({ const tab = getQueryParamString(router.query.tab);
resourceName: router.query.tab === 'pending' ? 'txs_pending' : 'txs_validated',
filters: { filter: router.query.tab === 'pending' ? 'pending' : 'validated' }, React.useEffect(() => {
if (tab === 'blob_txs' && config.UI.views.tx.hiddenViews?.blob_txs) {
router.replace({ pathname: '/txs' }, undefined, { shallow: true });
}
}, [ router, tab ]);
const txsValidatedQuery = useQueryWithPages({
resourceName: 'txs_validated',
filters: { filter: 'validated' },
options: { options: {
enabled: !router.query.tab || router.query.tab === 'validated' || router.query.tab === 'pending', enabled: !tab || tab === 'validated',
placeholderData: generateListStub<'txs_validated'>(TX, 50, { next_page_params: { placeholderData: generateListStub<'txs_validated'>(TX, 50, { next_page_params: {
block_number: 9005713, block_number: 9005713,
index: 5, index: 5,
...@@ -40,10 +49,36 @@ const Transactions = () => { ...@@ -40,10 +49,36 @@ const Transactions = () => {
}, },
}); });
const txsPendingQuery = useQueryWithPages({
resourceName: 'txs_pending',
filters: { filter: 'pending' },
options: {
enabled: tab === 'pending',
placeholderData: generateListStub<'txs_pending'>(TX, 50, { next_page_params: {
inserted_at: '2024-02-05T07:04:47.749818Z',
hash: '0x00',
filter: 'pending',
} }),
},
});
const txsWithBlobsQuery = useQueryWithPages({
resourceName: 'txs_with_blobs',
filters: { type: 'blob_transaction' },
options: {
enabled: !config.UI.views.tx.hiddenViews?.blob_txs && tab === 'blob_txs',
placeholderData: generateListStub<'txs_with_blobs'>(TX, 50, { next_page_params: {
block_number: 10602877,
index: 8,
items_count: 50,
} }),
},
});
const txsWatchlistQuery = useQueryWithPages({ const txsWatchlistQuery = useQueryWithPages({
resourceName: 'txs_watchlist', resourceName: 'txs_watchlist',
options: { options: {
enabled: router.query.tab === 'watchlist', enabled: tab === 'watchlist',
placeholderData: generateListStub<'txs_watchlist'>(TX, 50, { next_page_params: { placeholderData: generateListStub<'txs_watchlist'>(TX, 50, { next_page_params: {
block_number: 9005713, block_number: 9005713,
index: 5, index: 5,
...@@ -61,15 +96,32 @@ const Transactions = () => { ...@@ -61,15 +96,32 @@ const Transactions = () => {
id: 'validated', id: 'validated',
title: verifiedTitle, title: verifiedTitle,
component: component:
<TxsWithFrontendSorting query={ txsQuery } showSocketInfo={ txsQuery.pagination.page === 1 } socketInfoNum={ num } socketInfoAlert={ socketAlert }/> }, <TxsWithFrontendSorting
query={ txsValidatedQuery }
showSocketInfo={ txsValidatedQuery.pagination.page === 1 }
socketInfoNum={ num }
socketInfoAlert={ socketAlert }
/> },
{ {
id: 'pending', id: 'pending',
title: 'Pending', title: 'Pending',
component: ( component: (
<TxsWithFrontendSorting <TxsWithFrontendSorting
query={ txsQuery } query={ txsPendingQuery }
showBlockInfo={ false } showBlockInfo={ false }
showSocketInfo={ txsQuery.pagination.page === 1 } showSocketInfo={ txsPendingQuery.pagination.page === 1 }
socketInfoNum={ num }
socketInfoAlert={ socketAlert }
/>
),
},
!config.UI.views.tx.hiddenViews?.blob_txs && {
id: 'blob_txs',
title: 'Blob txns',
component: (
<TxsWithFrontendSorting
query={ txsWithBlobsQuery }
showSocketInfo={ txsWithBlobsQuery.pagination.page === 1 }
socketInfoNum={ num } socketInfoNum={ num }
socketInfoAlert={ socketAlert } socketInfoAlert={ socketAlert }
/> />
...@@ -82,7 +134,14 @@ const Transactions = () => { ...@@ -82,7 +134,14 @@ const Transactions = () => {
} : undefined, } : undefined,
].filter(Boolean); ].filter(Boolean);
const pagination = router.query.tab === 'watchlist' ? txsWatchlistQuery.pagination : txsQuery.pagination; const pagination = (() => {
switch (tab) {
case 'pending': return txsPendingQuery.pagination;
case 'watchlist': return txsWatchlistQuery.pagination;
case 'blob_txs': return txsWithBlobsQuery.pagination;
default: return txsValidatedQuery.pagination;
}
})();
return ( return (
<> <>
......
...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address'; import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; 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 BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
...@@ -200,6 +201,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -200,6 +201,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
</TxEntity.Container> </TxEntity.Container>
); );
} }
case 'blob': {
return (
<BlobEntity.Container>
<BlobEntity.Icon/>
<BlobEntity.Link
isLoading={ isLoading }
hash={ data.blob_hash }
onClick={ handleLinkClick }
>
<BlobEntity.Content
asProp="mark"
hash={ data.blob_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</BlobEntity.Link>
</BlobEntity.Container>
);
}
case 'user_operation': { case 'user_operation': {
return ( return (
<UserOpEntity.Container> <UserOpEntity.Container>
......
...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index'; ...@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
import { saveToRecentKeywords } from 'lib/recentSearchKeywords'; import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
import { ADDRESS_REGEXP } from 'lib/validations/address'; import { ADDRESS_REGEXP } from 'lib/validations/address';
import * as AddressEntity from 'ui/shared/entities/address/AddressEntity'; 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 BlockEntity from 'ui/shared/entities/block/BlockEntity';
import * as TokenEntity from 'ui/shared/entities/token/TokenEntity'; import * as TokenEntity from 'ui/shared/entities/token/TokenEntity';
import * as TxEntity from 'ui/shared/entities/tx/TxEntity'; import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
...@@ -285,6 +286,30 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => { ...@@ -285,6 +286,30 @@ const SearchResultTableItem = ({ data, searchTerm, isLoading }: Props) => {
</> </>
); );
} }
case 'blob': {
return (
<Td colSpan={ 3 } fontSize="sm">
<BlobEntity.Container>
<BlobEntity.Icon/>
<BlobEntity.Link
isLoading={ isLoading }
hash={ data.blob_hash }
onClick={ handleLinkClick }
>
<BlobEntity.Content
asProp="mark"
hash={ data.blob_hash }
fontSize="sm"
lineHeight={ 5 }
fontWeight={ 700 }
/>
</BlobEntity.Link>
</BlobEntity.Container>
</Td>
);
}
case 'user_operation': { case 'user_operation': {
return ( return (
<> <>
......
...@@ -37,6 +37,7 @@ const AdditionalInfoButton = ({ isOpen, onClick, className, isLoading }: Props, ...@@ -37,6 +37,7 @@ const AdditionalInfoButton = ({ isOpen, onClick, className, isLoading }: Props,
onClick={ onClick } onClick={ onClick }
cursor="pointer" cursor="pointer"
flexShrink={ 0 } flexShrink={ 0 }
aria-label="Transaction info"
> >
<IconSvg <IconSvg
name="info" name="info"
......
...@@ -25,7 +25,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => { ...@@ -25,7 +25,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
}, [ hasCopied ]); }, [ hasCopied ]);
if (isLoading) { if (isLoading) {
return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 }/>; return <Skeleton boxSize={ 5 } className={ className } borderRadius="sm" flexShrink={ 0 } ml={ 2 } display="inline-block"/>;
} }
return ( return (
......
...@@ -36,6 +36,7 @@ const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textare ...@@ -36,6 +36,7 @@ const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textare
borderRadius="md" borderRadius="md"
wordBreak="break-all" wordBreak="break-all"
whiteSpace="pre-wrap" whiteSpace="pre-wrap"
overflowX="hidden"
overflowY="auto" overflowY="auto"
isLoaded={ !isLoading } isLoaded={ !isLoading }
> >
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import BlobDataType from './BlobDataType';
test.use({ viewport: { width: 100, height: 50 } });
test('image data', async({ mount }) => {
const component = await mount(
<TestApp>
<BlobDataType data="0x89504E470D0A1A0A0000000D494844520000003C0000003C0403"/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('raw data', async({ mount }) => {
const component = await mount(
<TestApp>
<BlobDataType data="0x010203040506"/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('text data', async({ mount }) => {
const component = await mount(
<TestApp>
<BlobDataType data="0x7b226e616d65223a22706963732f"/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Flex, Skeleton, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import * as blobUtils from 'lib/blob';
import type { IconName } from 'ui/shared/IconSvg';
import IconSvg from 'ui/shared/IconSvg';
interface Props {
data: string;
isLoading?: boolean;
}
const TYPES: Record<string, { iconName: IconName; label: string}> = {
image: { iconName: 'blobs/image', label: 'Image' },
text: { iconName: 'blobs/text', label: 'Text' },
raw: { iconName: 'blobs/raw', label: 'Raw' },
};
const BlobDataType = ({ data, isLoading }: Props) => {
const iconColor = useColorModeValue('gray.500', 'gray.400');
const guessedType = React.useMemo(() => {
if (isLoading) {
return;
}
return blobUtils.guessDataType(data);
}, [ data, isLoading ]);
const { iconName, label } = (() => {
if (guessedType?.mime?.startsWith('image/')) {
return TYPES.image;
}
if (
guessedType?.mime?.startsWith('text/') ||
[
'application/json',
'application/xml',
'application/javascript',
].includes(guessedType?.mime || '')
) {
return TYPES.text;
}
return TYPES.raw;
})();
return (
<Flex alignItems="center" columnGap={ 2 }>
<IconSvg name={ iconName } boxSize={ 5 } color={ iconColor } isLoading={ isLoading }/>
<Skeleton isLoaded={ !isLoading }>{ label }</Skeleton>
</Flex>
);
};
export default React.memo(BlobDataType);
import { chakra } from '@chakra-ui/react';
import _omit from 'lodash/omit';
import React from 'react';
import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'hash'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/blobs/[hash]', query: { hash: props.hash } });
return (
<EntityBase.Link
{ ...props }
href={ props.href ?? defaultHref }
>
{ props.children }
</EntityBase.Link>
);
});
type IconProps = Omit<EntityBase.IconBaseProps, 'name'> & {
name?: EntityBase.IconBaseProps['name'];
};
const Icon = (props: IconProps) => {
return (
<EntityBase.Icon
{ ...props }
name={ props.name ?? 'blob' }
/>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'hash' | 'text'>;
const Content = chakra((props: ContentProps) => {
return (
<EntityBase.Content
{ ...props }
text={ props.text ?? props.hash }
/>
);
});
type CopyProps = Omit<EntityBase.CopyBaseProps, 'text'> & Pick<EntityProps, 'hash'>;
const Copy = (props: CopyProps) => {
return (
<EntityBase.Copy
{ ...props }
text={ props.hash }
noCopy={ props.noCopy }
/>
);
};
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
hash: string;
text?: string;
}
const BlobEntity = (props: EntityProps) => {
const linkProps = _omit(props, [ 'className' ]);
const partsProps = _omit(props, [ 'className', 'onClick' ]);
return (
<Container className={ props.className }>
<Icon { ...partsProps }/>
<Link { ...linkProps }>
<Content { ...partsProps }/>
</Link>
<Copy { ...partsProps }/>
</Container>
);
};
export default React.memo(chakra(BlobEntity));
export {
Container,
Link,
Icon,
Content,
Copy,
};
...@@ -148,7 +148,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -148,7 +148,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
}, [ queryClient, resourceName, router, scrollToTop ]); }, [ queryClient, resourceName, router, scrollToTop ]);
const onFilterChange = useCallback(<R extends PaginatedResources = Resource>(newFilters: PaginationFilters<R> | undefined) => { const onFilterChange = useCallback(<R extends PaginatedResources = Resource>(newFilters: PaginationFilters<R> | undefined) => {
const newQuery = omit<typeof router.query>(router.query, 'next_page_params', 'page', resource.filterFields); const newQuery = omit<typeof router.query>(router.query, 'next_page_params', 'page', 'filterFields' in resource ? resource.filterFields : []);
if (newFilters) { if (newFilters) {
Object.entries(newFilters).forEach(([ key, value ]) => { Object.entries(newFilters).forEach(([ key, value ]) => {
const isValidValue = typeof value === 'boolean' || (value && value.length); const isValidValue = typeof value === 'boolean' || (value && value.length);
...@@ -170,7 +170,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({ ...@@ -170,7 +170,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
setPage(1); setPage(1);
setPageParams(INITIAL_PAGE_PARAMS); setPageParams(INITIAL_PAGE_PARAMS);
}); });
}, [ router, resource.filterFields, scrollToTop ]); }, [ router, resource, scrollToTop ]);
const onSortingChange = useCallback((newSorting: PaginationSorting<Resource> | undefined) => { const onSortingChange = useCallback((newSorting: PaginationSorting<Resource> | undefined) => {
const newQuery = { const newQuery = {
......
...@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace'; ...@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace';
import config from 'configs/app'; import config from 'configs/app';
export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation'; export type ApiCategory = 'token' | 'nft' | 'address' | 'public_tag' | 'transaction' | 'block' | 'user_operation' | 'blob';
export type Category = ApiCategory | 'app'; export type Category = ApiCategory | 'app';
export type ItemsCategoriesMap = export type ItemsCategoriesMap =
...@@ -23,6 +23,7 @@ export const searchCategories: Array<{id: Category; title: string }> = [ ...@@ -23,6 +23,7 @@ export const searchCategories: Array<{id: Category; title: string }> = [
{ id: 'public_tag', title: 'Public tags' }, { id: 'public_tag', title: 'Public tags' },
{ id: 'transaction', title: 'Transactions' }, { id: 'transaction', title: 'Transactions' },
{ id: 'block', title: 'Blocks' }, { id: 'block', title: 'Blocks' },
{ id: 'blob', title: 'Blobs' },
]; ];
if (config.features.userOps.isEnabled) { if (config.features.userOps.isEnabled) {
...@@ -38,6 +39,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh ...@@ -38,6 +39,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' }, transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
block: { itemTitle: 'Block', itemTitleShort: 'Block' }, block: { itemTitle: 'Block', itemTitleShort: 'Block' },
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' }, user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
blob: { itemTitle: 'Blob', itemTitleShort: 'Blob' },
}; };
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined { export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
...@@ -67,5 +69,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C ...@@ -67,5 +69,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
case 'user_operation': { case 'user_operation': {
return 'user_operation'; return 'user_operation';
} }
case 'blob': {
return 'blob';
}
} }
} }
...@@ -54,7 +54,7 @@ test('search by token name +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -54,7 +54,7 @@ test('search by token name +@mobile +@dark-mode', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
...@@ -75,7 +75,7 @@ test('search by contract name +@mobile +@dark-mode', async({ mount, page }) => ...@@ -75,7 +75,7 @@ test('search by contract name +@mobile +@dark-mode', async({ mount, page }) =>
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
...@@ -97,7 +97,7 @@ test('search by name homepage +@dark-mode', async({ mount, page }) => { ...@@ -97,7 +97,7 @@ test('search by name homepage +@dark-mode', async({ mount, page }) => {
<SearchBar isHomepage/> <SearchBar isHomepage/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
...@@ -117,7 +117,7 @@ test('search by tag +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -117,7 +117,7 @@ test('search by tag +@mobile +@dark-mode', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 500 } });
...@@ -137,7 +137,7 @@ test('search by address hash +@mobile', async({ mount, page }) => { ...@@ -137,7 +137,7 @@ test('search by address hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type(searchMock.address1.address); await page.getByPlaceholder(/search/i).fill(searchMock.address1.address);
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
...@@ -159,7 +159,7 @@ test('search by block number +@mobile', async({ mount, page }) => { ...@@ -159,7 +159,7 @@ test('search by block number +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type(String(searchMock.block1.block_number)); await page.getByPlaceholder(/search/i).fill(String(searchMock.block1.block_number));
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 600 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 600 } });
...@@ -179,7 +179,7 @@ test('search by block hash +@mobile', async({ mount, page }) => { ...@@ -179,7 +179,7 @@ test('search by block hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type(searchMock.block1.block_hash); await page.getByPlaceholder(/search/i).fill(searchMock.block1.block_hash);
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
...@@ -199,7 +199,27 @@ test('search by tx hash +@mobile', async({ mount, page }) => { ...@@ -199,7 +199,27 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash); await page.getByPlaceholder(/search/i).fill(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
});
test('search by blob hash +@mobile', async({ mount, page }) => {
const API_URL = buildApiUrl('quick_search') + `?q=${ searchMock.blob1.blob_hash }`;
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify([
searchMock.blob1,
]),
}));
await mount(
<TestApp>
<SearchBar/>
</TestApp>,
);
await page.getByPlaceholder(/search/i).fill(searchMock.blob1.blob_hash);
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
...@@ -228,7 +248,7 @@ testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => { ...@@ -228,7 +248,7 @@ testWithUserOps('search by user op hash +@mobile', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type(searchMock.tx1.tx_hash); await page.getByPlaceholder(/search/i).fill(searchMock.tx1.tx_hash);
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } }); await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 300 } });
...@@ -251,7 +271,7 @@ test('search with view all link', async({ mount, page }) => { ...@@ -251,7 +271,7 @@ test('search with view all link', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
...@@ -283,7 +303,7 @@ test('scroll suggest to category', async({ mount, page }) => { ...@@ -283,7 +303,7 @@ test('scroll suggest to category', async({ mount, page }) => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
await page.getByRole('tab', { name: 'Addresses' }).click(); await page.getByRole('tab', { name: 'Addresses' }).click();
...@@ -345,7 +365,7 @@ base.describe('with apps', () => { ...@@ -345,7 +365,7 @@ base.describe('with apps', () => {
<SearchBar/> <SearchBar/>
</TestApp>, </TestApp>,
); );
await page.getByPlaceholder(/search/i).type('o'); await page.getByPlaceholder(/search/i).fill('o');
await page.waitForResponse(API_URL); await page.waitForResponse(API_URL);
......
import { chakra, Flex } from '@chakra-ui/react';
import React from 'react';
import type { SearchResultBlob } from 'types/api/search';
import * as BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
data: SearchResultBlob;
searchTerm: string;
}
const SearchBarSuggestBlob = ({ data }: Props) => {
return (
<Flex alignItems="center" minW={ 0 }>
<BlobEntity.Icon/>
<chakra.mark overflow="hidden" whiteSpace="nowrap" fontWeight={ 700 }>
<HashStringShortenDynamic hash={ data.blob_hash } isTooltipDisabled/>
</chakra.mark>
</Flex>
);
};
export default React.memo(SearchBarSuggestBlob);
...@@ -7,6 +7,7 @@ import type { SearchResultItem } from 'types/api/search'; ...@@ -7,6 +7,7 @@ import type { SearchResultItem } from 'types/api/search';
import { route } from 'nextjs-routes'; import { route } from 'nextjs-routes';
import SearchBarSuggestAddress from './SearchBarSuggestAddress'; import SearchBarSuggestAddress from './SearchBarSuggestAddress';
import SearchBarSuggestBlob from './SearchBarSuggestBlob';
import SearchBarSuggestBlock from './SearchBarSuggestBlock'; import SearchBarSuggestBlock from './SearchBarSuggestBlock';
import SearchBarSuggestItemLink from './SearchBarSuggestItemLink'; import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
import SearchBarSuggestLabel from './SearchBarSuggestLabel'; import SearchBarSuggestLabel from './SearchBarSuggestLabel';
...@@ -42,6 +43,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -42,6 +43,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'user_operation': { case 'user_operation': {
return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } }); return route({ pathname: '/op/[hash]', query: { hash: data.user_operation_hash } });
} }
case 'blob': {
return route({ pathname: '/blobs/[hash]', query: { hash: data.blob_hash } });
}
} }
})(); })();
...@@ -67,6 +71,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) => ...@@ -67,6 +71,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
case 'user_operation': { case 'user_operation': {
return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>; return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
} }
case 'blob': {
return <SearchBarSuggestBlob data={ data } searchTerm={ searchTerm }/>;
}
} }
})(); })();
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as blobsMock from 'mocks/blobs/blobs';
import * as txMock from 'mocks/txs/tx';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import TxBlobs from './TxBlobs';
import type { TxQuery } from './useTxQuery';
const TX_BLOBS_API_URL = buildApiUrl('tx_blobs', { hash: txMock.base.hash });
const hooksConfig = {
router: {
query: { hash: txMock.base.hash },
},
};
test('base view +@mobile', async({ mount, page }) => {
await page.route(TX_BLOBS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(blobsMock.txBlobs),
}));
const txQuery = {
data: txMock.base,
isPlaceholderData: false,
isError: false,
} as TxQuery;
const component = await mount(
<TestApp>
<TxBlobs txQuery={ txQuery }/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import { TX_BLOB } from 'stubs/blobs';
import { generateListStub } from 'stubs/utils';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TxBlobsList from './blobs/TxBlobsList';
import TxBlobsTable from './blobs/TxBlobsTable';
import TxPendingAlert from './TxPendingAlert';
import TxSocketAlert from './TxSocketAlert';
import type { TxQuery } from './useTxQuery';
interface Props {
txQuery: TxQuery;
}
const TxBlobs = ({ txQuery }: Props) => {
const { data, isPlaceholderData, isError, pagination } = useQueryWithPages({
resourceName: 'tx_blobs',
pathParams: { hash: txQuery.data?.hash },
options: {
enabled: !txQuery.isPlaceholderData && Boolean(txQuery.data?.hash) && Boolean(txQuery.data?.status),
placeholderData: generateListStub<'tx_blobs'>(TX_BLOB, 3, { next_page_params: null }),
},
});
if (!txQuery.isPending && !txQuery.isPlaceholderData && !txQuery.isError && !txQuery.data.status) {
return txQuery.socketStatus ? <TxSocketAlert status={ txQuery.socketStatus }/> : <TxPendingAlert/>;
}
const content = data ? (
<>
<Hide below="lg" ssr={ false }>
<TxBlobsTable data={ data.items } isLoading={ isPlaceholderData } top={ pagination.isVisible ? 80 : 0 }/>
</Hide>
<Show below="lg" ssr={ false }>
<TxBlobsList data={ data.items } isLoading={ isPlaceholderData }/>
</Show>
</>
) : null;
const actionBar = pagination.isVisible ? (
<ActionBar mt={ -6 } showShadow>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) : null;
return (
<DataListDisplay
isError={ isError || txQuery.isError }
items={ data?.items }
emptyText="There are no blobs for this transaction."
content={ content }
actionBar={ actionBar }
/>
);
};
export default TxBlobs;
import { Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import BlobDataType from 'ui/shared/blob/BlobDataType';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import ListItemMobileGrid from 'ui/shared/ListItemMobile/ListItemMobileGrid';
interface Props {
data: TxBlob;
isLoading?: boolean;
}
const TxBlobListItem = ({ data, isLoading }: Props) => {
const size = data.blob_data.replace('0x', '').length / 2;
return (
<ListItemMobileGrid.Container>
<ListItemMobileGrid.Label isLoading={ isLoading }>Blob hash</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlobEntity hash={ data.hash } isLoading={ isLoading }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Data type</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<BlobDataType isLoading={ isLoading } data={ data.blob_data }/>
</ListItemMobileGrid.Value>
<ListItemMobileGrid.Label isLoading={ isLoading }>Size, bytes</ListItemMobileGrid.Label>
<ListItemMobileGrid.Value>
<Skeleton isLoaded={ !isLoading }>
{ size.toLocaleString() }
</Skeleton>
</ListItemMobileGrid.Value>
</ListItemMobileGrid.Container>
);
};
export default React.memo(TxBlobListItem);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import TxBlobListItem from './TxBlobListItem';
const TxBlobList = ({ data, isLoading }: { data: Array<TxBlob>; isLoading?: boolean }) => {
return (
<Box>
{ data.map((item, index) => (
<TxBlobListItem
key={ item.hash + (isLoading ? index : '') }
data={ item }
isLoading={ isLoading }
/>
)) }
</Box>
);
};
export default TxBlobList;
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import { default as Thead } from 'ui/shared/TheadSticky';
import TxBlobsTableItem from './TxBlobsTableItem';
interface Props {
data: Array<TxBlob>;
top: number;
isLoading?: boolean;
}
const TxInternalsTable = ({ data, top, isLoading }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th width="60%">Blob hash</Th>
<Th width="20%">Data type</Th>
<Th width="20%">Size, bytes</Th>
</Tr>
</Thead>
<Tbody>
{ data.map((item, index) => (
<TxBlobsTableItem key={ item.hash + (isLoading ? index : '') } data={ item } isLoading={ isLoading }/>
)) }
</Tbody>
</Table>
);
};
export default TxInternalsTable;
import { Tr, Td, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { TxBlob } from 'types/api/blobs';
import BlobDataType from 'ui/shared/blob/BlobDataType';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
interface Props {
data: TxBlob;
isLoading?: boolean;
}
const TxBlobsTableItem = ({ data, isLoading }: Props) => {
const size = data.blob_data.replace('0x', '').length / 2;
return (
<Tr alignItems="top">
<Td>
<BlobEntity hash={ data.hash } noIcon isLoading={ isLoading }/>
</Td>
<Td verticalAlign="middle">
<BlobDataType isLoading={ isLoading } data={ data.blob_data }/>
</Td>
<Td verticalAlign="middle">
<Skeleton isLoaded={ !isLoading } display="inline-block">
{ size.toLocaleString() }
</Skeleton>
</Td>
</Tr>
);
};
export default React.memo(TxBlobsTableItem);
import BigNumber from 'bignumber.js';
import React from 'react';
import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import { ZERO } from 'lib/consts';
import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import IconSvg from 'ui/shared/IconSvg';
const rollupFeature = config.features.rollup;
interface Props {
data: Transaction;
isLoading?: boolean;
}
const TxDetailsBurntFees = ({ data, isLoading }: Props) => {
if (config.UI.views.tx.hiddenFields?.burnt_fees || (rollupFeature.isEnabled && rollupFeature.type === 'optimistic')) {
return null;
}
const value = BigNumber(data.tx_burnt_fee || 0).plus(BigNumber(data.blob_gas_used || 0).multipliedBy(BigNumber(data.blob_gas_price || 0)));
if (value.isEqualTo(ZERO)) {
return null;
}
return (
<DetailsInfoItem
title="Burnt fees"
hint={ `
Amount of ${ currencyUnits.ether } burned for this transaction. Equals Block Base Fee per Gas * Gas Used
${ data.blob_gas_price && data.blob_gas_used ? ' + Blob Gas Price * Blob Gas Used' : '' }
` }
isLoading={ isLoading }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500" isLoading={ isLoading }/>
<CurrencyValue
value={ value.toString() }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
ml={ 2 }
isLoading={ isLoading }
/>
</DetailsInfoItem>
);
};
export default React.memo(TxDetailsBurntFees);
...@@ -21,6 +21,7 @@ const TxDetailsOther = ({ nonce, type, position }: Props) => { ...@@ -21,6 +21,7 @@ const TxDetailsOther = ({ nonce, type, position }: Props) => {
<Text as="span" fontWeight="500">Txn type: </Text> <Text as="span" fontWeight="500">Txn type: </Text>
<Text fontWeight="600" as="span">{ type }</Text> <Text fontWeight="600" as="span">{ type }</Text>
{ type === 2 && <Text fontWeight="400" as="span" ml={ 1 } variant="secondary">(EIP-1559)</Text> } { type === 2 && <Text fontWeight="400" as="span" ml={ 1 } variant="secondary">(EIP-1559)</Text> }
{ type === 3 && <Text fontWeight="400" as="span" ml={ 1 } variant="secondary">(EIP-4844)</Text> }
</Box> </Box>
), ),
<Box key="nonce"> <Box key="nonce">
......
...@@ -116,6 +116,22 @@ test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => { ...@@ -116,6 +116,22 @@ test('with actions uniswap +@mobile +@dark-mode', async({ mount, page }) => {
}); });
}); });
test('with blob', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxInfo data={ txMock.withBlob } isLoading={ false }/>
</TestApp>,
{ hooksConfig },
);
await page.getByText('View details').click();
await expect(component).toHaveScreenshot({
mask: [ page.locator(configs.adsBannerSelector) ],
maskColor: configs.maskColor,
});
});
const l2Test = test.extend({ const l2Test = test.extend({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any, context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
......
...@@ -45,6 +45,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability'; ...@@ -45,6 +45,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import Utilization from 'ui/shared/Utilization/Utilization'; import Utilization from 'ui/shared/Utilization/Utilization';
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps'; import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions'; import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
import TxDetailsBurntFees from 'ui/tx/details/TxDetailsBurntFees';
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas'; import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice'; import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
import TxDetailsOther from 'ui/tx/details/TxDetailsOther'; import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
...@@ -357,7 +358,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -357,7 +358,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
{ !config.UI.views.tx.hiddenFields?.tx_fee && ( { !config.UI.views.tx.hiddenFields?.tx_fee && (
<DetailsInfoItem <DetailsInfoItem
title="Transaction fee" title="Transaction fee"
hint="Total transaction fee" hint={ data.blob_gas_used ? 'Transaction fee without blob fee' : 'Total transaction fee' }
isLoading={ isLoading } isLoading={ isLoading }
> >
{ data.stability_fee ? ( { data.stability_fee ? (
...@@ -422,21 +423,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -422,21 +423,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
) } ) }
</DetailsInfoItem> </DetailsInfoItem>
) } ) }
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !(rollupFeature.isEnabled && rollupFeature.type === 'optimistic') && ( <TxDetailsBurntFees data={ data } isLoading={ isLoading }/>
<DetailsInfoItem
title="Burnt fees"
hint={ `Amount of ${ currencyUnits.ether } burned for this transaction. Equals Block Base Fee per Gas * Gas Used` }
>
<IconSvg name="flame" boxSize={ 5 } color="gray.500"/>
<CurrencyValue
value={ String(data.tx_burnt_fee) }
currency={ currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
ml={ 2 }
/>
</DetailsInfoItem>
) }
{ rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && ( { rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && (
<> <>
{ data.l1_gas_used && ( { data.l1_gas_used && (
...@@ -502,6 +489,50 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => { ...@@ -502,6 +489,50 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
{ isExpanded && ( { isExpanded && (
<> <>
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/> <GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
{ (data.blob_gas_used || data.max_fee_per_blob_gas || data.blob_gas_price) && (
<>
{ data.blob_gas_used && data.blob_gas_price && (
<DetailsInfoItem
title="Blob fee"
hint="Blob fee for this transaction"
>
<CurrencyValue
value={ BigNumber(data.blob_gas_used).multipliedBy(data.blob_gas_price).toString() }
currency={ config.UI.views.tx.hiddenFields?.fee_currency ? '' : currencyUnits.ether }
exchangeRate={ data.exchange_rate }
flexWrap="wrap"
isLoading={ isLoading }
/>
</DetailsInfoItem>
) }
{ data.blob_gas_used && (
<DetailsInfoItem
title="Blob gas usage"
hint="Amount of gas used by the blobs in this transaction"
>
{ BigNumber(data.blob_gas_used).toFormat() }
</DetailsInfoItem>
) }
{ (data.max_fee_per_blob_gas || data.blob_gas_price) && (
<DetailsInfoItem
title={ `Blob gas fees (${ currencyUnits.gwei })` }
hint={ `Amount of ${ currencyUnits.ether } used for blobs in this transaction` }
>
{ data.blob_gas_price && (
<Text fontWeight="600" as="span">{ BigNumber(data.blob_gas_price).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
) }
{ (data.max_fee_per_blob_gas && data.blob_gas_price) && <TextSeparator/> }
{ data.max_fee_per_blob_gas && (
<>
<Text as="span" fontWeight="500" whiteSpace="pre">Max: </Text>
<Text fontWeight="600" as="span">{ BigNumber(data.max_fee_per_blob_gas).dividedBy(WEI_IN_GWEI).toFixed() }</Text>
</>
) }
</DetailsInfoItem>
) }
<DetailsInfoItemDivider/>
</>
) }
<TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/> <TxDetailsOther nonce={ data.nonce } type={ data.type } position={ data.position }/>
<DetailsInfoItem <DetailsInfoItem
title="Raw input" title="Raw input"
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as txMock from 'mocks/txs/tx';
import TestApp from 'playwright/TestApp';
import TxAdditionalInfo from './TxAdditionalInfo';
test('regular transaction +@dark-mode', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxAdditionalInfo tx={ txMock.base }/>
</TestApp>,
);
await component.getByLabel('Transaction info').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 600 } });
});
test('regular transaction +@mobile -@default', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxAdditionalInfo tx={ txMock.base } isMobile/>
</TestApp>,
);
await component.getByLabel('Transaction info').click();
await expect(page).toHaveScreenshot();
});
test('blob transaction', async({ mount, page }) => {
const component = await mount(
<TestApp>
<TxAdditionalInfo tx={ txMock.withBlob }/>
</TestApp>,
);
await component.getByLabel('Transaction info').click();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 400, height: 600 } });
});
...@@ -58,7 +58,7 @@ const TxAdditionalInfo = ({ hash, tx, isMobile, isLoading, className }: Props) = ...@@ -58,7 +58,7 @@ const TxAdditionalInfo = ({ hash, tx, isMobile, isLoading, className }: Props) =
<AdditionalInfoButton isOpen={ isOpen } isLoading={ isLoading } className={ className }/> <AdditionalInfoButton isOpen={ isOpen } isLoading={ isLoading } className={ className }/>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent border="1px solid" borderColor="divider"> <PopoverContent border="1px solid" borderColor="divider">
<PopoverBody> <PopoverBody fontWeight={ 400 } fontSize="sm">
{ content } { content }
</PopoverBody> </PopoverBody>
</PopoverContent> </PopoverContent>
......
...@@ -10,6 +10,7 @@ import config from 'configs/app'; ...@@ -10,6 +10,7 @@ import config from 'configs/app';
import getValueWithUnit from 'lib/getValueWithUnit'; import getValueWithUnit from 'lib/getValueWithUnit';
import { currencyUnits } from 'lib/units'; import { currencyUnits } from 'lib/units';
import CurrencyValue from 'ui/shared/CurrencyValue'; import CurrencyValue from 'ui/shared/CurrencyValue';
import BlobEntity from 'ui/shared/entities/blob/BlobEntity';
import LinkInternal from 'ui/shared/LinkInternal'; import LinkInternal from 'ui/shared/LinkInternal';
import TextSeparator from 'ui/shared/TextSeparator'; import TextSeparator from 'ui/shared/TextSeparator';
import TxFeeStability from 'ui/shared/tx/TxFeeStability'; import TxFeeStability from 'ui/shared/tx/TxFeeStability';
...@@ -26,12 +27,34 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => { ...@@ -26,12 +27,34 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
color: 'gray.500', color: 'gray.500',
fontWeight: 600, fontWeight: 600,
marginBottom: 3, marginBottom: 3,
fontSize: 'sm',
}; };
return ( return (
<> <>
<Heading as="h4" size="sm" mb={ 6 }>Additional info </Heading> <Heading as="h4" size="sm" mb={ 6 }>Additional info </Heading>
{ tx.blob_versioned_hashes && tx.blob_versioned_hashes.length > 0 && (
<Box { ...sectionProps } mb={ 4 }>
<Flex alignItems="center" justifyContent="space-between">
<Text { ...sectionTitleProps }>Blobs: { tx.blob_versioned_hashes.length }</Text>
{ tx.blob_versioned_hashes.length > 3 && (
<LinkInternal
href={ route({ pathname: '/tx/[hash]', query: { hash: tx.hash, tab: 'blobs' } }) }
mb={ 3 }
>
view all
</LinkInternal>
) }
</Flex>
<Flex flexDir="column" rowGap={ 3 }>
{ tx.blob_versioned_hashes.slice(0, 3).map((hash, index) => (
<Flex key={ hash } columnGap={ 2 }>
<Box fontWeight={ 500 }>{ index + 1 }</Box>
<BlobEntity hash={ hash } noIcon/>
</Flex>
)) }
</Flex>
</Box>
) }
{ !config.UI.views.tx.hiddenFields?.tx_fee && ( { !config.UI.views.tx.hiddenFields?.tx_fee && (
<Box { ...sectionProps } mb={ 4 }> <Box { ...sectionProps } mb={ 4 }>
{ (tx.stability_fee !== undefined || tx.fee.value !== null) && ( { (tx.stability_fee !== undefined || tx.fee.value !== null) && (
...@@ -73,40 +96,42 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => { ...@@ -73,40 +96,42 @@ const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
{ tx.base_fee_per_gas !== null && ( { tx.base_fee_per_gas !== null && (
<Box> <Box>
<Text as="span" fontWeight="500">Base: </Text> <Text as="span" fontWeight="500">Base: </Text>
<Text fontWeight="600" as="span">{ getValueWithUnit(tx.base_fee_per_gas, 'gwei').toFormat() }</Text> <Text fontWeight="700" as="span">{ getValueWithUnit(tx.base_fee_per_gas, 'gwei').toFormat() }</Text>
</Box> </Box>
) } ) }
{ tx.max_fee_per_gas !== null && ( { tx.max_fee_per_gas !== null && (
<Box mt={ 1 }> <Box mt={ 1 }>
<Text as="span" fontWeight="500">Max: </Text> <Text as="span" fontWeight="500">Max: </Text>
<Text fontWeight="600" as="span">{ getValueWithUnit(tx.max_fee_per_gas, 'gwei').toFormat() }</Text> <Text fontWeight="700" as="span">{ getValueWithUnit(tx.max_fee_per_gas, 'gwei').toFormat() }</Text>
</Box> </Box>
) } ) }
{ tx.max_priority_fee_per_gas !== null && ( { tx.max_priority_fee_per_gas !== null && (
<Box mt={ 1 }> <Box mt={ 1 }>
<Text as="span" fontWeight="500">Max priority: </Text> <Text as="span" fontWeight="500">Max priority: </Text>
<Text fontWeight="600" as="span">{ getValueWithUnit(tx.max_priority_fee_per_gas, 'gwei').toFormat() }</Text> <Text fontWeight="700" as="span">{ getValueWithUnit(tx.max_priority_fee_per_gas, 'gwei').toFormat() }</Text>
</Box> </Box>
) } ) }
</Box> </Box>
) } ) }
<Box { ...sectionProps } mb={ 4 }> { !(tx.blob_versioned_hashes && tx.blob_versioned_hashes.length > 0) && (
<Text { ...sectionTitleProps }>Others</Text> <Box { ...sectionProps } mb={ 4 }>
<Box> <Text { ...sectionTitleProps }>Others</Text>
<Text as="span" fontWeight="500">Txn type: </Text> <Box>
<Text fontWeight="600" as="span">{ tx.type }</Text> <Text as="span" fontWeight="500">Txn type: </Text>
{ tx.type === 2 && <Text fontWeight="400" as="span" ml={ 1 } color="gray.500">(EIP-1559)</Text> } <Text fontWeight="600" as="span">{ tx.type }</Text>
</Box> { tx.type === 2 && <Text fontWeight="400" as="span" ml={ 1 } color="gray.500">(EIP-1559)</Text> }
<Box mt={ 1 }> </Box>
<Text as="span" fontWeight="500">Nonce: </Text> <Box mt={ 1 }>
<Text fontWeight="600" as="span">{ tx.nonce }</Text> <Text as="span" fontWeight="500">Nonce: </Text>
</Box> <Text fontWeight="600" as="span">{ tx.nonce }</Text>
<Box mt={ 1 }> </Box>
<Text as="span" fontWeight="500">Position: </Text> <Box mt={ 1 }>
<Text fontWeight="600" as="span">{ tx.position }</Text> <Text as="span" fontWeight="500">Position: </Text>
<Text fontWeight="600" as="span">{ tx.position }</Text>
</Box>
</Box> </Box>
</Box> ) }
<LinkInternal fontSize="sm" href={ route({ pathname: '/tx/[hash]', query: { hash: tx.hash } }) }>More details</LinkInternal> <LinkInternal href={ route({ pathname: '/tx/[hash]', query: { hash: tx.hash } }) }>More details</LinkInternal>
</> </>
); );
}; };
......
...@@ -9,7 +9,16 @@ export interface Props { ...@@ -9,7 +9,16 @@ export interface Props {
isLoading?: boolean; isLoading?: boolean;
} }
const TYPES_ORDER = [ 'rootstock_remasc', 'rootstock_bridge', 'token_creation', 'contract_creation', 'token_transfer', 'contract_call', 'coin_transfer' ]; const TYPES_ORDER: Array<TransactionType> = [
'blob_transaction',
'rootstock_remasc',
'rootstock_bridge',
'token_creation',
'contract_creation',
'token_transfer',
'contract_call',
'coin_transfer',
];
const TxType = ({ types, isLoading }: Props) => { const TxType = ({ types, isLoading }: Props) => {
const typeToShow = types.sort((t1, t2) => TYPES_ORDER.indexOf(t1) - TYPES_ORDER.indexOf(t2))[0]; const typeToShow = types.sort((t1, t2) => TYPES_ORDER.indexOf(t1) - TYPES_ORDER.indexOf(t2))[0];
...@@ -22,6 +31,10 @@ const TxType = ({ types, isLoading }: Props) => { ...@@ -22,6 +31,10 @@ const TxType = ({ types, isLoading }: Props) => {
label = 'Contract call'; label = 'Contract call';
colorScheme = 'blue'; colorScheme = 'blue';
break; break;
case 'blob_transaction':
label = 'Blob txn';
colorScheme = 'yellow';
break;
case 'contract_creation': case 'contract_creation':
label = 'Contract creation'; label = 'Contract creation';
colorScheme = 'blue'; colorScheme = 'blue';
......
...@@ -11999,6 +11999,11 @@ lz-string@^1.5.0: ...@@ -11999,6 +11999,11 @@ lz-string@^1.5.0:
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941" resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.5.0.tgz#c1ab50f77887b712621201ba9fd4e3a6ed099941"
integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==
magic-bytes.js@1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/magic-bytes.js/-/magic-bytes.js-1.8.0.tgz#8362793c60cd77c2dd77db6420be727192df68e2"
integrity sha512-lyWpfvNGVb5lu8YUAbER0+UMBTdR63w2mcSUlhhBTyVbxJvjgqwyAf3AZD6MprgK0uHuBoWXSDAMWLupX83o3Q==
magic-string@^0.30.5: magic-string@^0.30.5:
version "0.30.5" version "0.30.5"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.5.tgz#1994d980bd1c8835dc6e78db7cbd4ae4f24746f9"
......
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