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
@@ -1,5 +1,5 @@
|
||||
import type { TxAdditionalFieldsId, TxFieldsId } from 'types/views/tx';
|
||||
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from 'types/views/tx';
|
||||
import type { TxAdditionalFieldsId, TxFieldsId, TxViewId } 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';
|
||||
|
||||
@@ -33,9 +33,31 @@ const additionalFields = (() => {
|
||||
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({
|
||||
hiddenFields,
|
||||
additionalFields,
|
||||
hiddenViews,
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -32,8 +32,8 @@ import { ADDRESS_VIEWS_IDS, IDENTICON_TYPES } from '../../../types/views/address
|
||||
import { BLOCK_FIELDS_IDS } from '../../../types/views/block';
|
||||
import type { BlockFieldId } from '../../../types/views/block';
|
||||
import type { NftMarketplaceItem } from '../../../types/views/nft';
|
||||
import type { TxAdditionalFieldsId, TxFieldsId } from '../../../types/views/tx';
|
||||
import { TX_ADDITIONAL_FIELDS_IDS, TX_FIELDS_IDS } from '../../../types/views/tx';
|
||||
import type { TxAdditionalFieldsId, TxFieldsId, TxViewId } 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 * as regexp from '../../../lib/regexp';
|
||||
@@ -448,6 +448,11 @@ const schema = yup
|
||||
.transform(replaceQuotes)
|
||||
.json()
|
||||
.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
|
||||
.array()
|
||||
.transform(replaceQuotes)
|
||||
|
||||
@@ -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_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_VIEWS=['blob_txs']
|
||||
NEXT_PUBLIC_VISUALIZE_API_HOST=https://example.com
|
||||
NEXT_PUBLIC_WEB3_DISABLE_ADD_TOKEN_TO_WALLET=false
|
||||
NEXT_PUBLIC_WEB3_WALLETS=['coinbase','metamask','token_pocket']
|
||||
|
||||
@@ -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_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
|
||||
| Id | Description |
|
||||
@@ -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 |
|
||||
|
||||
##### Transaction view list
|
||||
| Id | Description |
|
||||
| --- | --- |
|
||||
| `blob_txs` | List of all transactions that contain blob data |
|
||||
|
||||
|
||||
|
||||
#### NFT views
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,11 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 675 B |
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 608 B |
@@ -31,6 +31,7 @@ import type {
|
||||
AddressNFTTokensFilter,
|
||||
} from 'types/api/address';
|
||||
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 { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
|
||||
import type { BackendVersionConfig } from 'types/api/configs';
|
||||
@@ -84,9 +85,10 @@ import type {
|
||||
Transaction,
|
||||
TransactionsResponseWatchlist,
|
||||
TransactionsSorting,
|
||||
TransactionsResponseWithBlobs,
|
||||
} from 'types/api/transaction';
|
||||
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 { UserOpsResponse, UserOp, UserOpsFilters, UserOpsAccount } from 'types/api/userOps';
|
||||
import type { ValidatorsCountersResponse, ValidatorsFilters, ValidatorsResponse, ValidatorsSorting } from 'types/api/validators';
|
||||
@@ -264,7 +266,7 @@ export const RESOURCES = {
|
||||
block_txs: {
|
||||
path: '/api/v2/blocks/:height_or_hash/transactions',
|
||||
pathParams: [ 'height_or_hash' as const ],
|
||||
filterFields: [],
|
||||
filterFields: [ 'type' as const ],
|
||||
},
|
||||
block_withdrawals: {
|
||||
path: '/api/v2/blocks/:height_or_hash/withdrawals',
|
||||
@@ -279,6 +281,10 @@ export const RESOURCES = {
|
||||
path: '/api/v2/transactions',
|
||||
filterFields: [ 'filter' as const, 'type' as const, 'method' as const ],
|
||||
},
|
||||
txs_with_blobs: {
|
||||
path: '/api/v2/transactions',
|
||||
filterFields: [ 'type' as const ],
|
||||
},
|
||||
txs_watchlist: {
|
||||
path: '/api/v2/transactions/watchlist',
|
||||
filterFields: [ ],
|
||||
@@ -316,6 +322,10 @@ export const RESOURCES = {
|
||||
pathParams: [ 'hash' as const ],
|
||||
filterFields: [],
|
||||
},
|
||||
tx_blobs: {
|
||||
path: '/api/v2/transactions/:hash/blobs',
|
||||
pathParams: [ 'hash' as const ],
|
||||
},
|
||||
tx_interpretation: {
|
||||
path: '/api/v2/transactions/:hash/summary',
|
||||
pathParams: [ 'hash' as const ],
|
||||
@@ -664,6 +674,12 @@ export const RESOURCES = {
|
||||
pathParams: [ 'chainType' as const ],
|
||||
},
|
||||
|
||||
// BLOBS
|
||||
blob: {
|
||||
path: '/api/v2/blobs/:hash',
|
||||
pathParams: [ 'hash' as const ],
|
||||
},
|
||||
|
||||
// CONFIGS
|
||||
config_backend_version: {
|
||||
path: '/api/v2/config/backend-version',
|
||||
@@ -723,8 +739,8 @@ export interface ResourceError<T = unknown> {
|
||||
export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>
|
||||
|
||||
export type PaginatedResources = 'blocks' | 'block_txs' |
|
||||
'txs_validated' | 'txs_pending' | 'txs_watchlist' | 'txs_execution_node' |
|
||||
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' |
|
||||
'txs_validated' | 'txs_pending' | 'txs_with_blobs' | 'txs_watchlist' | 'txs_execution_node' |
|
||||
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' | 'tx_state_changes' | 'tx_blobs' |
|
||||
'addresses' |
|
||||
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
|
||||
'search' |
|
||||
@@ -775,6 +791,7 @@ Q extends 'block_txs' ? BlockTransactionsResponse :
|
||||
Q extends 'block_withdrawals' ? BlockWithdrawalsResponse :
|
||||
Q extends 'txs_validated' ? TransactionsResponseValidated :
|
||||
Q extends 'txs_pending' ? TransactionsResponsePending :
|
||||
Q extends 'txs_with_blobs' ? TransactionsResponseWithBlobs :
|
||||
Q extends 'txs_watchlist' ? TransactionsResponseWatchlist :
|
||||
Q extends 'txs_execution_node' ? TransactionsResponseValidated :
|
||||
Q extends 'tx' ? Transaction :
|
||||
@@ -783,6 +800,7 @@ Q extends 'tx_logs' ? LogsResponseTx :
|
||||
Q extends 'tx_token_transfers' ? TokenTransferResponse :
|
||||
Q extends 'tx_raw_trace' ? RawTracesResponse :
|
||||
Q extends 'tx_state_changes' ? TxStateChanges :
|
||||
Q extends 'tx_blobs' ? TxBlobs :
|
||||
Q extends 'tx_interpretation' ? TxInterpretationResponse :
|
||||
Q extends 'addresses' ? AddressesResponse :
|
||||
Q extends 'address' ? Address :
|
||||
@@ -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_txs' ? ZkEvmL2TxnBatchTxs :
|
||||
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;
|
||||
// !!! IMPORTANT !!!
|
||||
// See comment above
|
||||
@@ -853,6 +864,7 @@ never;
|
||||
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
export type ResourcePayloadB<Q extends ResourceName> =
|
||||
Q extends 'blob' ? Blob :
|
||||
Q extends 'marketplace_dapps' ? Array<MarketplaceAppOverview> :
|
||||
Q extends 'marketplace_dapp' ? MarketplaceAppOverview :
|
||||
Q extends 'validators' ? ValidatorsResponse :
|
||||
@@ -862,6 +874,13 @@ Q extends 'shibarium_deposits' ? ShibariumDepositsResponse :
|
||||
Q extends 'shibarium_withdrawals_count' ? number :
|
||||
Q extends 'shibarium_deposits_count' ? number :
|
||||
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;
|
||||
/* eslint-enable @typescript-eslint/indent */
|
||||
|
||||
@@ -874,7 +893,9 @@ export type PaginatedResponseNextPageParams<Q extends ResourceName> = Q extends
|
||||
/* eslint-disable @typescript-eslint/indent */
|
||||
export type PaginationFilters<Q extends PaginatedResources> =
|
||||
Q extends 'blocks' ? BlockFilters :
|
||||
Q extends 'block_txs' ? TTxsWithBlobsFilters :
|
||||
Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
|
||||
Q extends 'txs_with_blobs' ? TTxsWithBlobsFilters :
|
||||
Q extends 'tx_token_transfers' ? TokenTransferFilters :
|
||||
Q extends 'token_transfers' ? TokenTransferFilters :
|
||||
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as guessDataType } from './guessDataType';
|
||||
@@ -0,0 +1,14 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// hex can be with prefix - `0x{string}` - or without it - `{string}`
|
||||
export default function hexToBytes(hex: string) {
|
||||
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));
|
||||
}
|
||||
return bytes;
|
||||
|
||||
@@ -37,6 +37,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
|
||||
'/output-roots': 'Root page',
|
||||
'/batches': 'Root page',
|
||||
'/batches/[number]': 'Regular page',
|
||||
'/blobs/[hash]': 'Regular page',
|
||||
'/ops': 'Root page',
|
||||
'/op/[hash]': 'Regular page',
|
||||
'/404': 'Regular page',
|
||||
|
||||
@@ -40,6 +40,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/output-roots': DEFAULT_TEMPLATE,
|
||||
'/batches': DEFAULT_TEMPLATE,
|
||||
'/batches/[number]': DEFAULT_TEMPLATE,
|
||||
'/blobs/[hash]': DEFAULT_TEMPLATE,
|
||||
'/ops': DEFAULT_TEMPLATE,
|
||||
'/op/[hash]': DEFAULT_TEMPLATE,
|
||||
'/404': DEFAULT_TEMPLATE,
|
||||
|
||||
@@ -35,6 +35,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
|
||||
'/output-roots': 'output roots',
|
||||
'/batches': 'tx batches (L2 blocks)',
|
||||
'/batches/[number]': 'L2 tx batch %number%',
|
||||
'/blobs/[hash]': 'blob %hash% details',
|
||||
'/ops': 'user operations',
|
||||
'/op/[hash]': 'user operation %hash%',
|
||||
'/404': 'error - page not found',
|
||||
|
||||
@@ -35,6 +35,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
|
||||
'/output-roots': 'Output roots',
|
||||
'/batches': 'Tx batches (L2 blocks)',
|
||||
'/batches/[number]': 'L2 tx batch details',
|
||||
'/blobs/[hash]': 'Blob details',
|
||||
'/ops': 'User operations',
|
||||
'/op/[hash]': 'User operation details',
|
||||
'/404': '404',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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 = {
|
||||
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 = {
|
||||
items: [
|
||||
base,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
SearchResultLabel,
|
||||
SearchResult,
|
||||
SearchResultUserOp,
|
||||
SearchResultBlob,
|
||||
} from 'types/api/search';
|
||||
|
||||
export const token1: SearchResultToken = {
|
||||
@@ -116,6 +117,12 @@ export const userOp1: SearchResultUserOp = {
|
||||
url: '/op/0xcb560d77b0f3af074fa05c1e5c691bcdfe457e630062b5907e9e71fc74b2ec61',
|
||||
};
|
||||
|
||||
export const blob1: SearchResultBlob = {
|
||||
blob_hash: '0x0108dd3e414da9f3255f7a831afa606e8dfaea93d082dfa9b15305583cbbdbbe',
|
||||
type: 'blob' as const,
|
||||
timestamp: null,
|
||||
};
|
||||
|
||||
export const baseResponse: SearchResult = {
|
||||
items: [
|
||||
token1,
|
||||
@@ -124,6 +131,7 @@ export const baseResponse: SearchResult = {
|
||||
address1,
|
||||
contract1,
|
||||
tx1,
|
||||
blob1,
|
||||
],
|
||||
next_page_params: null,
|
||||
};
|
||||
|
||||
@@ -341,3 +341,17 @@ export const base4 = {
|
||||
...base,
|
||||
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) => {
|
||||
|
||||
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" {
|
||||
| StaticRoute<"/auth/unverified-email">
|
||||
| DynamicRoute<"/batches/[number]", { "number": string }>
|
||||
| StaticRoute<"/batches">
|
||||
| DynamicRoute<"/blobs/[hash]", { "hash": string }>
|
||||
| DynamicRoute<"/block/[height_or_hash]", { "height_or_hash": string }>
|
||||
| StaticRoute<"/blocks">
|
||||
| StaticRoute<"/contract-verification">
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"graphql-ws": "^5.11.3",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash": "^4.0.0",
|
||||
"magic-bytes.js": "1.8.0",
|
||||
"mixpanel-browser": "^2.47.0",
|
||||
"monaco-editor": "^0.34.1",
|
||||
"next": "13.5.4",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 @@
|
||||
| "arrows/north-east"
|
||||
| "arrows/south-east"
|
||||
| "arrows/up-down"
|
||||
| "blob"
|
||||
| "blobs/image"
|
||||
| "blobs/raw"
|
||||
| "blobs/text"
|
||||
| "block_slim"
|
||||
| "block"
|
||||
| "brands/safe"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
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 {
|
||||
bitcoin_merged_mining_merkle_proof?: string | null;
|
||||
hash_for_merged_mining?: 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 {
|
||||
|
||||
@@ -55,6 +55,12 @@ export interface SearchResultTx {
|
||||
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 {
|
||||
type: 'user_operation';
|
||||
user_operation_hash: string;
|
||||
@@ -62,7 +68,8 @@ export interface SearchResultUserOp {
|
||||
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 {
|
||||
items: Array<SearchResultItem>;
|
||||
@@ -86,5 +93,5 @@ export interface SearchResultFilters {
|
||||
export interface SearchRedirectResult {
|
||||
parameter: string | null;
|
||||
redirect: boolean;
|
||||
type: 'address' | 'block' | 'transaction' | 'user_operation' | null;
|
||||
type: 'address' | 'block' | 'transaction' | 'user_operation' | 'blob' | null;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ export type Transaction = {
|
||||
zkevm_batch_number?: number;
|
||||
zkevm_status?: typeof ZKEVM_L2_TX_STATUSES[number];
|
||||
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' ];
|
||||
@@ -104,6 +110,15 @@ export interface TransactionsResponsePending {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TransactionsResponseWithBlobs {
|
||||
items: Array<Transaction>;
|
||||
next_page_params: {
|
||||
block_number: number;
|
||||
index: number;
|
||||
items_count: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface TransactionsResponseWatchlist {
|
||||
items: Array<Transaction>;
|
||||
next_page_params: {
|
||||
@@ -119,7 +134,8 @@ export type TransactionType = 'rootstock_remasc' |
|
||||
'contract_creation' |
|
||||
'contract_call' |
|
||||
'token_creation' |
|
||||
'coin_transfer'
|
||||
'coin_transfer' |
|
||||
'blob_transaction'
|
||||
|
||||
export type TxsResponse = TransactionsResponseValidated | TransactionsResponsePending | BlockTransactionsResponse;
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ export type TTxsFilters = {
|
||||
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';
|
||||
|
||||
@@ -16,3 +16,9 @@ export const TX_ADDITIONAL_FIELDS_IDS = [
|
||||
] as const;
|
||||
|
||||
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>;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
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);
|
||||
@@ -0,0 +1,31 @@
|
||||
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);
|
||||
@@ -0,0 +1,78 @@
|
||||
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);
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -51,6 +51,24 @@ test('genesis block', async({ mount, page }) => {
|
||||
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({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: contextWithEnvs(configs.viewsEnvs.block.hiddenFields) as any,
|
||||
|
||||
@@ -28,6 +28,7 @@ import RawDataSnippet from 'ui/shared/RawDataSnippet';
|
||||
import TextSeparator from 'ui/shared/TextSeparator';
|
||||
import Utilization from 'ui/shared/Utilization/Utilization';
|
||||
|
||||
import BlockDetailsBlobInfo from './details/BlockDetailsBlobInfo';
|
||||
import type { BlockQuery } from './useBlockQuery';
|
||||
|
||||
interface Props {
|
||||
@@ -114,6 +115,31 @@ const BlockDetails = ({ query }: Props) => {
|
||||
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 = (() => {
|
||||
switch (data.type) {
|
||||
case 'reorg':
|
||||
@@ -172,9 +198,7 @@ const BlockDetails = ({ query }: Props) => {
|
||||
isLoading={ isPlaceholderData }
|
||||
>
|
||||
<Skeleton isLoaded={ !isPlaceholderData }>
|
||||
<LinkInternal href={ route({ pathname: '/block/[height_or_hash]', query: { height_or_hash: heightOrHash, tab: 'txs' } }) }>
|
||||
{ data.tx_count } transaction{ data.tx_count === 1 ? '' : 's' }
|
||||
</LinkInternal>
|
||||
{ txsNum }
|
||||
</Skeleton>
|
||||
</DetailsInfoItem>
|
||||
{ config.features.beaconChain.isEnabled && Boolean(data.withdrawals_count) && (
|
||||
@@ -364,6 +388,8 @@ const BlockDetails = ({ query }: Props) => {
|
||||
<>
|
||||
<GridItem colSpan={{ base: undefined, lg: 2 }} mt={{ base: 1, lg: 4 }}/>
|
||||
|
||||
{ !isPlaceholderData && <BlockDetailsBlobInfo data={ data }/> }
|
||||
|
||||
{ data.bitcoin_merged_mining_header && (
|
||||
<DetailsInfoItem
|
||||
title="Bitcoin merged mining header"
|
||||
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,87 @@
|
||||
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);
|
||||
@@ -0,0 +1,26 @@
|
||||
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 {
|
||||
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 apiQuery = useQueryWithPages({
|
||||
@@ -0,0 +1,35 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
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';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import BlockDetails from 'ui/block/BlockDetails';
|
||||
import BlockWithdrawals from 'ui/block/BlockWithdrawals';
|
||||
import useBlockBlobTxsQuery from 'ui/block/useBlockBlobTxsQuery';
|
||||
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 TextAd from 'ui/shared/ad/TextAd';
|
||||
import ServiceDegradationWarning from 'ui/shared/alerts/ServiceDegradationWarning';
|
||||
@@ -40,8 +41,9 @@ const BlockPageContent = () => {
|
||||
const tab = getQueryParamString(router.query.tab);
|
||||
|
||||
const blockQuery = useBlockQuery({ heightOrHash });
|
||||
const blockTxsQuery = useBlockTxQuery({ heightOrHash, blockQuery, tab });
|
||||
const blockTxsQuery = useBlockTxsQuery({ heightOrHash, blockQuery, tab });
|
||||
const blockWithdrawalsQuery = useBlockWithdrawalsQuery({ heightOrHash, blockQuery, tab });
|
||||
const blockBlobTxsQuery = useBlockBlobTxsQuery({ heightOrHash, blockQuery, tab });
|
||||
|
||||
const tabs: Array<RoutedTab> = React.useMemo(() => ([
|
||||
{
|
||||
@@ -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) ?
|
||||
{
|
||||
id: 'withdrawals',
|
||||
@@ -75,7 +85,7 @@ const BlockPageContent = () => {
|
||||
</>
|
||||
),
|
||||
} : null,
|
||||
].filter(Boolean)), [ blockQuery, blockTxsQuery, blockWithdrawalsQuery ]);
|
||||
].filter(Boolean)), [ blockBlobTxsQuery, blockQuery, blockTxsQuery, blockWithdrawalsQuery ]);
|
||||
|
||||
const hasPagination = !isMobile && (
|
||||
(tab === 'txs' && blockTxsQuery.pagination.isVisible) ||
|
||||
|
||||
@@ -158,6 +158,31 @@ test('search by tx hash +@mobile', async({ mount, page }) => {
|
||||
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({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: contextWithEnvs(configs.featureEnvs.userOps) as any,
|
||||
|
||||
@@ -58,6 +58,11 @@ const SearchResultsPageContent = () => {
|
||||
router.replace({ pathname: '/op/[hash]', query: { hash: redirectCheckQuery.data.parameter } });
|
||||
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';
|
||||
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
|
||||
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
|
||||
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
|
||||
import TxBlobs from 'ui/tx/TxBlobs';
|
||||
import TxDetails from 'ui/tx/TxDetails';
|
||||
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
|
||||
import TxDetailsWrapped from 'ui/tx/TxDetailsWrapped';
|
||||
@@ -55,6 +56,9 @@ const TransactionPageContent = () => {
|
||||
{ id: 'user_ops', title: 'User operations', component: <TxUserOps txQuery={ txQuery }/> } :
|
||||
undefined,
|
||||
{ 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: 'state', title: 'State', component: <TxState txQuery={ txQuery }/> },
|
||||
{ id: 'raw_trace', title: 'Raw trace', component: <TxRawTrace txQuery={ txQuery }/> },
|
||||
|
||||
@@ -7,6 +7,7 @@ import config from 'configs/app';
|
||||
import useHasAccount from 'lib/hooks/useHasAccount';
|
||||
import useIsMobile from 'lib/hooks/useIsMobile';
|
||||
import useNewTxsSocket from 'lib/hooks/useNewTxsSocket';
|
||||
import getQueryParamString from 'lib/router/getQueryParamString';
|
||||
import { TX } from 'stubs/tx';
|
||||
import { generateListStub } from 'stubs/utils';
|
||||
import PageTitle from 'ui/shared/Page/PageTitle';
|
||||
@@ -26,11 +27,19 @@ const Transactions = () => {
|
||||
const verifiedTitle = config.chain.verificationType === 'validation' ? 'Validated' : 'Mined';
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
const txsQuery = useQueryWithPages({
|
||||
resourceName: router.query.tab === 'pending' ? 'txs_pending' : 'txs_validated',
|
||||
filters: { filter: router.query.tab === 'pending' ? 'pending' : 'validated' },
|
||||
const tab = getQueryParamString(router.query.tab);
|
||||
|
||||
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: {
|
||||
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: {
|
||||
block_number: 9005713,
|
||||
index: 5,
|
||||
@@ -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({
|
||||
resourceName: 'txs_watchlist',
|
||||
options: {
|
||||
enabled: router.query.tab === 'watchlist',
|
||||
enabled: tab === 'watchlist',
|
||||
placeholderData: generateListStub<'txs_watchlist'>(TX, 50, { next_page_params: {
|
||||
block_number: 9005713,
|
||||
index: 5,
|
||||
@@ -61,15 +96,32 @@ const Transactions = () => {
|
||||
id: 'validated',
|
||||
title: verifiedTitle,
|
||||
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',
|
||||
title: 'Pending',
|
||||
component: (
|
||||
<TxsWithFrontendSorting
|
||||
query={ txsQuery }
|
||||
query={ txsPendingQuery }
|
||||
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 }
|
||||
socketInfoAlert={ socketAlert }
|
||||
/>
|
||||
@@ -82,7 +134,14 @@ const Transactions = () => {
|
||||
} : undefined,
|
||||
].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 (
|
||||
<>
|
||||
|
||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
|
||||
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
|
||||
import { ADDRESS_REGEXP } from 'lib/validations/address';
|
||||
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 TokenEntity from 'ui/shared/entities/token/TokenEntity';
|
||||
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
@@ -200,6 +201,28 @@ const SearchResultListItem = ({ data, searchTerm, isLoading }: Props) => {
|
||||
</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': {
|
||||
return (
|
||||
<UserOpEntity.Container>
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as mixpanel from 'lib/mixpanel/index';
|
||||
import { saveToRecentKeywords } from 'lib/recentSearchKeywords';
|
||||
import { ADDRESS_REGEXP } from 'lib/validations/address';
|
||||
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 TokenEntity from 'ui/shared/entities/token/TokenEntity';
|
||||
import * as TxEntity from 'ui/shared/entities/tx/TxEntity';
|
||||
@@ -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': {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -37,6 +37,7 @@ const AdditionalInfoButton = ({ isOpen, onClick, className, isLoading }: Props,
|
||||
onClick={ onClick }
|
||||
cursor="pointer"
|
||||
flexShrink={ 0 }
|
||||
aria-label="Transaction info"
|
||||
>
|
||||
<IconSvg
|
||||
name="info"
|
||||
|
||||
@@ -25,7 +25,7 @@ const CopyToClipboard = ({ text, className, isLoading }: Props) => {
|
||||
}, [ hasCopied ]);
|
||||
|
||||
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 (
|
||||
|
||||
@@ -36,6 +36,7 @@ const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textare
|
||||
borderRadius="md"
|
||||
wordBreak="break-all"
|
||||
whiteSpace="pre-wrap"
|
||||
overflowX="hidden"
|
||||
overflowY="auto"
|
||||
isLoaded={ !isLoading }
|
||||
>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,90 @@
|
||||
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>({
|
||||
}, [ queryClient, resourceName, router, scrollToTop ]);
|
||||
|
||||
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) {
|
||||
Object.entries(newFilters).forEach(([ key, value ]) => {
|
||||
const isValidValue = typeof value === 'boolean' || (value && value.length);
|
||||
@@ -170,7 +170,7 @@ export default function useQueryWithPages<Resource extends PaginatedResources>({
|
||||
setPage(1);
|
||||
setPageParams(INITIAL_PAGE_PARAMS);
|
||||
});
|
||||
}, [ router, resource.filterFields, scrollToTop ]);
|
||||
}, [ router, resource, scrollToTop ]);
|
||||
|
||||
const onSortingChange = useCallback((newSorting: PaginationSorting<Resource> | undefined) => {
|
||||
const newQuery = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MarketplaceAppOverview } from 'types/client/marketplace';
|
||||
|
||||
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 ItemsCategoriesMap =
|
||||
@@ -23,6 +23,7 @@ export const searchCategories: Array<{id: Category; title: string }> = [
|
||||
{ id: 'public_tag', title: 'Public tags' },
|
||||
{ id: 'transaction', title: 'Transactions' },
|
||||
{ id: 'block', title: 'Blocks' },
|
||||
{ id: 'blob', title: 'Blobs' },
|
||||
];
|
||||
|
||||
if (config.features.userOps.isEnabled) {
|
||||
@@ -38,6 +39,7 @@ export const searchItemTitles: Record<Category, { itemTitle: string; itemTitleSh
|
||||
transaction: { itemTitle: 'Transaction', itemTitleShort: 'Txn' },
|
||||
block: { itemTitle: 'Block', itemTitleShort: 'Block' },
|
||||
user_operation: { itemTitle: 'User operation', itemTitleShort: 'User op' },
|
||||
blob: { itemTitle: 'Blob', itemTitleShort: 'Blob' },
|
||||
};
|
||||
|
||||
export function getItemCategory(item: SearchResultItem | SearchResultAppItem): Category | undefined {
|
||||
@@ -67,5 +69,8 @@ export function getItemCategory(item: SearchResultItem | SearchResultAppItem): C
|
||||
case 'user_operation': {
|
||||
return 'user_operation';
|
||||
}
|
||||
case 'blob': {
|
||||
return 'blob';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ test('search by token name +@mobile +@dark-mode', async({ mount, page }) => {
|
||||
<SearchBar/>
|
||||
</TestApp>,
|
||||
);
|
||||
await page.getByPlaceholder(/search/i).type('o');
|
||||
await page.getByPlaceholder(/search/i).fill('o');
|
||||
await page.waitForResponse(API_URL);
|
||||
|
||||
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 }) =>
|
||||
<SearchBar/>
|
||||
</TestApp>,
|
||||
);
|
||||
await page.getByPlaceholder(/search/i).type('o');
|
||||
await page.getByPlaceholder(/search/i).fill('o');
|
||||
await page.waitForResponse(API_URL);
|
||||
|
||||
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 }) => {
|
||||
<SearchBar isHomepage/>
|
||||
</TestApp>,
|
||||
);
|
||||
await page.getByPlaceholder(/search/i).type('o');
|
||||
await page.getByPlaceholder(/search/i).fill('o');
|
||||
await page.waitForResponse(API_URL);
|
||||
|
||||
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 }) => {
|
||||
<SearchBar/>
|
||||
</TestApp>,
|
||||
);
|
||||
await page.getByPlaceholder(/search/i).type('o');
|
||||
await page.getByPlaceholder(/search/i).fill('o');
|
||||
await page.waitForResponse(API_URL);
|
||||
|
||||
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 }) => {
|
||||
<SearchBar/>
|
||||
</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 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 }) => {
|
||||
<SearchBar/>
|
||||
</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 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 }) => {
|
||||
<SearchBar/>
|
||||
</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 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 }) => {
|
||||
<SearchBar/>
|
||||
</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 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 }) => {
|
||||
<SearchBar/>
|
||||
</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 } });
|
||||
@@ -251,7 +271,7 @@ test('search with view all link', async({ mount, page }) => {
|
||||
<SearchBar/>
|
||||
</TestApp>,
|
||||
);
|
||||
await page.getByPlaceholder(/search/i).type('o');
|
||||
await page.getByPlaceholder(/search/i).fill('o');
|
||||
|
||||
await page.waitForResponse(API_URL);
|
||||
|
||||
@@ -283,7 +303,7 @@ test('scroll suggest to category', async({ mount, page }) => {
|
||||
<SearchBar/>
|
||||
</TestApp>,
|
||||
);
|
||||
await page.getByPlaceholder(/search/i).type('o');
|
||||
await page.getByPlaceholder(/search/i).fill('o');
|
||||
await page.waitForResponse(API_URL);
|
||||
|
||||
await page.getByRole('tab', { name: 'Addresses' }).click();
|
||||
@@ -345,7 +365,7 @@ base.describe('with apps', () => {
|
||||
<SearchBar/>
|
||||
</TestApp>,
|
||||
);
|
||||
await page.getByPlaceholder(/search/i).type('o');
|
||||
await page.getByPlaceholder(/search/i).fill('o');
|
||||
|
||||
await page.waitForResponse(API_URL);
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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';
|
||||
import { route } from 'nextjs-routes';
|
||||
|
||||
import SearchBarSuggestAddress from './SearchBarSuggestAddress';
|
||||
import SearchBarSuggestBlob from './SearchBarSuggestBlob';
|
||||
import SearchBarSuggestBlock from './SearchBarSuggestBlock';
|
||||
import SearchBarSuggestItemLink from './SearchBarSuggestItemLink';
|
||||
import SearchBarSuggestLabel from './SearchBarSuggestLabel';
|
||||
@@ -42,6 +43,9 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm, onClick }: Props) =>
|
||||
case 'user_operation': {
|
||||
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) =>
|
||||
case 'user_operation': {
|
||||
return <SearchBarSuggestUserOp data={ data } searchTerm={ searchTerm } isMobile={ isMobile }/>;
|
||||
}
|
||||
case 'blob': {
|
||||
return <SearchBarSuggestBlob data={ data } searchTerm={ searchTerm }/>;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,39 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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;
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
@@ -0,0 +1,54 @@
|
||||
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) => {
|
||||
<Text as="span" fontWeight="500">Txn type: </Text>
|
||||
<Text fontWeight="600" as="span">{ type }</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 key="nonce">
|
||||
|
||||
@@ -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({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: contextWithEnvs(configs.featureEnvs.optimisticRollup) as any,
|
||||
|
||||
@@ -45,6 +45,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability';
|
||||
import Utilization from 'ui/shared/Utilization/Utilization';
|
||||
import VerificationSteps from 'ui/shared/verificationSteps/VerificationSteps';
|
||||
import TxDetailsActions from 'ui/tx/details/txDetailsActions/TxDetailsActions';
|
||||
import TxDetailsBurntFees from 'ui/tx/details/TxDetailsBurntFees';
|
||||
import TxDetailsFeePerGas from 'ui/tx/details/TxDetailsFeePerGas';
|
||||
import TxDetailsGasPrice from 'ui/tx/details/TxDetailsGasPrice';
|
||||
import TxDetailsOther from 'ui/tx/details/TxDetailsOther';
|
||||
@@ -357,7 +358,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
|
||||
{ !config.UI.views.tx.hiddenFields?.tx_fee && (
|
||||
<DetailsInfoItem
|
||||
title="Transaction fee"
|
||||
hint="Total transaction fee"
|
||||
hint={ data.blob_gas_used ? 'Transaction fee without blob fee' : 'Total transaction fee' }
|
||||
isLoading={ isLoading }
|
||||
>
|
||||
{ data.stability_fee ? (
|
||||
@@ -422,21 +423,7 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
|
||||
) }
|
||||
</DetailsInfoItem>
|
||||
) }
|
||||
{ data.tx_burnt_fee && !config.UI.views.tx.hiddenFields?.burnt_fees && !(rollupFeature.isEnabled && rollupFeature.type === 'optimistic') && (
|
||||
<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>
|
||||
) }
|
||||
<TxDetailsBurntFees data={ data } isLoading={ isLoading }/>
|
||||
{ rollupFeature.isEnabled && rollupFeature.type === 'optimistic' && (
|
||||
<>
|
||||
{ data.l1_gas_used && (
|
||||
@@ -502,6 +489,50 @@ const TxInfo = ({ data, isLoading, socketStatus }: Props) => {
|
||||
{ isExpanded && (
|
||||
<>
|
||||
<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 }/>
|
||||
<DetailsInfoItem
|
||||
title="Raw input"
|
||||
|
||||
|
After Width: | Height: | Size: 152 KiB |
@@ -0,0 +1,43 @@
|
||||
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 } });
|
||||
});
|
||||