Commit 1bdcf51d authored by tom's avatar tom

Merge branch 'main' of github.com:blockscout/frontend into token-transfer-tx-info

parents dc90f9d9 0063a49c
......@@ -51,3 +51,4 @@ NEXT_PUBLIC_API_PORT=__PLACEHOLDER_FOR_NEXT_PUBLIC_API_PORT__
# external services config
NEXT_PUBLIC_SENTRY_DSN=__PLACEHOLDER_FOR_NEXT_PUBLIC_SENTRY_DSN__
NEXT_PUBLIC_AUTH0_CLIENT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_AUTH0_CLIENT_ID__
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=__PLACEHOLDER_FOR_NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID__
......@@ -6,6 +6,7 @@ WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock ./
RUN apk add git
RUN yarn --frozen-lockfile
# Rebuild the source code only when needed
......
......@@ -125,6 +125,7 @@ The app instance could be customized by passing following variables to NodeJS en
| NEXT_PUBLIC_SENTRY_DSN | `string` *(optional)* | Client key for your Sentry.io app | `<secret>` |
| SENTRY_CSP_REPORT_URI | `string` *(optional)* | URL for sending CSP-reports to your Sentry.io app | `<secret>` |
| NEXT_PUBLIC_AUTH0_CLIENT_ID | `string` *(optional)* | Client id for [Auth0](https://auth0.com/) provider | `<secret>` |
| NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID | `string` | Project id for [WalletConnect](https://docs.walletconnect.com/2.0/web3modal/react/installation#obtain-project-id) integration | `<secret>` |
### Marketplace app configuration properties
......
......@@ -115,6 +115,9 @@ const config = Object.freeze({
showGasTracker: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_SHOW_GAS_TRACKER) === 'false' ? false : true,
showAvgBlockTime: getEnvValue(process.env.NEXT_PUBLIC_HOMEPAGE_SHOW_AVG_BLOCK_TIME) === 'false' ? false : true,
},
walletConnect: {
projectId: getEnvValue(process.env.NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID),
},
});
export default config;
......@@ -3,11 +3,11 @@ NEXT_PUBLIC_FEATURED_NETWORKS=[{'title':'Ethereum','url':'https://blockscout.com
NEXT_PUBLIC_NETWORK_EXPLORERS=[{'title':'Anyblock','baseUrl':'https://explorer.anyblock.tools','paths':{'tx':'/ethereum/ethereum/goerli/transaction','address':'/ethereum/ethereum/goerli/address'}},{'title':'Etherscan','baseUrl':'https://goerli.etherscan.io/','paths':{'tx':'/tx','address':'/address'}}]
# network config
NEXT_PUBLIC_NETWORK_NAME=Ethereum
NEXT_PUBLIC_NETWORK_NAME=Goerli
NEXT_PUBLIC_NETWORK_SHORT_NAME=Goerli
NEXT_PUBLIC_NETWORK_ASSETS_PATHNAME=ethereum
NEXT_PUBLIC_NETWORK_TYPE=goerli
NEXT_PUBLIC_NETWORK_ID=420
NEXT_PUBLIC_NETWORK_ID=5
NEXT_PUBLIC_NETWORK_CURRENCY_NAME=Ether
NEXT_PUBLIC_NETWORK_CURRENCY_SYMBOL=ETH
NEXT_PUBLIC_NETWORK_CURRENCY_DECIMALS=18
......
......@@ -61,6 +61,10 @@ const oldUrls = [
oldPath: '/address/:id/validations',
newPath: `${ PATHS.address_index }?tab=blocks_validated`,
},
{
oldPath: '/address/:id/tokens/:hash/token-transfers',
newPath: `${ PATHS.address_index }?tab=token_transfers&token=:hash`,
},
];
async function redirects() {
......
......@@ -473,6 +473,8 @@ frontend:
# - "/address"
- "/stats"
- "/search-results"
- "/tokens"
- "/accounts"
resources:
limits:
memory:
......
......@@ -322,6 +322,8 @@ frontend:
- "/stats"
- "/search-results"
- "/token"
- "/tokens"
- "/accounts"
resources:
limits:
......
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.4 2.933a.156.156 0 0 0-.155.156v10.844a.467.467 0 0 1-.934 0V3.09A1.089 1.089 0 0 1 6.401 2h7.474a.467.467 0 0 1 .322.137l4.355 4.355a.467.467 0 0 1 .137.33v7.111a.467.467 0 0 1-.933 0V7.29h-3.89a.467.467 0 0 1-.466-.467v-3.89h-7Zm7.933.66v2.763h2.763l-2.763-2.763Zm-3.11 14.385c0-.186.064-.305.157-.385.1-.086.275-.16.542-.16.32.001.631.105.888.297a.467.467 0 0 0 .558-.749 2.427 2.427 0 0 0-1.444-.481h-.002c-.433 0-.841.12-1.15.384-.315.27-.483.657-.483 1.094 0 .235.065.447.189.628.12.175.28.297.436.386.28.16.633.253.915.327l.051.014c.327.087.568.157.733.257.13.08.165.145.165.254 0 .24-.084.34-.184.404-.134.086-.36.14-.671.14-.32 0-.632-.104-.888-.296a.467.467 0 0 0-.558.749c.417.31.923.48 1.444.481h.001c.388 0 .823-.062 1.175-.287.386-.247.614-.654.614-1.19 0-.513-.277-.847-.613-1.052-.298-.181-.674-.281-.967-.36l-.011-.002c-.333-.089-.576-.154-.745-.25a.377.377 0 0 1-.128-.103c-.012-.018-.025-.044-.025-.1Zm4.02-1.364c.238-.1.51.013.61.25l1.125 2.7 1.124-2.7a.467.467 0 1 1 .862.36l-1.556 3.733a.467.467 0 0 1-.861 0l-1.556-3.733a.466.466 0 0 1 .252-.61Zm-8.067.897c-.653.001-1.243.585-1.243 1.4s.59 1.399 1.243 1.4c.287-.003.564-.112.776-.306a.467.467 0 1 1 .63.69 2.1 2.1 0 0 1-1.4.55h-.004C5.934 21.244 5 20.163 5 18.91s.934-2.333 2.178-2.333h.004a2.1 2.1 0 0 1 1.4.55.467.467 0 1 1-.63.689 1.167 1.167 0 0 0-.776-.306Z" fill="currentColor" stroke="currentColor" stroke-width=".4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m18.27 5.648-2.916-2.916A2.512 2.512 0 0 0 13.586 2H6.5A2.5 2.5 0 0 0 4 4.5v15A2.5 2.5 0 0 0 6.5 22h10c1.375 0 2.5-1.125 2.5-2.5V7.414c0-.66-.262-1.297-.73-1.766ZM17.125 19.5c0 .345-.28.625-.625.625h-10a.625.625 0 0 1-.624-.625L5.875 4.505c0-.345.28-.625.625-.625h6.25V7c0 .69.56 1.25 1.25 1.25h3.09V19.5h.035Zm-4.715-6.094a.547.547 0 0 0-.455.244l-1.549 2.327-.456-.685a.544.544 0 0 0-.909-.002l-1.823 2.735a.547.547 0 0 0-.027.561.53.53 0 0 0 .446.289h7.656a.547.547 0 0 0 .455-.85L12.83 13.65c-.065-.154-.237-.244-.42-.244ZM9 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.4 2.933a.156.156 0 0 0-.156.156v10.844a.467.467 0 0 1-.933 0V3.09A1.089 1.089 0 0 1 6.4 2h7.476a.467.467 0 0 1 .32.137l4.356 4.355a.467.467 0 0 1 .137.33v7.111a.467.467 0 0 1-.934 0V7.29h-3.889a.467.467 0 0 1-.466-.467v-3.89h-7Zm7.933.66v2.763h2.762l-2.762-2.763Z" fill="currentColor" stroke="currentColor" stroke-width=".4" stroke-linecap="round" stroke-linejoin="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.752 20.137c-.016-.04-.03-.08-.043-.12-.068-.223.123-.42.356-.42h.074c.16 0 .29.123.369.263.045.08.103.147.17.2a.787.787 0 0 0 .256.136.99.99 0 0 0 .326.05.944.944 0 0 0 .421-.085.591.591 0 0 0 .26-.232.69.69 0 0 0 .086-.348.538.538 0 0 0-.036-.22.46.46 0 0 0-.119-.173c-.103-.096-.261-.173-.473-.232l-.63-.172a1.612 1.612 0 0 1-.55-.257 1.142 1.142 0 0 1-.36-.442 1.475 1.475 0 0 1-.125-.63c0-.293.065-.55.194-.768.13-.218.31-.388.538-.508.23-.12.495-.179.794-.179.31 0 .575.06.795.183.221.122.392.287.51.493.051.085.092.174.124.267.075.217-.117.414-.346.414-.194 0-.35-.152-.462-.31a.655.655 0 0 0-.25-.218.817.817 0 0 0-.379-.082c-.22 0-.395.06-.522.183a.559.559 0 0 0-.141.204.654.654 0 0 0-.048.258c0 .145.049.264.147.36a.922.922 0 0 0 .412.21l.634.173c.222.06.415.144.578.254.157.101.29.25.383.43.091.178.137.403.137.674a1.6 1.6 0 0 1-.191.789 1.308 1.308 0 0 1-.55.528c-.24.126-.531.19-.876.19-.26 0-.486-.036-.68-.109a1.375 1.375 0 0 1-.487-.303 1.355 1.355 0 0 1-.296-.45Zm-3.168-.04a1.803 1.803 0 0 1-.045-.17c-.044-.212.136-.39.354-.39h.05c.187 0 .332.157.412.325a.56.56 0 0 0 .197.224.448.448 0 0 0 .267.072c.194 0 .337-.066.43-.198.093-.132.14-.318.14-.56v-2.898a.404.404 0 0 1 .808 0v2.874c0 .53-.122.93-.365 1.209-.242.276-.576.415-1.005.415a1.402 1.402 0 0 1-.58-.113 1.17 1.17 0 0 1-.415-.32 1.428 1.428 0 0 1-.248-.47Zm9.279-1.906v.628c0 .308-.04.565-.12.771-.067.19-.182.352-.328.466a.795.795 0 0 1-.48.151.8.8 0 0 1-.48-.151 1.027 1.027 0 0 1-.326-.466 2.159 2.159 0 0 1-.12-.77v-.629c0-.31.04-.566.12-.77.067-.19.181-.352.326-.466a.787.787 0 0 1 .48-.155c.18 0 .34.052.48.155.146.113.26.275.329.465.08.205.119.461.119.771Zm.82.625v-.617c0-.454-.07-.844-.21-1.17a1.674 1.674 0 0 0-.602-.758c-.258-.176-.57-.265-.935-.265a1.65 1.65 0 0 0-.939.265c-.26.173-.47.437-.601.755-.14.326-.21.717-.21 1.173v.617c0 .45.07.84.21 1.17.14.326.34.577.601.753.263.174.576.26.94.26s.676-.086.935-.26c.261-.176.462-.427.601-.753.14-.33.21-.72.21-1.17ZM16 17.69v2.838a.38.38 0 0 1-.762 0v-4.003a.426.426 0 0 1 .797-.208l1.66 2.97a.02.02 0 0 0 .04-.01v-2.796a.383.383 0 1 1 .765 0v4.006a.422.422 0 0 1-.79.206l-1.661-2.974a.056.056 0 0 0-.049-.028Z" fill="currentColor" stroke="currentColor" stroke-width=".3"/>
</svg>
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="m12.18 3.765-1.944-1.944a1.674 1.674 0 0 0-1.179-.488H4.333c-.92 0-1.666.746-1.666 1.667v10c0 .92.746 1.667 1.667 1.667H11c.917 0 1.667-.75 1.667-1.667V4.943c0-.44-.175-.865-.487-1.178ZM11.417 13c0 .23-.187.417-.417.417H4.334A.417.417 0 0 1 3.917 13V3.003c0-.23.186-.416.416-.416H8.5v2.08c0 .46.373.833.833.833h2.06V13h.024ZM8.273 8.938a.365.365 0 0 0-.303.162l-1.032 1.55-.305-.456a.363.363 0 0 0-.606-.001l-1.215 1.823a.364.364 0 0 0-.018.374c.063.12.186.192.297.192h5.104a.365.365 0 0 0 .304-.566L8.554 9.1c-.044-.103-.158-.162-.28-.162ZM6 8.833a.833.833 0 1 0 0-1.666.833.833 0 0 0 0 1.666Z" fill="currentColor"/>
</svg>
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 3c0-.184.15-.333.333-.333h4.334v2c0 .368.298.666.666.666h2v1.334h1.334v-.92c0-.441-.176-.865-.489-1.178L9.431 1.822a1.667 1.667 0 0 0-1.179-.489H4.333c-.92 0-1.666.747-1.666 1.667v3.667H4V3Zm0 10v-1H2.667v1c0 .92.746 1.667 1.666 1.667H11c.92 0 1.667-.746 1.667-1.667v-1h-1.334v1a.333.333 0 0 1-.333.333H4.333A.333.333 0 0 1 4 13Zm1.167-5.833H4.75c-.575 0-1.042.466-1.042 1.041v2.084c0 .575.466 1.041 1.042 1.041h.417a1.04 1.04 0 0 0 1.041-1.041v-.209a.418.418 0 0 0-.416-.416.418.418 0 0 0-.417.416v.209a.209.209 0 0 1-.208.208H4.75a.209.209 0 0 1-.208-.208V8.208c0-.114.093-.208.208-.208h.417a.21.21 0 0 1 .208.208v.209a.417.417 0 0 0 .833 0v-.209a1.04 1.04 0 0 0-1.041-1.041Zm2.666 0a1.21 1.21 0 0 0-.599 2.258l.662.377a.375.375 0 0 1-.188.7h-.666a.418.418 0 0 0-.417.417c0 .23.188.417.417.417h.666a1.21 1.21 0 0 0 .6-2.258l-.662-.377a.375.375 0 0 1 .188-.7h.458a.418.418 0 0 0 0-.833h-.459Zm2.125.416v.823c0 .6.144 1.188.417 1.719a3.737 3.737 0 0 0 .417-1.719v-.823a.417.417 0 0 1 .833 0v.823a4.58 4.58 0 0 1-.77 2.542l-.134.2a.415.415 0 0 1-.692 0l-.133-.2a4.58 4.58 0 0 1-.771-2.542v-.823a.417.417 0 0 1 .833 0Z" fill="currentColor"/>
</svg>
......@@ -2,6 +2,7 @@ import { compile } from 'path-to-regexp';
import appConfig from 'configs/app/config';
import isNeedProxy from './isNeedProxy';
import { RESOURCES } from './resources';
import type { ApiResource, ResourceName } from './resources';
......@@ -10,23 +11,10 @@ export default function buildUrl(
pathParams?: Record<string, string | undefined>,
queryParams?: Record<string, string | Array<string> | number | undefined>,
) {
// FIXME
// 1. I was not able to figure out how to send CORS with credentials from localhost
// unsuccessfully tried different ways, even custom local dev domain
// so for local development we have to use next.js api as proxy server
// 2. and there is an issue with API and csrf token
// for some reason API will reply with error "Bad request" to any PUT / POST CORS request
// even though valid csrf-token is passed in header
// we also can pass token in request body but in this case API will replay with "Forbidden" error
// @nikitosing said it will take a lot of time to debug this problem on back-end side, maybe he'll change his mind in future :)
// To sum up, we are using next.js proxy for all instances where app host is not the same as API host (incl. localhost)
// will need to change the condition if there are more micro services that need authentication and DB state changes
const needProxy = appConfig.host !== appConfig.api.host;
const resource: ApiResource = typeof _resource === 'string' ? RESOURCES[_resource] : _resource;
const baseUrl = needProxy ? appConfig.baseUrl : (resource.endpoint || appConfig.api.endpoint);
const baseUrl = isNeedProxy() ? appConfig.baseUrl : (resource.endpoint || appConfig.api.endpoint);
const basePath = resource.basePath !== undefined ? resource.basePath : appConfig.api.basePath;
const path = needProxy ? '/node-api/proxy' + basePath + resource.path : basePath + resource.path;
const path = isNeedProxy() ? '/node-api/proxy' + basePath + resource.path : basePath + resource.path;
const url = new URL(compile(path)(pathParams), baseUrl);
queryParams && Object.entries(queryParams).forEach(([ key, value ]) => {
......
import appConfig from 'configs/app/config';
// FIXME
// I was not able to figure out how to send CORS with credentials from localhost
// unsuccessfully tried different ways, even custom local dev domain
// so for local development we have to use next.js api as proxy server
export default function isNeedProxy() {
return appConfig.host === 'localhost' && appConfig.host !== appConfig.api.host;
}
......@@ -24,8 +24,9 @@ export default function fetchFactory(
});
return nodeFetch(url, {
headers,
...init,
headers,
body: init?.body ? JSON.stringify(init.body) : undefined,
});
};
}
......@@ -14,6 +14,7 @@ import type {
AddressTokensFilter,
AddressTokensResponse,
} from 'types/api/address';
import type { AddressesResponse } from 'types/api/addresses';
import type { BlocksResponse, BlockTransactionsResponse, Block, BlockFilters } from 'types/api/block';
import type { ChartMarketResponse, ChartTransactionResponse } from 'types/api/charts';
import type { SmartContract, SmartContractReadMethod, SmartContractWriteMethod } from 'types/api/contract';
......@@ -24,6 +25,7 @@ import type { RawTracesResponse } from 'types/api/rawTrace';
import type { SearchResult, SearchResultFilters } from 'types/api/search';
import type { Counters, StatsCharts, StatsChart, HomeStats } from 'types/api/stats';
import type { TokenCounters, TokenInfo, TokenHolders } from 'types/api/tokenInfo';
import type { TokensResponse, TokensFilters } from 'types/api/tokens';
import type { TokenTransferResponse, TokenTransferFilters } from 'types/api/tokenTransfer';
import type { TransactionsResponseValidated, TransactionsResponsePending, Transaction } from 'types/api/transaction';
import type { TTxsFilters } from 'types/api/txsFilters';
......@@ -127,6 +129,13 @@ export const RESOURCES = {
path: '/api/v2/transactions/:id/raw-trace',
},
// ADDRESSES
addresses: {
path: '/api/v2/addresses/',
paginationFields: [ 'fetched_coin_balance' as const, 'hash' as const, 'items_count' as const ],
filterFields: [ ],
},
// ADDRESS
address: {
path: '/api/v2/addresses/:id',
......@@ -150,7 +159,7 @@ export const RESOURCES = {
address_token_transfers: {
path: '/api/v2/addresses/:id/token-transfers',
paginationFields: [ 'block_number' as const, 'items_count' as const, 'index' as const, 'transaction_index' as const ],
filterFields: [ 'filter' as const, 'type' as const ],
filterFields: [ 'filter' as const, 'type' as const, 'token' as const ],
},
address_blocks_validated: {
path: '/api/v2/addresses/:id/blocks-validated',
......@@ -208,6 +217,16 @@ export const RESOURCES = {
paginationFields: [ 'items_count' as const, 'value' as const ],
filterFields: [],
},
token_transfers: {
path: '/api/v2/tokens/:hash/transfers',
paginationFields: [ 'block_number' as const, 'items_count' as const, 'index' as const ],
filterFields: [],
},
tokens: {
path: '/api/v2/tokens',
paginationFields: [ 'holder_count' as const, 'items_count' as const, 'name' as const ],
filterFields: [ 'filter' as const, 'type' as const ],
},
// HOMEPAGE
homepage_stats: {
......@@ -275,10 +294,11 @@ export type ResourceErrorAccount<T> = ResourceError<{ errors: T }>
export type PaginatedResources = 'blocks' | 'block_txs' |
'txs_validated' | 'txs_pending' |
'tx_internal_txs' | 'tx_logs' | 'tx_token_transfers' |
'addresses' |
'address_txs' | 'address_internal_txs' | 'address_token_transfers' | 'address_blocks_validated' | 'address_coin_balance' |
'search' |
'address_logs' | 'address_tokens' |
'token_holders';
'token_transfers' | 'token_holders' | 'tokens';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
......@@ -310,6 +330,7 @@ Q extends 'tx_internal_txs' ? InternalTransactionsResponse :
Q extends 'tx_logs' ? LogsResponseTx :
Q extends 'tx_token_transfers' ? TokenTransferResponse :
Q extends 'tx_raw_trace' ? RawTracesResponse :
Q extends 'addresses' ? AddressesResponse :
Q extends 'address' ? Address :
Q extends 'address_counters' ? AddressCounters :
Q extends 'address_token_balances' ? Array<AddressTokenBalance> :
......@@ -323,7 +344,9 @@ Q extends 'address_logs' ? LogsResponseAddress :
Q extends 'address_tokens' ? AddressTokensResponse :
Q extends 'token' ? TokenInfo :
Q extends 'token_counters' ? TokenCounters :
Q extends 'token_transfers' ? TokenTransferResponse :
Q extends 'token_holders' ? TokenHolders :
Q extends 'tokens' ? TokensResponse :
Q extends 'search' ? SearchResult :
Q extends 'contract' ? SmartContract :
Q extends 'contract_methods_read' ? Array<SmartContractReadMethod> :
......@@ -338,9 +361,11 @@ export type PaginationFilters<Q extends PaginatedResources> =
Q extends 'blocks' ? BlockFilters :
Q extends 'txs_validated' | 'txs_pending' ? TTxsFilters :
Q extends 'tx_token_transfers' ? TokenTransferFilters :
Q extends 'token_transfers' ? TokenTransferFilters :
Q extends 'address_txs' | 'address_internal_txs' ? AddressTxsFilters :
Q extends 'address_token_transfers' ? AddressTokenTransferFilters :
Q extends 'address_tokens' ? AddressTokensFilter :
Q extends 'search' ? SearchResultFilters :
Q extends 'tokens' ? TokensFilters :
never;
/* eslint-enable @typescript-eslint/indent */
......@@ -7,6 +7,7 @@ export enum NAMES {
API_TOKEN='_explorer_key',
TXS_SORT='txs_sort',
COLOR_MODE='chakra-ui-color-mode',
INDEXING_ALERT='indexing_alert',
}
export function get(name?: NAMES | undefined | null, serverCookie?: string) {
......
......@@ -68,8 +68,16 @@ function makePolicyMap() {
appConfig.api.socket,
appConfig.statsApi.endpoint,
// chain RPC server
appConfig.network.rpcUrl,
// ad
'request-global.czilladx.com',
// walletconnect
'*.walletconnect.com',
'wss://*.bridge.walletconnect.org',
'wss://www.walletlink.org',
],
'script-src': [
......@@ -130,6 +138,9 @@ function makePolicyMap() {
// ad
'servedbyadbutler.com',
'cdn.coinzilla.io',
// walletconnect
'*.walletconnect.com',
],
'font-src': [
......
......@@ -28,7 +28,6 @@ export default function useFetch() {
headers: {
...(hasBody ? { 'Content-type': 'application/json' } : undefined),
...params?.headers,
// ...(token ? { 'x-csrf-token': token } : {}),
},
};
......
import { useQuery } from '@tanstack/react-query';
import buildUrl from 'lib/api/buildUrl';
import isNeedProxy from 'lib/api/isNeedProxy';
import { getResourceKey } from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
import useFetch from 'lib/hooks/useFetch';
export default function useGetCsrfToken() {
const nodeApiFetch = useFetch();
useQuery(getResourceKey('csrf'), async() => {
if (!isNeedProxy()) {
const url = buildUrl('csrf');
const apiResponse = await fetch(url, { credentials: 'include' });
const csrfFromHeader = apiResponse.headers.get('x-bs-account-csrf');
// eslint-disable-next-line no-console
console.log('>>> RESPONSE HEADERS <<<');
// eslint-disable-next-line no-console
console.table([ {
'content-length': apiResponse.headers.get('content-length'),
'x-bs-account-csrf': csrfFromHeader,
} ]);
return csrfFromHeader ? { token: csrfFromHeader } : undefined;
}
return nodeApiFetch('/node-api/csrf');
}, {
enabled: Boolean(cookies.get(cookies.NAMES.API_TOKEN)),
});
}
......@@ -12,6 +12,7 @@ import publicTagIcon from 'icons/publictags.svg';
import statsIcon from 'icons/stats.svg';
import tokensIcon from 'icons/token.svg';
import transactionsIcon from 'icons/transactions.svg';
import walletIcon from 'icons/wallet.svg';
import watchlistIcon from 'icons/watchlist.svg';
import link from 'lib/link/link';
import useCurrentRoute from 'lib/link/useCurrentRoute';
......@@ -27,6 +28,7 @@ export default function useNavItems() {
{ text: 'Blocks', url: link('blocks'), icon: blocksIcon, isActive: currentRoute.startsWith('block'), isNewUi: false },
{ text: 'Transactions', url: link('txs'), icon: transactionsIcon, isActive: currentRoute.startsWith('tx'), isNewUi: false },
{ text: 'Tokens', url: link('tokens'), icon: tokensIcon, isActive: currentRoute === 'tokens', isNewUi: false },
{ text: 'Accounts', url: link('accounts'), icon: walletIcon, isActive: currentRoute === 'accounts', isNewUi: false },
isMarketplaceFilled ?
{ text: 'Apps', url: link('apps'), icon: appsIcon, isActive: currentRoute === 'apps', isNewUi: true } : null,
{ text: 'Charts & stats', url: link('stats'), icon: statsIcon, isActive: currentRoute === 'stats', isNewUi: false },
......
......@@ -15,10 +15,12 @@
"token_instance_item": "/token/:hash/instance/:id",
"address_index": "/address/:id",
"address_contract_verification": "/address/:id/contract_verifications/new",
"accounts": "/accounts",
"apps": "/apps",
"app_index": "/apps/:id",
"search_results": "/search-results",
"auth": "/auth/auth0",
"stats": "/stats",
"visualize_sol2uml": "/visualize/sol2uml"
"visualize_sol2uml": "/visualize/sol2uml",
"csv_export": "/csv-export"
}
......@@ -77,6 +77,12 @@ export const ROUTES = {
crossNetworkNavigation: true,
},
// ACCOUNTS
accounts: {
pattern: PATHS.accounts,
crossNetworkNavigation: true,
},
// APPS
apps: {
pattern: PATHS.apps,
......@@ -99,6 +105,10 @@ export const ROUTES = {
pattern: PATHS.visualize_sol2uml,
},
csv_export: {
pattern: PATHS.csv_export,
},
// AUTH
auth: {
pattern: PATHS.auth,
......
......@@ -18,6 +18,7 @@ SocketMessage.AddressCoinBalance |
SocketMessage.AddressTxs |
SocketMessage.AddressTxsPending |
SocketMessage.AddressTokenTransfer |
SocketMessage.TokenTransfers |
SocketMessage.Unknown;
interface SocketMessageParamsGeneric<Event extends string | undefined, Payload extends object | unknown> {
......@@ -42,5 +43,6 @@ export namespace SocketMessage {
export type AddressTxs = SocketMessageParamsGeneric<'transaction', { transaction: Transaction }>;
export type AddressTxsPending = SocketMessageParamsGeneric<'pending_transaction', { transaction: Transaction }>;
export type AddressTokenTransfer = SocketMessageParamsGeneric<'token_transfer', { token_transfer: TokenTransfer }>;
export type TokenTransfers = SocketMessageParamsGeneric<'token_transfer', {token_transfer: number }>
export type Unknown = SocketMessageParamsGeneric<undefined, unknown>;
}
import type { TokenType } from 'types/api/tokenInfo';
const TOKEN_TYPE: Array<{ title: string; id: TokenType }> = [
{ title: 'ERC-20', id: 'ERC-20' },
{ title: 'ERC-721', id: 'ERC-721' },
{ title: 'ERC-1155', id: 'ERC-1155' },
];
export default TOKEN_TYPE;
import type { Address } from 'types/api/address';
import type { AddressParam } from 'types/api/addressParams';
import { publicTag, privateTag, watchlistName } from 'mocks/address/tag';
import { tokenInfo } from 'mocks/tokens/tokenInfo';
export const hash = '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859';
export const withName: AddressParam = {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
hash: hash,
implementation_name: null,
is_contract: true,
is_verified: null,
......@@ -15,7 +18,7 @@ export const withName: AddressParam = {
};
export const withoutName: AddressParam = {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
hash: hash,
implementation_name: null,
is_contract: true,
is_verified: null,
......@@ -25,8 +28,8 @@ export const withoutName: AddressParam = {
public_tags: [],
};
export const withToken: Address = {
hash: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859',
export const token: Address = {
hash: hash,
implementation_name: null,
is_contract: true,
is_verified: false,
......@@ -37,8 +40,8 @@ export const withToken: Address = {
token: tokenInfo,
block_number_balance_updated_at: 8201413,
coin_balance: '1',
creation_tx_hash: null,
creator_address_hash: null,
creation_tx_hash: '0xc38cf7377bf72d6436f63c37b01b24d032101f20ec1849286dc703c712f10c98',
creator_address_hash: '0x34A9c688512ebdB575e82C50c9803F6ba2916E72',
exchange_rate: null,
implementation_address: null,
has_custom_methods_read: false,
......@@ -49,7 +52,65 @@ export const withToken: Address = {
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_token_transfers: false,
has_token_transfers: true,
has_tokens: true,
has_validated_blocks: false,
};
export const contract: Address = {
block_number_balance_updated_at: 30811263,
coin_balance: '27826501896887194214322205',
creation_tx_hash: '0xf2aff6501b632604c39978b47d309813d8a1bcca721864bbe86abf59704f195e',
creator_address_hash: '0x803ad3F50b9e1fF68615e8B053A186e1be288943',
exchange_rate: '0.04311',
has_custom_methods_read: false,
has_custom_methods_write: false,
has_decompiled_code: false,
has_logs: true,
has_methods_read: true,
has_methods_read_proxy: true,
has_methods_write: true,
has_methods_write_proxy: true,
has_token_transfers: false,
has_tokens: false,
has_validated_blocks: false,
hash: hash,
implementation_address: '0x2F4F4A52295940C576417d29F22EEb92B440eC89',
implementation_name: 'HomeBridge',
is_contract: true,
is_verified: true,
name: 'EternalStorageProxy',
private_tags: [ publicTag ],
public_tags: [ privateTag ],
token: null,
watchlist_names: [ watchlistName ],
};
export const validator: Address = {
block_number_balance_updated_at: 30811932,
coin_balance: '22910462800601256910890',
creation_tx_hash: null,
creator_address_hash: null,
exchange_rate: '0.00432018',
has_custom_methods_read: false,
has_custom_methods_write: false,
has_decompiled_code: false,
has_logs: false,
has_methods_read: false,
has_methods_read_proxy: false,
has_methods_write: false,
has_methods_write_proxy: false,
has_token_transfers: false,
has_tokens: false,
has_validated_blocks: true,
hash: hash,
implementation_address: null,
implementation_name: null,
is_contract: false,
is_verified: false,
name: 'Kiryl Ihnatsyeu',
private_tags: [],
public_tags: [],
token: null,
watchlist_names: [],
};
import type { AddressCounters } from 'types/api/address';
export const forContract: AddressCounters = {
gas_usage_count: '319340525',
token_transfers_count: '0',
transactions_count: '5462',
validations_count: '0',
};
export const forToken: AddressCounters = {
gas_usage_count: '247479698',
token_transfers_count: '1',
transactions_count: '8474',
validations_count: '0',
};
export const forValidator: AddressCounters = {
gas_usage_count: '91675762951',
token_transfers_count: '0',
transactions_count: '820802',
validations_count: '1726416',
};
import type { AddressTokenBalance } from 'types/api/address';
import * as tokens from 'mocks/tokens/tokenInfo';
export const erc20a: AddressTokenBalance = {
token: {
address: '0xb2a90505dc6680a7a695f7975d0d32EeF610f456',
decimals: '18',
exchange_rate: null,
holders: '23',
name: 'hyfi.token',
symbol: 'HyFi',
total_supply: '369000000000000000000000000',
type: 'ERC-20',
},
token: tokens.tokenInfoERC20a,
token_id: null,
value: '1169320000000000000000000',
};
export const erc20b: AddressTokenBalance = {
token: {
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
decimals: '6',
exchange_rate: '0.982',
holders: '17',
name: 'USD Coin',
symbol: 'USDC',
total_supply: '900000000000000000000000000',
type: 'ERC-20',
},
token: tokens.tokenInfoERC20b,
token_id: null,
value: '872500000000',
};
export const erc20c: AddressTokenBalance = {
token: {
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
decimals: '18',
exchange_rate: '1328.89',
holders: '17',
name: 'Ethereum',
symbol: 'ETH',
total_supply: '1000000000000000000000000',
type: 'ERC-20',
},
token: tokens.tokenInfoERC20c,
token_id: null,
value: '9852000000000000000000',
};
export const erc20d: AddressTokenBalance = {
token: {
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9195',
decimals: '18',
exchange_rate: null,
holders: '102625',
name: 'Zeta',
symbol: 'ZETA',
total_supply: '2100000000000000000000000000',
type: 'ERC-20',
},
token: tokens.tokenInfoERC20d,
token_id: null,
value: '39000000000000000000',
};
export const erc721a: AddressTokenBalance = {
token: {
address: '0xDe7cAc71E072FCBd4453E5FB3558C2684d1F88A0',
decimals: null,
exchange_rate: null,
holders: '7',
name: 'HyFi Athena',
symbol: 'HYFI_ATHENA',
total_supply: '105',
type: 'ERC-721',
},
token: tokens.tokenInfoERC721a,
token_id: null,
value: '51',
};
export const erc721b: AddressTokenBalance = {
token: {
address: '0xA8d5C7beEA8C9bB57f5fBa35fB638BF45550b11F',
decimals: null,
exchange_rate: null,
holders: '2',
name: 'World Of Women Galaxy',
symbol: 'WOWG',
total_supply: null,
type: 'ERC-721',
},
token: tokens.tokenInfoERC721b,
token_id: null,
value: '1',
};
export const erc721c: AddressTokenBalance = {
token: {
address: '0x47646F1d7dc4Dd2Db5a41D092e2Cf966e27A4992',
decimals: null,
exchange_rate: null,
holders: '12',
name: 'Puma',
symbol: 'PUMA',
total_supply: null,
type: 'ERC-721',
},
token: tokens.tokenInfoERC721c,
token_id: null,
value: '5',
};
export const erc1155a: AddressTokenBalance = {
token: {
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
decimals: null,
exchange_rate: null,
holders: '22',
name: 'HyFi Membership',
symbol: 'HYFI_MEMBERSHIP',
total_supply: '482',
type: 'ERC-1155',
},
token: tokens.tokenInfoERC1155a,
token_id: '42',
value: '24',
};
export const erc1155b: AddressTokenBalance = {
token: {
address: '0xf4b71b179132ad457f6bcae2a55efa9e4b26eefc',
decimals: null,
exchange_rate: null,
holders: '100',
name: 'WinkyVerse Collections',
symbol: 'WVC',
total_supply: '4943',
type: 'ERC-1155',
},
token: tokens.tokenInfoERC1155b,
token_id: '100010000000001',
value: '11',
};
export const erc1155withoutName: AddressTokenBalance = {
token: {
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
decimals: null,
exchange_rate: null,
holders: '22',
name: null,
symbol: null,
total_supply: '482',
type: 'ERC-1155',
},
token: tokens.tokenInfoERC1155WithoutName,
token_id: '64532245',
value: '42',
};
......
/* eslint-disable max-len */
import type { SmartContract } from 'types/api/contract';
export const verified: Partial<SmartContract> = {
abi: [ { anonymous: false, inputs: [ { indexed: true, internalType: 'address', name: 'src', type: 'address' }, { indexed: true, internalType: 'address', name: 'guy', type: 'address' }, { indexed: false, internalType: 'uint256', name: 'wad', type: 'uint256' } ], name: 'Approval', type: 'event' } ],
can_be_visualized_via_sol2uml: true,
compiler_version: 'v0.5.16+commit.9c3226ce',
constructor_args: 'constructor_args',
creation_bytecode: 'creation_bytecode',
deployed_bytecode: 'deployed_bytecode',
compiler_settings: 'compiler_settings',
evm_version: 'default',
is_verified: true,
name: 'WPOA',
optimization_enabled: true,
optimization_runs: 1500,
source_code: 'source_code',
verified_at: '2021-08-03T10:40:41.679421Z',
decoded_constructor_args: [
[ '0xc59615da2da226613b1c78f0c6676cac497910bc', { internalType: 'address', name: '_token', type: 'address' } ],
[ '1800', { internalType: 'uint256', name: '_duration', type: 'uint256' } ],
[ '900000000', { internalType: 'uint256', name: '_totalSupply', type: 'uint256' } ],
],
external_libraries: [
{ address_hash: '0xa62744BeE8646e237441CDbfdedD3458861748A8', name: 'Sol' },
{ address_hash: '0xa62744BeE8646e237441CDbfdedD3458861748A8', name: 'math' },
],
};
export const withMultiplePaths: Partial<SmartContract> = {
...verified,
file_path: './simple_storage.sol',
additional_sources: [
{
file_path: 'contracts/1_Storage.sol',
source_code: '// SPDX-License-Identifier: GPL-3.0 \n pragma solidity >=0.7.0 <0.9.0; \n contract Storage {\n //2112313123; \nuint256 number; \n function store(uint256 num) public {\nnumber = num;\n}\n function retrieve() public view returns (uint256)\n {\nreturn number;\n}\n}',
},
],
};
export const verifiedViaSourcify: Partial<SmartContract> = {
...verified,
is_verified_via_sourcify: true,
is_fully_verified: false,
is_partially_verified: true,
sourcify_repo_url: 'https://repo.sourcify.dev/contracts//full_match/99/0x51891596E158b2857e5356DC847e2D15dFbCF2d0/',
};
export const withTwinAddress: Partial<SmartContract> = {
...verified,
is_verified: false,
verified_twin_address_hash: '0xa62744bee8646e237441cdbfdedd3458861748a8',
};
export const withProxyAddress: Partial<SmartContract> = {
...verified,
is_verified: false,
verified_twin_address_hash: '0xa62744bee8646e237441cdbfdedd3458861748a8',
minimal_proxy_address_hash: '0xa62744bee8646e237441cdbfdedd3458861748a8',
};
export const selfDestructed: Partial<SmartContract> = {
...verified,
is_self_destructed: true,
};
export const withChangedByteCode: Partial<SmartContract> = {
...verified,
is_changed_bytecode: true,
};
export const nonVerified: Partial<SmartContract> = {
is_verified: false,
creation_bytecode: 'creation_bytecode',
deployed_bytecode: 'deployed_bytecode',
};
import type {
SmartContractQueryMethodReadError,
SmartContractQueryMethodReadSuccess,
SmartContractReadMethod,
SmartContractWriteMethod,
} from 'types/api/contract';
export const read: Array<SmartContractReadMethod> = [
{
constant: true,
inputs: [
{ internalType: 'address', name: '', type: 'address' },
],
method_id: '70a08231',
name: 'balanceOf',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256', value: '' },
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
inputs: [],
method_id: '06fdde03',
name: 'name',
outputs: [
{ internalType: 'string', name: '', type: 'string', value: 'Wrapped POA' },
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
inputs: [],
method_id: '18160ddd',
name: 'totalSupply',
outputs: [
{ internalType: 'uint256', name: '', type: 'uint256', value: '139905710421584994690047413' },
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
error: '(-32015) VM execution error. (revert)',
inputs: [],
method_id: 'df0ad3de',
name: 'upgradeabilityAdmin',
outputs: [
{ name: '', type: 'address', value: '' },
],
payable: false,
stateMutability: 'view',
type: 'function',
},
{
constant: true,
inputs: [],
method_id: '165ec2e2',
name: 'arianeeWhitelist',
outputs: [
{
name: '',
type: 'address',
value: '0xd3eee7f8e8021db24825c3457d5479f2b57f40ef',
},
],
payable: false,
stateMutability: 'view',
type: 'function',
},
];
export const readResultSuccess: SmartContractQueryMethodReadSuccess = {
is_error: false,
result: {
names: [ 'uint256' ],
output: [
{ type: 'uint256', value: '42' },
],
},
};
export const readResultError: SmartContractQueryMethodReadError = {
is_error: true,
result: {
message: 'Some shit happened',
code: -32017,
},
};
export const write: Array<SmartContractWriteMethod> = [
{
payable: true,
stateMutability: 'payable',
type: 'fallback',
},
{
constant: false,
inputs: [
{ internalType: 'address', name: 'guy', type: 'address' },
{ internalType: 'uint256', name: 'wad', type: 'uint256' },
],
name: 'approve',
outputs: [
{ internalType: 'bool', name: '', type: 'bool' },
],
payable: false,
stateMutability: 'nonpayable',
type: 'function',
},
{
constant: false,
inputs: [
{ internalType: 'address', name: 'src', type: 'address' },
{ internalType: 'address', name: 'dst', type: 'address' },
],
name: 'transferFrom',
outputs: [
{ internalType: 'bool', name: '', type: 'bool' },
],
payable: true,
stateMutability: 'payable',
type: 'function',
},
{
stateMutability: 'payable',
type: 'receive',
},
{
constant: false,
inputs: [],
name: 'pause',
outputs: [],
payable: false,
stateMutability: 'nonpayable',
type: 'function',
},
{
constant: false,
inputs: [
{ name: '_from', type: 'address' },
{ name: '_to', type: 'address' },
{ name: '_tokenId', type: 'uint256' },
{ name: '_data', type: 'bytes' },
],
name: 'safeTransferFrom',
outputs: [],
payable: false,
stateMutability: 'nonpayable',
type: 'function',
},
{
constant: false,
inputs: [
{ name: '_tokenId', type: 'uint256' },
{ name: '_hash', type: 'bytes32' },
{ name: '_keepRequestToken', type: 'bool' },
{ name: '_newOwner', type: 'address' },
{ name: '_signature', type: 'bytes' },
],
name: 'requestToken',
outputs: [
{ name: 'reward', type: 'uint256' },
],
payable: false,
stateMutability: 'nonpayable',
type: 'function',
},
{
constant: false,
inputs: [
{ name: '_tokenId', type: 'uint256' },
{ name: '_imprint', type: 'bytes32' },
{ name: '_uri', type: 'string' },
{ name: '_initialKey', type: 'address' },
{ name: '_tokenRecoveryTimestamp', type: 'uint256' },
{ name: '_initialKeyIsRequestKey', type: 'bool' },
],
name: 'hydrateToken',
outputs: [
{ name: '', type: 'uint256' },
],
payable: false,
stateMutability: 'nonpayable',
type: 'function',
},
];
......@@ -15,3 +15,113 @@ export const tokenCounters: TokenCounters = {
token_holders_count: '8838883',
transfers_count: '88282281',
};
export const tokenInfoERC20a: TokenInfo = {
address: '0xb2a90505dc6680a7a695f7975d0d32EeF610f456',
decimals: '18',
exchange_rate: null,
holders: '23',
name: 'hyfi.token',
symbol: 'HyFi',
total_supply: '369000000000000000000000000',
type: 'ERC-20',
};
export const tokenInfoERC20b: TokenInfo = {
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
decimals: '6',
exchange_rate: '0.982',
holders: '17',
name: 'USD Coin',
symbol: 'USDC',
total_supply: '900000000000000000000000000',
type: 'ERC-20',
};
export const tokenInfoERC20c: TokenInfo = {
address: '0xc1116c98ba622a6218433fF90a2E40DEa482d7A7',
decimals: '18',
exchange_rate: '1328.89',
holders: '17',
name: 'Ethereum',
symbol: 'ETH',
total_supply: '1000000000000000000000000',
type: 'ERC-20',
};
export const tokenInfoERC20d: TokenInfo = {
address: '0xCc7bb2D219A0FC08033E130629C2B854b7bA9195',
decimals: '18',
exchange_rate: null,
holders: '102625',
name: 'Zeta',
symbol: 'ZETA',
total_supply: '2100000000000000000000000000',
type: 'ERC-20',
};
export const tokenInfoERC721a: TokenInfo = {
address: '0xDe7cAc71E072FCBd4453E5FB3558C2684d1F88A0',
decimals: null,
exchange_rate: null,
holders: '7',
name: 'HyFi Athena',
symbol: 'HYFI_ATHENA',
total_supply: '105',
type: 'ERC-721',
};
export const tokenInfoERC721b: TokenInfo = {
address: '0xA8d5C7beEA8C9bB57f5fBa35fB638BF45550b11F',
decimals: null,
exchange_rate: null,
holders: '2',
name: 'World Of Women Galaxy',
symbol: 'WOWG',
total_supply: null,
type: 'ERC-721',
};
export const tokenInfoERC721c: TokenInfo = {
address: '0x47646F1d7dc4Dd2Db5a41D092e2Cf966e27A4992',
decimals: null,
exchange_rate: null,
holders: '12',
name: 'Puma',
symbol: 'PUMA',
total_supply: null,
type: 'ERC-721',
};
export const tokenInfoERC1155a: TokenInfo = {
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
decimals: null,
exchange_rate: null,
holders: '22',
name: 'HyFi Membership',
symbol: 'HYFI_MEMBERSHIP',
total_supply: '482',
type: 'ERC-1155',
};
export const tokenInfoERC1155b: TokenInfo = {
address: '0xf4b71b179132ad457f6bcae2a55efa9e4b26eefc',
decimals: null,
exchange_rate: null,
holders: '100',
name: 'WinkyVerse Collections',
symbol: 'WVC',
total_supply: '4943',
type: 'ERC-1155',
};
export const tokenInfoERC1155WithoutName: TokenInfo = {
address: '0x4b333DEd10c7ca855EA2C8D4D90A0a8b73788c8e',
decimals: null,
exchange_rate: null,
holders: '22',
name: null,
symbol: null,
total_supply: '482',
type: 'ERC-1155',
};
......@@ -40,6 +40,7 @@ export const erc20: TokenTransfer = {
timestamp: '2022-10-10T14:34:30.000000Z',
block_hash: '1',
log_index: '1',
method: 'updateSmartAsset',
};
export const erc721: TokenTransfer = {
......@@ -81,6 +82,7 @@ export const erc721: TokenTransfer = {
timestamp: '2022-10-10T14:34:30.000000Z',
block_hash: '1',
log_index: '1',
method: 'updateSmartAsset',
};
export const erc1155: TokenTransfer = {
......
......@@ -21,7 +21,7 @@
"lint:tsc": "./node_modules/.bin/tsc -p ./tsconfig.json",
"prepare": "husky install",
"format-svg": "./node_modules/.bin/svgo -r ./icons",
"test:pw": "./playwright/make-envs-script.sh && playwright test -c playwright-ct.config.ts",
"test:pw": "./playwright/make-envs-script.sh && NODE_OPTIONS=\"--max-old-space-size=4096\" playwright test -c playwright-ct.config.ts",
"test:pw:local": "export NODE_PATH=$(pwd)/node_modules && yarn test:pw",
"test:pw:docker": "docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.28.0-focal ./playwright/run-tests.sh",
"test:jest": "jest",
......@@ -40,6 +40,8 @@
"@tanstack/react-query-devtools": "^4.0.10",
"@types/papaparse": "^5.3.5",
"@types/react-scroll": "^1.8.4",
"@web3modal/ethereum": "^2.0.0-rc.2",
"@web3modal/react": "^2.0.0-rc.2",
"ace-builds": "^1.14.0",
"bignumber.js": "^9.1.0",
"d3": "^7.6.1",
......@@ -64,7 +66,8 @@
"react-identicons": "^1.2.5",
"react-jazzicon": "^1.0.4",
"react-scroll": "^1.8.7",
"use-font-face-observer": "^1.2.1"
"use-font-face-observer": "^1.2.1",
"wagmi": "^0.10.6"
},
"devDependencies": {
"@playwright/experimental-ct-react": "^1.28.0",
......
......@@ -50,7 +50,7 @@ const CustomErrorComponent = (props: Props) => {
);
}
const colorModeCookie = cookies.getFromCookieString(props.cookies, cookies.NAMES.COLOR_MODE);
const colorModeCookie = cookies.getFromCookieString(props.cookies || '', cookies.NAMES.COLOR_MODE);
return <NextErrorComponent statusCode={ props.statusCode } withDarkMode={ colorModeCookie === 'dark' }/>;
};
......
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import Accounts from 'ui/pages/Accounts';
const AccountsPage: NextPage = () => {
const title = `Top Accounts - ${ getNetworkTitle() }`;
return (
<>
<Head><title>{ title }</title></Head>
<Accounts/>
</>
);
};
export default AccountsPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import getNetworkTitle from 'lib/networks/getNetworkTitle';
import Tokens from 'ui/pages/Tokens';
const TokensPage: NextPage = () => {
const title = getNetworkTitle();
return (
<>
<Head>
<title>{ title }</title>
</Head>
<Tokens/>
</>
);
};
export default TokensPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
......@@ -51,6 +51,12 @@ const config: PlaywrightTestConfig = {
exportAsDefault: true,
}),
],
build: {
// it actually frees some memory that vite needs a lot
// https://github.com/storybookjs/builder-vite/issues/409#issuecomment-1152848986
sourcemap: false,
minify: false,
},
},
},
......
import { ChakraProvider } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { providers } from 'ethers';
import React from 'react';
import { createClient, WagmiConfig } from 'wagmi';
import { mainnet } from 'wagmi/chains';
import { MockConnector } from 'wagmi/connectors/mock';
import { AppContextProvider } from 'lib/appContext';
import type { Props as PageProps } from 'lib/next/getServerSideProps';
......@@ -23,6 +27,29 @@ const defaultAppContext = {
},
};
// >>> Web3 stuff
const provider = new providers.JsonRpcProvider(
'http://localhost:8545',
{
name: 'POA',
chainId: 99,
},
);
const connector = new MockConnector({
chains: [ mainnet ],
options: {
signer: provider.getSigner(),
},
});
const wagmiClient = createClient({
autoConnect: true,
connectors: [ connector ],
provider,
});
// <<<<
const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props) => {
const [ queryClient ] = React.useState(() => new QueryClient({
defaultOptions: {
......@@ -38,7 +65,9 @@ const TestApp = ({ children, withSocket, appContext = defaultAppContext }: Props
<QueryClientProvider client={ queryClient }>
<SocketProvider url={ withSocket ? `ws://localhost:${ PORT }` : undefined }>
<AppContextProvider { ...appContext }>
{ children }
<WagmiConfig client={ wagmiClient }>
{ children }
</WagmiConfig>
</AppContextProvider>
</SocketProvider>
</QueryClientProvider>
......
......@@ -47,7 +47,7 @@ const variantOutline = defineStyle((props) => {
const isGrayTheme = c === 'gray' || c === 'gray-dark';
const color = isGrayTheme ? mode('blackAlpha.800', 'whiteAlpha.800')(props) : mode(`${ c }.600`, `${ c }.300`)(props);
const borderColor = isGrayTheme ? mode('gray.300', 'gray.600')(props) : mode(`${ c }.600`, `${ c }.300`)(props);
const borderColor = isGrayTheme ? mode('gray.200', 'gray.600')(props) : mode(`${ c }.600`, `${ c }.300`)(props);
const activeBg = isGrayTheme ? mode('blue.50', 'gray.600')(props) : mode(`${ c }.50`, 'gray.600')(props);
const activeColor = (() => {
if (c === 'gray') {
......@@ -70,15 +70,15 @@ const variantOutline = defineStyle((props) => {
borderColor,
bg: 'transparent',
_hover: {
color: 'blue.400',
borderColor: 'blue.400',
color: 'link_hovered',
borderColor: 'link_hovered',
bg: 'transparent',
_active: {
bg: props.isActive ? activeBg : 'transparent',
borderColor: props.isActive ? activeBg : 'blue.400',
color: props.isActive ? activeColor : 'blue.400',
borderColor: props.isActive ? activeBg : 'link_hovered',
color: props.isActive ? activeColor : 'link_hovered',
p: {
color: 'blue.400',
color: 'link_hovered',
},
},
_disabled: {
......@@ -86,7 +86,7 @@ const variantOutline = defineStyle((props) => {
borderColor,
},
p: {
color: 'blue.400',
color: 'link_hovered',
},
},
_disabled: {
......@@ -147,7 +147,7 @@ const variantSubtle = defineStyle((props) => {
bg: mode('blackAlpha.200', 'whiteAlpha.200')(props),
color: mode('blackAlpha.800', 'whiteAlpha.800')(props),
_hover: {
color: 'blue.400',
color: 'link_hovered',
_disabled: {
color: mode('blackAlpha.800', 'whiteAlpha.800')(props),
bg: mode('blackAlpha.200', 'whiteAlpha.200')(props),
......@@ -160,7 +160,7 @@ const variantSubtle = defineStyle((props) => {
bg: `${ c }.100`,
color: `${ c }.600`,
_hover: {
color: 'blue.400',
color: 'link_hovered',
},
};
});
......
......@@ -8,9 +8,9 @@ const baseStyle = defineStyle(getDefaultTransitionProps());
const variantPrimary = defineStyle((props) => {
return {
color: mode('blue.600', 'blue.300')(props),
color: 'link',
_hover: {
color: mode('blue.400', 'blue.200')(props),
color: 'link_hovered',
textDecorationStyle: props.textDecorationStyle || 'solid',
},
};
......
import { menuAnatomy as parts } from '@chakra-ui/anatomy';
import {
createMultiStyleConfigHelpers,
cssVar,
defineStyle,
} from '@chakra-ui/styled-system';
const { defineMultiStyleConfig, definePartsStyle } =
createMultiStyleConfigHelpers(parts.keys);
const $bg = cssVar('menu-bg');
const $shadow = cssVar('menu-shadow');
const baseStyleList = defineStyle({
[$bg.variable]: '#fff',
[$shadow.variable]: 'shadows.2xl',
_dark: {
[$bg.variable]: 'colors.gray.900',
[$shadow.variable]: 'shadows.dark-lg',
},
borderWidth: '0',
bg: $bg.reference,
boxShadow: $shadow.reference,
});
const baseStyleItem = defineStyle({
_focus: {
[$bg.variable]: 'colors.blue.50',
_dark: {
[$bg.variable]: 'colors.gray.800',
},
},
_hover: {
[$bg.variable]: 'colors.blue.50',
_dark: {
[$bg.variable]: 'colors.gray.800',
},
},
bg: $bg.reference,
});
const baseStyle = definePartsStyle({
list: baseStyleList,
item: baseStyleItem,
});
const Menu = defineMultiStyleConfig({
baseStyle,
});
export default Menu;
......@@ -51,7 +51,7 @@ const baseStyleCloseButton = defineStyle((props) => {
height: 10,
width: 10,
color: mode('gray.700', 'gray.500')(props),
_hover: { color: 'blue.400' },
_hover: { color: 'link_hovered' },
_active: { bg: 'none' },
};
});
......
......@@ -29,6 +29,7 @@ const baseStyleContent = defineStyle((props) => {
_dark: {
[$popperBg.variable]: `colors.gray.900`,
[$arrowShadowColor.variable]: `colors.whiteAlpha.300`,
boxShadow: 'dark-lg',
},
width: 'xs',
border: 'none',
......
import { selectAnatomy as parts } from '@chakra-ui/anatomy';
import {
createMultiStyleConfigHelpers,
} from '@chakra-ui/styled-system';
import { mode } from '@chakra-ui/theme-tools';
import Input from './Input';
const { defineMultiStyleConfig, definePartsStyle } =
createMultiStyleConfigHelpers(parts.keys);
const variantOutline = definePartsStyle((props) => {
return {
field: {
...Input.variants?.outline(props).field,
borderColor: mode('gray.200', 'gray.600')(props),
_hover: {
borderColor: mode('gray.300', 'gray.500')(props),
},
_focusVisible: {
borderColor: mode('gray.200', 'gray.600')(props),
boxShadow: 'none',
},
cursor: 'pointer',
},
};
});
const Select = defineMultiStyleConfig({
variants: {
...Input.variants,
outline: variantOutline,
},
});
export default Select;
......@@ -23,7 +23,7 @@ const variantSimple = definePartsStyle((props) => {
...transitionProps,
},
td: {
borderColor: mode('blackAlpha.200', 'whiteAlpha.200')(props),
borderColor: 'divider',
...transitionProps,
},
};
......
......@@ -22,7 +22,7 @@ const variantSoftRounded = definePartsStyle((props) => {
},
},
_hover: {
color: 'blue.400',
color: 'link_hovered',
},
},
};
......
......@@ -10,10 +10,6 @@ const variantSecondary = defineStyle((props) => ({
color: mode('gray.500', 'gray.400')(props),
}));
const variantAlternate = defineStyle((props) => ({
color: mode('blue.600', 'blue.300')(props),
}));
const variantInherit = {
color: 'inherit',
};
......@@ -21,7 +17,6 @@ const variantInherit = {
const variants: Record<string, SystemStyleInterpolation> = {
primary: variantPrimary,
secondary: variantSecondary,
alternate: variantAlternate,
inherit: variantInherit,
};
......
......@@ -8,9 +8,11 @@ import FormLabel from './FormLabel';
import Heading from './Heading';
import Input from './Input';
import Link from './Link';
import Menu from './Menu';
import Modal from './Modal';
import Popover from './Popover';
import Radio from './Radio';
import Select from './Select';
import Skeleton from './Skeleton';
import Spinner from './Spinner';
import Switch from './Switch';
......@@ -32,9 +34,11 @@ const components = {
Form,
FormLabel,
Link,
Menu,
Modal,
Popover,
Radio,
Select,
Skeleton,
Spinner,
Switch,
......
const semanticTokens = {
colors: {
divider: {
'default': 'blackAlpha.200',
_dark: 'whiteAlpha.200',
},
link: {
'default': 'blue.600',
_dark: 'blue.300',
},
link_hovered: {
'default': 'blue.400',
},
},
};
export default semanticTokens;
......@@ -5,6 +5,7 @@ import config from './config';
import borders from './foundations/borders';
import breakpoints from './foundations/breakpoints';
import colors from './foundations/colors';
import semanticTokens from './foundations/semanticTokens';
import transition from './foundations/transition';
import typography from './foundations/typography';
import zIndices from './foundations/zIndices';
......@@ -22,6 +23,7 @@ const overrides = {
breakpoints,
transition,
zIndices,
semanticTokens,
};
export default extendTheme(overrides);
......@@ -30,6 +30,10 @@ export default function getOutlinedFieldStyles(props: StyleFunctionProps) {
_hover: {
borderColor: 'transparent',
},
':-webkit-autofill': {
// background color for disabled input which value was selected from browser autocomplete popup
'-webkit-box-shadow': `0 0 0px 1000px ${ mode('rgba(16, 17, 18, 0.08)', 'rgba(255, 255, 255, 0.08)')(props) } inset`,
},
},
_invalid: {
borderColor: getColor(theme, errorColor),
......
......@@ -81,8 +81,9 @@ export interface AddressTokenTransferResponse {
}
export type AddressTokenTransferFilters = {
filter: AddressFromToFilter;
type: Array<TokenType>;
filter?: AddressFromToFilter;
type?: Array<TokenType>;
token?: string;
}
export type AddressTokensFilter = {
......
import type { AddressParam } from './addressParams';
export type AddressesItem = AddressParam &{ tx_count: string; coin_balance: string }
export type AddressesResponse = {
items: Array<AddressesItem>;
next_page_params: {
fetched_coin_balance: string;
hash: string;
items_count: number;
};
total_supply: string;
}
import type { Abi } from 'abitype';
export type SmartContractMethodArgType = 'address' | 'uint256' | 'bool' | 'string' | 'bytes' | 'bytes32';
export type SmartContractMethodStateMutability = 'view' | 'nonpayable' | 'payable';
export interface SmartContract {
deployed_bytecode: string | null;
creation_bytecode: string | null;
is_self_destructed: boolean;
abi: Array<Record<string, unknown>> | null;
abi: Abi | null;
compiler_version: string | null;
evm_version: string | null;
optimization_enabled: boolean | null;
......@@ -10,8 +15,38 @@ export interface SmartContract {
name: string | null;
verified_at: string | null;
is_verified: boolean | null;
is_changed_bytecode: boolean | null;
// sourcify info >>>
is_verified_via_sourcify: boolean | null;
is_fully_verified: boolean | null;
is_partially_verified: boolean | null;
sourcify_repo_url: string | null;
// <<<<
source_code: string | null;
constructor_args: string | null;
decoded_constructor_args: Array<SmartContractDecodedConstructorArg> | null;
can_be_visualized_via_sol2uml: boolean | null;
is_vyper_contract: boolean | null;
file_path: string;
additional_sources: Array<{ file_path: string; source_code: string }>;
external_libraries: Array<SmartContractExternalLibrary> | null;
compiler_settings: unknown;
verified_twin_address_hash: string | null;
minimal_proxy_address_hash: string | null;
}
export type SmartContractDecodedConstructorArg = [
string,
{
internalType: SmartContractMethodArgType;
name: string;
type: SmartContractMethodArgType;
}
]
export interface SmartContractExternalLibrary {
address_hash: string;
name: string;
}
export interface SmartContractMethodBase {
......@@ -19,9 +54,10 @@ export interface SmartContractMethodBase {
outputs: Array<SmartContractMethodOutput>;
constant: boolean;
name: string;
stateMutability: string;
stateMutability: SmartContractMethodStateMutability;
type: 'function';
payable: boolean;
error?: string;
}
export interface SmartContractReadMethod extends SmartContractMethodBase {
......@@ -29,21 +65,51 @@ export interface SmartContractReadMethod extends SmartContractMethodBase {
}
export interface SmartContractWriteFallback {
payable: true;
payable?: true;
stateMutability: 'payable';
type: 'fallback';
}
export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWriteFallback;
export interface SmartContractWriteReceive {
payable?: true;
stateMutability: 'payable';
type: 'receive';
}
export type SmartContractWriteMethod = SmartContractMethodBase | SmartContractWriteFallback | SmartContractWriteReceive;
export type SmartContractMethod = SmartContractReadMethod | SmartContractWriteMethod;
export interface SmartContractMethodInput {
internalType: string;
internalType?: SmartContractMethodArgType;
name: string;
type: string;
type: SmartContractMethodArgType;
}
export interface SmartContractMethodOutput extends SmartContractMethodInput {
value?: string;
}
export interface SmartContractQueryMethodReadSuccess {
is_error: false;
result: {
names: Array<string>;
output: Array<{
type: string;
value: string;
}>;
};
}
export interface SmartContractQueryMethodReadError {
is_error: true;
result: {
code: number;
message: string;
raw?: string;
} | {
error: string;
};
}
export type SmartContractQueryMethodRead = SmartContractQueryMethodReadSuccess | SmartContractQueryMethodReadError;
......@@ -39,6 +39,7 @@ interface TokenTransferBase {
timestamp: string;
block_hash: string;
log_index: string;
method?: string;
}
export type TokenTransferPagination = {
......
import type { TokenInfo, TokenType } from './tokenInfo';
export type TokensResponse = {
items: Array<TokenInfo>;
next_page_params: {
holder_count: number;
items_count: number;
name: string;
};
}
export type TokensFilters = { filter: string; type: Array<TokenType> | undefined };
import { useColorModeValue, useToken } from '@chakra-ui/react';
import {
EthereumClient,
modalConnectors,
walletConnectProvider,
} from '@web3modal/ethereum';
import { Web3Modal } from '@web3modal/react';
import React from 'react';
import type { Chain } from 'wagmi';
import { configureChains, createClient, WagmiConfig } from 'wagmi';
import type { RoutedSubTab } from 'ui/shared/RoutedTabs/types';
import appConfig from 'configs/app/config';
import { ContractContextProvider } from 'ui/address/contract/context';
import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
interface Props {
tabs: Array<RoutedSubTab>;
}
export const currentChain: Chain = {
id: Number(appConfig.network.id),
name: appConfig.network.name || '',
network: appConfig.network.name || '',
nativeCurrency: {
decimals: appConfig.network.currency.decimals,
name: appConfig.network.currency.name || '',
symbol: appConfig.network.currency.symbol || '',
},
rpcUrls: {
'default': {
http: [ appConfig.network.rpcUrl || '' ],
},
},
blockExplorers: {
'default': {
name: 'Blockscout',
url: appConfig.baseUrl,
},
},
};
const chains = [ currentChain ];
const { provider } = configureChains(chains, [
walletConnectProvider({ projectId: appConfig.walletConnect.projectId || '' }),
]);
const wagmiClient = createClient({
autoConnect: true,
connectors: modalConnectors({ appName: 'web3Modal', chains }),
provider,
});
const ethereumClient = new EthereumClient(wagmiClient, chains);
const TAB_LIST_PROPS = {
columnGap: 3,
};
const AddressContract = ({ tabs }: Props) => {
return <RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={{ columnGap: 3 }}/>;
const modalZIndex = useToken<string>('zIndices', 'modal');
return (
<WagmiConfig client={ wagmiClient }>
<ContractContextProvider>
<RoutedTabs tabs={ tabs } variant="outline" colorScheme="gray" size="sm" tabListProps={ TAB_LIST_PROPS }/>
</ContractContextProvider>
<Web3Modal
projectId={ appConfig.walletConnect.projectId }
ethereumClient={ ethereumClient }
themeZIndex={ Number(modalZIndex) }
themeMode={ useColorModeValue('light', 'dark') }
themeBackground="themeColor"
/>
</WagmiConfig>
);
};
export default React.memo(AddressContract);
import { chakra, Icon, Link, Tooltip } from '@chakra-ui/react';
import React from 'react';
import svgFileIcon from 'icons/files/csv.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import link from 'lib/link/link';
interface Props {
address: string;
type: 'transactions' | 'internal-transactions' | 'token-transfers';
className?: string;
}
const AddressCsvExportLink = ({ className, address, type }: Props) => {
const isMobile = useIsMobile();
return (
<Tooltip isDisabled={ !isMobile } label="Download CSV">
<Link
className={ className }
display="inline-flex"
alignItems="center"
href={ link('csv_export', undefined, { type, address }) }
>
<Icon as={ svgFileIcon } boxSize={{ base: '30px', lg: 6 }}/>
{ !isMobile && <chakra.span ml={ 1 }>Download CSV</chakra.span> }
</Link>
</Tooltip>
);
};
export default React.memo(chakra(AddressCsvExportLink));
import type { MetaMaskInpageProvider } from '@metamask/providers';
import { test, expect } from '@playwright/experimental-ct-react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
import type { Address } from 'types/api/address';
import * as addressMock from 'mocks/address/address';
import * as countersMock from 'mocks/address/counters';
import * as tokenBalanceMock from 'mocks/address/tokenBalance';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressDetails from './AddressDetails';
import MockAddressPage from './testUtils/MockAddressPage';
const ADDRESS_HASH = addressMock.hash;
const API_URL_ADDRESS = buildApiUrl('address', { id: ADDRESS_HASH });
const API_URL_COUNTERS = buildApiUrl('address_counters', { id: ADDRESS_HASH });
const API_URL_TOKEN_BALANCES = buildApiUrl('address_token_balances', { id: ADDRESS_HASH });
const hooksConfig = {
router: {
query: { id: ADDRESS_HASH },
},
};
test('contract +@mobile', async({ mount, page }) => {
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.contract),
}));
await page.route(API_URL_COUNTERS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(countersMock.forContract),
}));
const component = await mount(
<TestApp>
<AddressDetails addressQuery={{ data: addressMock.contract } as UseQueryResult<Address, unknown>}/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('token', async({ mount, page }) => {
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.token),
}));
await page.route(API_URL_COUNTERS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(countersMock.forToken),
}));
await page.route(API_URL_TOKEN_BALANCES, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokenBalanceMock.baseList),
}));
await page.evaluate(() => {
window.ethereum = { } as MetaMaskInpageProvider;
});
const component = await mount(
<TestApp>
<MockAddressPage>
<AddressDetails addressQuery={{ data: addressMock.token } as UseQueryResult<Address, unknown>}/>
</MockAddressPage>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('validator +@mobile', async({ mount, page }) => {
await page.route(API_URL_ADDRESS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addressMock.validator),
}));
await page.route(API_URL_COUNTERS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(countersMock.forValidator),
}));
const component = await mount(
<TestApp>
<AddressDetails addressQuery={{ data: addressMock.validator } as UseQueryResult<Address, unknown>}/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
......@@ -18,6 +18,7 @@ import DataFetchAlert from 'ui/shared/DataFetchAlert';
import DetailsInfoItem from 'ui/shared/DetailsInfoItem';
import ExternalLink from 'ui/shared/ExternalLink';
import HashStringShorten from 'ui/shared/HashStringShorten';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
import AddressAddToMetaMask from './details/AddressAddToMetaMask';
import AddressBalance from './details/AddressBalance';
......@@ -99,11 +100,23 @@ const AddressDetails = ({ addressQuery, scrollRef }: Props) => {
title="Creator"
hint="Transaction and address of creation."
>
<AddressLink hash={ addressQuery.data.creator_address_hash } truncation="constant"/>
<Text whiteSpace="pre"> at </Text>
<AddressLink type="address" hash={ addressQuery.data.creator_address_hash } truncation="constant"/>
<Text whiteSpace="pre"> at txn </Text>
<AddressLink hash={ addressQuery.data.creation_tx_hash } type="transaction" truncation="constant"/>
</DetailsInfoItem>
) }
{ addressQuery.data.is_contract && addressQuery.data.implementation_address && (
<DetailsInfoItem
title="Implementation"
hint="Implementation address of the proxy contract."
columnGap={ 1 }
>
<Link href={ link('address_index', { id: addressQuery.data.implementation_address }) }>{ addressQuery.data.implementation_name }</Link>
<Text variant="secondary" overflow="hidden">
<HashStringShortenDynamic hash={ `(${ addressQuery.data.implementation_address })` }/>
</Text>
</DetailsInfoItem>
) }
<AddressBalance data={ addressQuery.data }/>
{ addressQuery.data.has_tokens && (
<DetailsInfoItem
......
......@@ -17,6 +17,7 @@ import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTxsFilter from './AddressTxsFilter';
import AddressIntTxsList from './internals/AddressIntTxsList';
......@@ -80,13 +81,14 @@ const AddressInternalTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivE
return (
<>
<ActionBar mt={ -6 }>
<ActionBar mt={ -6 } justifyContent="left">
<AddressTxsFilter
defaultFilter={ filterValue }
onFilterChange={ handleFilterChange }
isActive={ Boolean(filterValue) }
/>
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
<AddressCsvExportLink address={ queryIdStr } type="internal-transactions" ml={{ base: 2, lg: 'auto' }}/>
{ isPaginationVisible && <Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/> }
</ActionBar>
{ content }
</>
......
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { erc1155 } from 'mocks/tokens/tokenTransfer';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import AddressTokenTransfers from './AddressTokenTransfers';
const API_URL = buildApiUrl('address_token_transfers', { id: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859' }) +
'?token=0x1189a607CEac2f0E14867de4EB15b15C9FFB5859';
const hooksConfig = {
router: {
query: { id: '0xd789a607CEac2f0E14867de4EB15b15C9FFB5859', token: '0x1189a607CEac2f0E14867de4EB15b15C9FFB5859' },
},
};
test('with token filter and pagination +@mobile', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ items: [ erc1155 ], next_page_params: { block_number: 1 } }),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokenTransfers/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('with token filter and no pagination +@mobile', async({ mount, page }) => {
await page.route(API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({ items: [ erc1155 ] }),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<AddressTokenTransfers/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Hide, Show, Text } from '@chakra-ui/react';
import { Flex, Hide, Icon, Show, Text, Tooltip, useColorModeValue } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -9,13 +9,16 @@ import type { AddressFromToFilter, AddressTokenTransferResponse } from 'types/ap
import type { TokenType } from 'types/api/tokenInfo';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import crossIcon from 'icons/cross.svg';
import { getResourceKey } from 'lib/api/useApiQuery';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useIsMobile from 'lib/hooks/useIsMobile';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import TOKEN_TYPE from 'lib/token/tokenTypes';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
......@@ -23,11 +26,14 @@ import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import { TOKEN_TYPE, flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenLogo from 'ui/shared/TokenLogo';
import { flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
import AddressCsvExportLink from './AddressCsvExportLink';
type Filters = {
type: Array<TokenType>;
filter: AddressFromToFilter | undefined;
......@@ -61,20 +67,26 @@ const matchFilters = (filters: Filters, tokenTransfer: TokenTransfer, address?:
const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}) => {
const router = useRouter();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const currentAddress = router.query.id?.toString();
const [ socketAlert, setSocketAlert ] = React.useState('');
const [ newItemsCount, setNewItemsCount ] = React.useState(0);
const tokenFilter = router.query.token ? router.query.token.toString() : undefined;
const [ filters, setFilters ] = React.useState<Filters>(
{ type: getTokenFilterValue(router.query.type) || [], filter: getAddressFilterValue(router.query.filter) },
{
type: getTokenFilterValue(router.query.type) || [],
filter: getAddressFilterValue(router.query.filter),
},
);
const { isError, isLoading, data, pagination, onFilterChange, isPaginationVisible } = useQueryWithPages({
resourceName: 'address_token_transfers',
pathParams: { id: currentAddress },
filters: filters,
filters: tokenFilter ? { token: tokenFilter } : filters,
scrollRef,
});
......@@ -89,6 +101,13 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
setFilters((prevState) => ({ ...prevState, filter: filterVal }));
}, [ filters, onFilterChange ]);
const resetTokenFilter = React.useCallback(() => {
onFilterChange({});
}, [ onFilterChange ]);
const resetTokenIconColor = useColorModeValue('blue.600', 'blue.300');
const resetTokenIconHoverColor = useColorModeValue('blue.400', 'blue.200');
const handleNewSocketMessage: SocketMessage.AddressTokenTransfer['handler'] = (payload) => {
setSocketAlert('');
......@@ -131,7 +150,7 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
topic: `addresses:${ (router.query.id as string).toLowerCase() }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: pagination.page !== 1,
isDisabled: pagination.page !== 1 || Boolean(tokenFilter),
});
useSocketMessage({
......@@ -141,7 +160,7 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
});
const numActiveFilters = (filters.type?.length || 0) + (filters.filter ? 1 : 0);
const isActionBarHidden = !numActiveFilters && !data?.items.length;
const isActionBarHidden = !tokenFilter && !numActiveFilters && !data?.items.length;
const content = (() => {
if (isLoading) {
......@@ -179,13 +198,13 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
showTxInfo
top={ 80 }
enableTimeIncrement
showSocketInfo={ pagination.page === 1 }
showSocketInfo={ pagination.page === 1 && !tokenFilter }
socketInfoAlert={ socketAlert }
socketInfoNum={ newItemsCount }
/>
</Hide>
<Show below="lg">
{ pagination.page === 1 && (
{ pagination.page === 1 && !tokenFilter && (
<SocketNewItemsNotice
url={ window.location.href }
num={ newItemsCount }
......@@ -205,19 +224,45 @@ const AddressTokenTransfers = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLD
);
})();
const tokenFilterComponent = tokenFilter && (
<Flex alignItems="center" py={ 1 } flexWrap="wrap" mb={{ base: isPaginationVisible ? 6 : 3, lg: 0 }}>
Filtered by token
<TokenLogo hash={ tokenFilter } boxSize={ 6 } mx={ 2 }/>
{ isMobile ? tokenFilter.slice(0, 4) + '...' + tokenFilter.slice(-4) : tokenFilter }
<Tooltip label="Reset filter">
<Flex>
<Icon
as={ crossIcon }
boxSize={ 6 }
ml={ 1 }
color={ resetTokenIconColor }
cursor="pointer"
_hover={{ color: resetTokenIconHoverColor }}
onClick={ resetTokenFilter }
/>
</Flex>
</Tooltip>
</Flex>
);
return (
<>
{ isMobile && tokenFilterComponent }
{ !isActionBarHidden && (
<ActionBar mt={ -6 }>
<TokenTransferFilter
defaultTypeFilters={ filters.type }
onTypeFilterChange={ handleTypeFilterChange }
appliedFiltersNum={ numActiveFilters }
withAddressFilter
onAddressFilterChange={ handleAddressFilterChange }
defaultAddressFilter={ filters.filter }
/>
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
{ !isMobile && tokenFilterComponent }
{ !tokenFilter && (
<TokenTransferFilter
defaultTypeFilters={ filters.type }
onTypeFilterChange={ handleTypeFilterChange }
appliedFiltersNum={ numActiveFilters }
withAddressFilter
onAddressFilterChange={ handleAddressFilterChange }
defaultAddressFilter={ filters.filter }
/>
) }
{ currentAddress && <AddressCsvExportLink address={ currentAddress } type="token-transfers" ml={{ base: 2, lg: 'auto' }}/> }
{ isPaginationVisible && <Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/> }
</ActionBar>
) }
{ content }
......
import { useQueryClient } from '@tanstack/react-query';
import castArray from 'lodash/castArray';
import { useRouter } from 'next/router';
import React from 'react';
......@@ -17,6 +16,7 @@ import ActionBar from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/Pagination';
import TxsContent from 'ui/txs/TxsContent';
import AddressCsvExportLink from './AddressCsvExportLink';
import AddressTxsFilter from './AddressTxsFilter';
const OVERLOAD_COUNT = 75;
......@@ -31,12 +31,13 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
const [ newItemsCount, setNewItemsCount ] = React.useState(0);
const isMobile = useIsMobile();
const currentAddress = router.query.id?.toString();
const [ filterValue, setFilterValue ] = React.useState<AddressFromToFilter>(getFilterValue(router.query.filter));
const addressTxsQuery = useQueryWithPages({
resourceName: 'address_txs',
pathParams: { id: castArray(router.query.id)[0] },
pathParams: { id: currentAddress },
filters: { filter: filterValue },
scrollRef,
});
......@@ -51,8 +52,6 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
const handleNewSocketMessage: SocketMessage.AddressTxs['handler'] = (payload) => {
setSocketAlert('');
const currentAddress = router.query.id?.toString();
if (addressTxsQuery.data?.items && addressTxsQuery.data.items.length >= OVERLOAD_COUNT) {
if (
!filterValue ||
......@@ -109,7 +108,7 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
}, []);
const channel = useSocketChannel({
topic: `addresses:${ (router.query.id as string).toLowerCase() }`,
topic: `addresses:${ currentAddress?.toLowerCase() }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: addressTxsQuery.pagination.page !== 1,
......@@ -140,13 +139,14 @@ const AddressTxs = ({ scrollRef }: {scrollRef?: React.RefObject<HTMLDivElement>}
{ !isMobile && (
<ActionBar mt={ -6 }>
{ filter }
{ addressTxsQuery.isPaginationVisible && <Pagination { ...addressTxsQuery.pagination }/> }
{ currentAddress && <AddressCsvExportLink address={ currentAddress } type="transactions" ml="auto"/> }
{ addressTxsQuery.isPaginationVisible && <Pagination { ...addressTxsQuery.pagination } ml={ 8 }/> }
</ActionBar>
) }
<TxsContent
filter={ filter }
query={ addressTxsQuery }
currentAddress={ typeof router.query.id === 'string' ? router.query.id : undefined }
currentAddress={ typeof currentAddress === 'string' ? currentAddress : undefined }
enableTimeIncrement
showSocketInfo={ addressTxsQuery.pagination.page === 1 }
socketInfoAlert={ socketAlert }
......
......@@ -10,7 +10,7 @@ import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import FilterButton from 'ui/shared/FilterButton';
import FilterButton from 'ui/shared/filters/FilterButton';
interface Props {
isActive: boolean;
......
......@@ -49,11 +49,11 @@ const AddressCoinBalanceHistory = ({ query }: Props) => {
<Table variant="simple" size="sm">
<Thead top={ 80 }>
<Tr>
<Th width="25%">Block</Th>
<Th width="25%">Txn</Th>
<Th width="25%">Age</Th>
<Th width="25%" isNumeric pr={ 1 }/>
<Th width="120px" isNumeric>Balance { appConfig.network.currency.symbol }</Th>
<Th width="20%">Block</Th>
<Th width="20%">Txn</Th>
<Th width="20%">Age</Th>
<Th width="20%" isNumeric pr={ 1 }>Balance { appConfig.network.currency.symbol }</Th>
<Th width="20%" isNumeric>Delta</Th>
</Tr>
</Thead>
<Tbody>
......
......@@ -30,7 +30,7 @@ const AddressCoinBalanceListItem = (props: Props) => {
<StatHelpText display="flex" mb={ 0 } alignItems="center">
<StatArrow type={ isPositiveDelta ? 'increase' : 'decrease' }/>
<Text as="span" color={ isPositiveDelta ? 'green.500' : 'red.500' } fontWeight={ 600 }>
{ deltaBn.toFixed(8) }
{ deltaBn.toFormat() }
</Text>
</StatHelpText>
</Stat>
......
......@@ -46,7 +46,7 @@ const AddressCoinBalanceTableItem = (props: Props) => {
<StatHelpText display="flex" mb={ 0 } alignItems="center">
<StatArrow type={ isPositiveDelta ? 'increase' : 'decrease' }/>
<Text as="span" color={ isPositiveDelta ? 'green.500' : 'red.500' } fontWeight={ 600 }>
{ deltaBn.toFixed(8) }
{ deltaBn.toFormat() }
</Text>
</StatHelpText>
</Stat>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as contractMock from 'mocks/contract/info';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import ContractCode from './ContractCode';
const addressHash = 'hash';
const CONTRACT_API_URL = buildApiUrl('contract', { id: addressHash });
const hooksConfig = {
router: {
query: { id: addressHash },
},
};
test('verified with changed byte code +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withChangedByteCode),
}));
const component = await mount(
<TestApp>
<ContractCode/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
test('verified with multiple sources', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withMultiplePaths),
}));
await mount(
<TestApp>
<ContractCode/>
</TestApp>,
{ hooksConfig },
);
const section = page.locator('section', { hasText: 'Contract source code' });
await expect(section).toHaveScreenshot();
});
test('verified via sourcify', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.verifiedViaSourcify),
}));
await mount(
<TestApp>
<ContractCode/>
</TestApp>,
{ hooksConfig },
);
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 1200, height: 110 } });
});
test('self destructed', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.selfDestructed),
}));
await mount(
<TestApp>
<ContractCode/>
</TestApp>,
{ hooksConfig },
);
const section = page.locator('section', { hasText: 'Contract creation code' });
await expect(section).toHaveScreenshot();
});
test('with twin address alert +@mobile', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withTwinAddress),
}));
const component = await mount(
<TestApp>
<ContractCode/>
</TestApp>,
{ hooksConfig },
);
await expect(component.getByRole('alert')).toHaveScreenshot();
});
test('with proxy address alert +@mobile', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.withProxyAddress),
}));
const component = await mount(
<TestApp>
<ContractCode/>
</TestApp>,
{ hooksConfig },
);
await expect(component.getByRole('alert')).toHaveScreenshot();
});
test('non verified', async({ mount, page }) => {
await page.route(CONTRACT_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMock.nonVerified),
}));
const component = await mount(
<TestApp>
<ContractCode/>
</TestApp>,
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
});
import { Flex, Skeleton, Button, Grid, GridItem, Text } from '@chakra-ui/react';
import dynamic from 'next/dynamic';
import { Flex, Skeleton, Button, Grid, GridItem, Text, Alert, Link, chakra, Box } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import useApiQuery from 'lib/api/useApiQuery';
import link from 'lib/link/link';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ExternalLink from 'ui/shared/ExternalLink';
import RawDataSnippet from 'ui/shared/RawDataSnippet';
const DynamicContractSourceCode = dynamic(
() => import('./ContractSourceCode'),
{ ssr: false },
);
import ContractSourceCode from './ContractSourceCode';
const InfoItem = ({ label, value }: { label: string; value: string }) => (
<GridItem display="flex" columnGap={ 6 }>
......@@ -23,10 +23,12 @@ const InfoItem = ({ label, value }: { label: string; value: string }) => (
const ContractCode = () => {
const router = useRouter();
const addressHash = router.query.id?.toString();
const { data, isLoading, isError } = useApiQuery('contract', {
pathParams: { id: router.query.id?.toString() },
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(router.query.id),
enabled: Boolean(addressHash),
refetchOnMount: false,
},
});
......@@ -57,14 +59,92 @@ const ContractCode = () => {
ml="auto"
mr={ 3 }
as="a"
href={ link('address_contract_verification', { id: router.query.id?.toString() }) }
href={ link('address_contract_verification', { id: addressHash }) }
>
Verify & publish
</Button>
);
const constructorArgs = (() => {
if (!data.decoded_constructor_args) {
return data.constructor_args;
}
const decoded = data.decoded_constructor_args
.map(([ value, { name, type } ], index) => {
const valueEl = type === 'address' ? <Link href={ link('address_index', { id: value }) }>{ value }</Link> : <span>{ value }</span>;
return (
<Box key={ index }>
<span>Arg [{ index }] { name || '' } ({ type }): </span>
{ valueEl }
</Box>
);
});
return (
<>
<span>{ data.constructor_args }</span>
<br/><br/>
{ decoded }
</>
);
})();
const externalLibraries = (() => {
if (!data.external_libraries || data.external_libraries.length === 0) {
return null;
}
return data.external_libraries.map((item) => (
<Box key={ item.address_hash }>
<chakra.span fontWeight={ 500 }>{ item.name }: </chakra.span>
<Link href={ link('address_index', { id: item.address_hash }, { tab: 'contract' }) }>{ item.address_hash }</Link>
</Box>
));
})();
return (
<>
<Flex flexDir="column" rowGap={ 2 } mb={ 6 } _empty={{ display: 'none' }}>
{ data.is_verified && <Alert status="success">Contract Source Code Verified (Exact Match)</Alert> }
{ data.is_verified_via_sourcify && (
<Alert status="warning" whiteSpace="pre-wrap" flexWrap="wrap">
<span>This contract has been { data.is_partially_verified ? 'partially ' : '' }verified via Sourcify. </span>
{ data.sourcify_repo_url && <ExternalLink href={ data.sourcify_repo_url } title="View contract in Sourcify repository" fontSize="md"/> }
</Alert>
) }
{ data.is_changed_bytecode && (
<Alert status="warning">
Warning! Contract bytecode has been changed and does not match the verified one. Therefore, interaction with this smart contract may be risky.
</Alert>
) }
{ !data.is_verified && data.verified_twin_address_hash && !data.minimal_proxy_address_hash && (
<Alert status="warning" whiteSpace="pre-wrap" flexWrap="wrap">
<span>Contract is not verified. However, we found a verified contract with the same bytecode in Blockscout DB </span>
<Address>
<AddressIcon address={{ hash: data.verified_twin_address_hash, is_contract: true, implementation_name: null }}/>
<AddressLink type="address" hash={ data.verified_twin_address_hash } truncation="constant" ml={ 2 }/>
</Address>
<chakra.span mt={ 1 }>All functions displayed below are from ABI of that contract. In order to verify current contract, proceed with </chakra.span>
<Link href={ link('address_contract_verification', { id: data.verified_twin_address_hash }) }>Verify & Publish</Link>
<span> page</span>
</Alert>
) }
{ data.minimal_proxy_address_hash && (
<Alert status="warning" flexWrap="wrap" whiteSpace="pre-wrap">
<span>Minimal Proxy Contract for </span>
<Address>
<AddressIcon address={{ hash: data.minimal_proxy_address_hash, is_contract: true, implementation_name: null }}/>
<AddressLink type="address" hash={ data.minimal_proxy_address_hash } truncation="constant" ml={ 2 }/>
</Address>
<span>. </span>
<Box>
<Link href="https://eips.ethereum.org/EIPS/eip-1167">EIP-1167</Link>
<span> - minimal bytecode implementation that delegates all calls to a known address</span>
</Box>
</Alert>
) }
</Flex>
{ data.is_verified && (
<Grid templateColumns={{ base: '1fr', lg: '1fr 1fr' }} rowGap={ 4 } columnGap={ 6 } mb={ 8 }>
{ data.name && <InfoItem label="Contract name" value={ data.name }/> }
......@@ -76,18 +156,35 @@ const ContractCode = () => {
</Grid>
) }
<Flex flexDir="column" rowGap={ 6 }>
{ constructorArgs && (
<RawDataSnippet
data={ constructorArgs }
title="Constructor Arguments"
textareaMaxHeight="200px"
/>
) }
{ data.source_code && (
<DynamicContractSourceCode
<ContractSourceCode
data={ data.source_code }
hasSol2Yml={ Boolean(data.can_be_visualized_via_sol2uml) }
address={ router.query.id?.toString() }
address={ addressHash }
isViper={ Boolean(data.is_vyper_contract) }
filePath={ data.file_path }
additionalSource={ data.additional_sources }
/>
) }
{ Boolean(data.compiler_settings) && (
<RawDataSnippet
data={ JSON.stringify(data.compiler_settings) }
title="Compiler Settings"
textareaMaxHeight="200px"
/>
) }
{ data.abi && (
<RawDataSnippet
data={ JSON.stringify(data.abi) }
title="Contract ABI"
textareaMinHeight="200px"
textareaMaxHeight="200px"
/>
) }
{ data.creation_bytecode && (
......@@ -95,12 +192,27 @@ const ContractCode = () => {
data={ data.creation_bytecode }
title="Contract creation code"
rightSlot={ data.is_verified ? null : verificationButton }
beforeSlot={ data.is_self_destructed ? (
<Alert status="info" whiteSpace="pre-wrap" mb={ 3 }>
Contracts that self destruct in their constructors have no contract code published and cannot be verified.
Displaying the init data provided of the creating transaction.
</Alert>
) : null }
textareaMaxHeight="200px"
/>
) }
{ data.deployed_bytecode && (
<RawDataSnippet
data={ data.deployed_bytecode }
title="Deployed ByteCode"
textareaMaxHeight="200px"
/>
) }
{ externalLibraries && (
<RawDataSnippet
data={ externalLibraries }
title="External Libraries"
textareaMaxHeight="200px"
/>
) }
</Flex>
......
import { Alert, Button, Flex } from '@chakra-ui/react';
import { useWeb3Modal } from '@web3modal/react';
import React from 'react';
import { useAccount, useDisconnect } from 'wagmi';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
const ContractConnectWallet = () => {
const { open } = useWeb3Modal();
const { address, isDisconnected } = useAccount();
const { disconnect } = useDisconnect();
const isMobile = useIsMobile();
const handleConnect = React.useCallback(() => {
open();
}, [ open ]);
const handleDisconnect = React.useCallback(() => {
disconnect();
}, [ disconnect ]);
const content = (() => {
if (isDisconnected || !address) {
return (
<>
<span>Disconnected</span>
<Button ml={ 3 } onClick={ handleConnect } size="sm" variant="outline">Connect wallet</Button>
</>
);
}
return (
<Flex columnGap={ 3 } rowGap={ 3 } alignItems={{ base: 'flex-start', lg: 'center' }} flexDir={{ base: 'column', lg: 'row' }}>
<Flex alignItems="center">
<span>Connected to </span>
<AddressIcon address={{ hash: address, is_contract: false, implementation_name: null }} mx={ 2 }/>
<AddressLink type="address" fontWeight={ 600 } hash={ address } truncation={ isMobile ? 'constant' : 'dynamic' }/>
</Flex>
<Button onClick={ handleDisconnect } size="sm" variant="outline">Disconnect</Button>
</Flex>
);
})();
return <Alert mb={ 6 } status={ address ? 'success' : 'warning' }>{ content }</Alert>;
};
export default ContractConnectWallet;
import { Alert } from '@chakra-ui/react';
import React from 'react';
const ContractCustomAbiAlert = () => {
return (
<Alert status="warning" mb={ 4 }>
Note: Contract with custom ABI is only meant for debugging purpose and it is the user’s responsibility to ensure that the provided ABI
matches the contract, otherwise errors may occur or results returned may be incorrect.
Blockscout is not responsible for any losses that arise from the use of Read & Write contract.
</Alert>
);
};
export default React.memo(ContractCustomAbiAlert);
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
import type { Address as TAddress } from 'types/api/address';
import { getResourceKey } from 'lib/api/useApiQuery';
import Address from 'ui/shared/address/Address';
import AddressLink from 'ui/shared/address/AddressLink';
interface Props {
hash: string | undefined;
}
const ContractImplementationAddress = ({ hash }: Props) => {
const queryClient = useQueryClient();
const data = queryClient.getQueryData<TAddress>(getResourceKey('address', {
pathParams: { id: hash },
}));
if (!data?.implementation_address) {
return null;
}
return (
<Address whiteSpace="pre-wrap" flexWrap="wrap" mb={ 6 }>
<span>Implementation address: </span>
<AddressLink type="address" hash={ data.implementation_address }/>
</Address>
);
};
export default React.memo(ContractImplementationAddress);
import { Box, Button, chakra, Flex, Icon, Text, useColorModeValue } from '@chakra-ui/react';
import { Box, Button, chakra, Flex, Icon, Text } from '@chakra-ui/react';
import _fromPairs from 'lodash/fromPairs';
import React from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type { MethodFormFields } from './types';
import type { MethodFormFields, ContractMethodCallResult } from './types';
import type { SmartContractMethodInput, SmartContractMethod } from 'types/api/contract';
import appConfig from 'configs/app/config';
......@@ -12,9 +12,16 @@ import arrowIcon from 'icons/arrows/down-right.svg';
import ContractMethodField from './ContractMethodField';
interface ResultComponentProps<T extends SmartContractMethod> {
item: T;
result: ContractMethodCallResult<T>;
onSettle: () => void;
}
interface Props<T extends SmartContractMethod> {
data: T;
caller: (data: T, args: Array<string>) => Promise<Array<Array<string>>>;
onSubmit: (data: T, args: Array<string | Array<string>>) => Promise<ContractMethodCallResult<T>>;
ResultComponent: (props: ResultComponentProps<T>) => JSX.Element | null;
isWrite?: boolean;
}
......@@ -36,32 +43,61 @@ const sortFields = (data: Array<SmartContractMethodInput>) => ([ a ]: [string, s
return 0;
};
const ContractMethodCallable = <T extends SmartContractMethod>({ data, caller, isWrite }: Props<T>) => {
const castFieldValue = (data: Array<SmartContractMethodInput>) => ([ key, value ]: [ string, string ], index: number) => {
if (data[index].type.includes('[]')) {
return [ key, parseArrayValue(value) ];
}
return [ key, value ];
};
const parseArrayValue = (value: string) => value.replace(/(\[|\])|\s/g, '').split(',');
const ContractMethodCallable = <T extends SmartContractMethod>({ data, onSubmit, ResultComponent, isWrite }: Props<T>) => {
const [ result, setResult ] = React.useState<ContractMethodCallResult<T>>();
const [ isLoading, setLoading ] = React.useState(false);
const inputs = React.useMemo(() => {
return data.payable && (!('inputs' in data) || data.inputs.length === 0) ? [ {
name: 'value',
type: appConfig.network.currency.symbol,
internalType: appConfig.network.currency.symbol,
} as SmartContractMethodInput ] : data.inputs;
return [
...('inputs' in data ? data.inputs : []),
...(data.stateMutability === 'payable' ? [ {
name: 'value',
type: appConfig.network.currency.symbol,
internalType: appConfig.network.currency.symbol,
} as SmartContractMethodInput ] : []),
];
}, [ data ]);
const { control, handleSubmit, setValue } = useForm<MethodFormFields>({
defaultValues: _fromPairs(inputs.map(({ name }, index) => [ getFieldName(name, index), '' ])),
});
const [ result, setResult ] = React.useState<Array<Array<string>>>([ ]);
const onSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const handleTxSettle = React.useCallback(() => {
setLoading(false);
}, []);
const handleFormChange = React.useCallback(() => {
result && setResult(undefined);
}, [ result ]);
const onFormSubmit: SubmitHandler<MethodFormFields> = React.useCallback(async(formData) => {
const args = Object.entries(formData)
.sort(sortFields(inputs))
.map(castFieldValue(inputs))
.map(([ , value ]) => value);
const result = await caller(data, args);
setResult(result);
setResult(undefined);
setLoading(true);
}, [ caller, data, inputs ]);
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
onSubmit(data, args)
.then((result) => {
setResult(result);
})
.catch((error) => {
setResult(error?.error || error?.data || (error?.reason && { message: error.reason }) || error);
setLoading(false);
});
}, [ onSubmit, data, inputs ]);
return (
<Box>
......@@ -72,8 +108,9 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, caller, i
flexDir={{ base: 'column', lg: 'row' }}
rowGap={ 2 }
alignItems={{ base: 'flex-start', lg: 'center' }}
onSubmit={ handleSubmit(onSubmit) }
onSubmit={ handleSubmit(onFormSubmit) }
flexWrap="wrap"
onChange={ handleFormChange }
>
{ inputs.map(({ type, name }, index) => {
const fieldName = getFieldName(name, index);
......@@ -84,10 +121,14 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, caller, i
placeholder={ `${ name }(${ type })` }
control={ control }
setValue={ setValue }
isDisabled={ isLoading }
onClear={ handleFormChange }
/>
);
}) }
<Button
isLoading={ isLoading }
loadingText={ isWrite ? 'Write' : 'Query' }
variant="outline"
size="sm"
flexShrink={ 0 }
......@@ -102,18 +143,7 @@ const ContractMethodCallable = <T extends SmartContractMethod>({ data, caller, i
<Text>{ data.outputs.map(({ type }) => type).join(', ') }</Text>
</Flex>
) }
{ result.length > 0 && (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm">
<p>
[ <chakra.span fontWeight={ 600 }>{ 'name' in data ? data.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.map(([ key, value ], index) => (
<chakra.p key={ index } whiteSpace="break-spaces" wordBreak="break-all"> { key }: { value }</chakra.p>
)) }
<p>]</p>
</Box>
) }
{ result && <ResultComponent item={ data } result={ result } onSettle={ handleTxSettle }/> }
</Box>
);
};
......
......@@ -7,6 +7,7 @@ import type { SmartContractMethodOutput } from 'types/api/contract';
import appConfig from 'configs/app/config';
import { WEI } from 'lib/consts';
import AddressLink from 'ui/shared/address/AddressLink';
interface Props {
data: SmartContractMethodOutput;
......@@ -31,9 +32,17 @@ const ContractMethodStatic = ({ data }: Props) => {
}
}, [ data.value ]);
const content = (() => {
if (data.type === 'address' && data.value) {
return <AddressLink type="address" hash={ data.value }/>;
}
return <chakra.span wordBreak="break-all">({ data.type }): { value }</chakra.span>;
})();
return (
<Flex flexDir={{ base: 'column', lg: 'row' }} columnGap={ 2 } rowGap={ 2 }>
<chakra.span wordBreak="break-all">({ data.type }): { value }</chakra.span>
{ content }
{ isBigInt && <Checkbox onChange={ handleCheckboxChange }>{ label }</Checkbox> }
</Flex>
);
......
......@@ -12,19 +12,28 @@ interface Props {
setValue: UseFormSetValue<MethodFormFields>;
placeholder: string;
name: string;
isDisabled: boolean;
onClear: () => void;
}
const ContractMethodField = ({ control, name, placeholder, setValue }: Props) => {
const ContractMethodField = ({ control, name, placeholder, setValue, isDisabled, onClear }: Props) => {
const ref = React.useRef<HTMLInputElement>(null);
const handleClear = React.useCallback(() => {
setValue(name, '');
onClear();
ref.current?.focus();
}, [ name, setValue ]);
}, [ name, onClear, setValue ]);
const renderInput = React.useCallback(({ field }: { field: ControllerRenderProps<MethodFormFields> }) => {
return (
<FormControl id={ name } maxW={{ base: '100%', lg: 'calc((100% - 24px) / 3)' }}>
<FormControl
id={ name }
flexBasis={{ base: '100%', lg: 'calc((100% - 24px) / 3 - 65px)' }}
w={{ base: '100%', lg: 'auto' }}
flexGrow={ 1 }
isDisabled={ isDisabled
}>
<InputGroup size="xs">
<Input
{ ...field }
......@@ -33,13 +42,13 @@ const ContractMethodField = ({ control, name, placeholder, setValue }: Props) =>
/>
{ field.value && (
<InputRightElement>
<InputClearButton onClick={ handleClear }/>
<InputClearButton onClick={ handleClear } isDisabled={ isDisabled }/>
</InputRightElement>
) }
</InputGroup>
</FormControl>
);
}, [ handleClear, name, placeholder ]);
}, [ handleClear, isDisabled, name, placeholder ]);
return (
<Controller
......
......@@ -39,11 +39,11 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, renderC
<Accordion allowMultiple position="relative" onChange={ handleAccordionStateChange } index={ expandedSections }>
{ data.map((item, index) => {
return (
<AccordionItem key={ index } as="section">
<AccordionItem key={ index } as="section" _first={{ borderTopWidth: '0' }}>
<h2>
<AccordionButton px={ 0 } py={ 3 } _hover={{ bgColor: 'inherit' }}>
<Box as="span" fontFamily="heading" fontWeight={ 500 } fontSize="lg" mr={ 1 }>
{ index + 1 }. { item.type === 'fallback' ? 'fallback' : item.name }
{ index + 1 }. { item.type === 'fallback' || item.type === 'receive' ? item.type : item.name }
</Box>
{ item.type === 'fallback' && (
<Tooltip
......@@ -58,6 +58,21 @@ const ContractMethodsAccordion = <T extends SmartContractMethod>({ data, renderC
</Box>
</Tooltip>
) }
{ item.type === 'receive' && (
<Tooltip
label={ `The receive function is executed on a call to the contract with empty calldata.
This is the function that is executed on plain Ether transfers (e.g. via .send() or .transfer()).
If no such function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer.
If neither a receive Ether nor a payable fallback function is present,
the contract cannot receive Ether through regular transactions and throws an exception.` }
placement="top"
maxW="320px"
>
<Box cursor="pointer" display="inherit">
<Icon as={ infoIcon } boxSize={ 5 }/>
</Box>
</Tooltip>
) }
<AccordionIcon/>
</AccordionButton>
</h2>
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as contractMethodsMock from 'mocks/contract/methods';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import ContractRead from './ContractRead';
const addressHash = 'hash';
const CONTRACT_READ_METHODS_API_URL = buildApiUrl('contract_methods_read', { id: addressHash }) + '?is_custom_abi=false';
const CONTRACT_QUERY_METHOD_API_URL = buildApiUrl('contract_method_query', { id: addressHash });
const hooksConfig = {
router: {
query: { id: addressHash },
},
};
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(CONTRACT_READ_METHODS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMethodsMock.read),
}));
await page.route(CONTRACT_QUERY_METHOD_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMethodsMock.readResultSuccess),
}));
const component = await mount(
<TestApp>
<ContractRead/>
</TestApp>,
{ hooksConfig },
);
await component.getByText(/expand all/i).click();
await expect(component).toHaveScreenshot();
await component.getByPlaceholder(/address/i).type('address-hash');
await component.getByText(/query/i).click();
await component.getByText(/wei/i).click();
await expect(component).toHaveScreenshot();
});
test('error result', async({ mount, page }) => {
await page.route(CONTRACT_READ_METHODS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMethodsMock.read),
}));
await page.route(CONTRACT_QUERY_METHOD_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMethodsMock.readResultError),
}));
const component = await mount(
<TestApp>
<ContractRead/>
</TestApp>,
{ hooksConfig },
);
await component.getByText(/expand all/i).click();
await component.getByPlaceholder(/address/i).type('address-hash');
await component.getByText(/query/i).click();
const section = page.locator('section', { hasText: 'balanceOf' });
await expect(section).toHaveScreenshot();
});
import { Flex } from '@chakra-ui/react';
import { Alert, Flex } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import { useAccount } from 'wagmi';
import type { SmartContractReadMethod } from 'types/api/contract';
import type { SmartContractReadMethod, SmartContractQueryMethodRead } from 'types/api/contract';
import useApiFetch from 'lib/api/useApiFetch';
import useApiQuery from 'lib/api/useApiQuery';
......@@ -10,28 +11,37 @@ import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordi
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractMethodConstant from './ContractMethodConstant';
import ContractReadResult from './ContractReadResult';
interface Props {
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractRead = ({ isProxy }: Props) => {
const ContractRead = ({ isProxy, isCustomAbi }: Props) => {
const router = useRouter();
const apiFetch = useApiFetch();
const { address: userAddress } = useAccount();
const addressHash = router.query.id?.toString();
const { data, isLoading, isError } = useApiQuery(isProxy ? 'contract_methods_read_proxy' : 'contract_methods_read', {
pathParams: { id: addressHash },
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
queryOptions: {
enabled: Boolean(router.query.id),
},
});
const contractCaller = React.useCallback(async(item: SmartContractReadMethod, args: Array<string>) => {
await apiFetch('contract_method_query', {
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractReadMethod, args: Array<string | Array<string>>) => {
return apiFetch<'contract_method_query', SmartContractQueryMethodRead>('contract_method_query', {
pathParams: { id: addressHash },
fetchParams: {
method: 'POST',
......@@ -39,15 +49,18 @@ const ContractRead = ({ isProxy }: Props) => {
args,
method_id: item.method_id,
contract_type: isProxy ? 'proxy' : 'regular',
from: userAddress,
},
},
});
return [ [ 'string', 'this is mock' ] ];
}, [ addressHash, apiFetch, isProxy ]);
}, [ addressHash, apiFetch, isProxy, userAddress ]);
const renderContent = React.useCallback((item: SmartContractReadMethod, index: number, id: number) => {
if (item.inputs.length === 0) {
if (item.error) {
return <Alert status="error" fontSize="sm">{ item.error }</Alert>;
}
if (item.outputs.some(({ value }) => value)) {
return (
<Flex flexDir="column" rowGap={ 1 }>
{ item.outputs.map((output, index) => <ContractMethodConstant key={ index } data={ output }/>) }
......@@ -59,10 +72,11 @@ const ContractRead = ({ isProxy }: Props) => {
<ContractMethodCallable
key={ id + '_' + index }
data={ item }
caller={ contractCaller }
onSubmit={ handleMethodFormSubmit }
ResultComponent={ ContractReadResult }
/>
);
}, [ contractCaller ]);
}, [ handleMethodFormSubmit ]);
if (isError) {
return <DataFetchAlert/>;
......@@ -72,7 +86,18 @@ const ContractRead = ({ isProxy }: Props) => {
return <ContentLoader/>;
}
return <ContractMethodsAccordion data={ data } renderContent={ renderContent }/>;
if (data.length === 0 && !isProxy) {
return <span>No public read functions were found for this contract.</span>;
}
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/>
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } renderContent={ renderContent }/>
</>
);
};
export default ContractRead;
export default React.memo(ContractRead);
import { Alert, Box, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { ContractMethodReadResult } from './types';
import type { SmartContractReadMethod } from 'types/api/contract';
interface Props {
item: SmartContractReadMethod;
result: ContractMethodReadResult;
onSettle: () => void;
}
const ContractReadResult = ({ item, result, onSettle }: Props) => {
const resultBgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
React.useEffect(() => {
onSettle();
}, [ onSettle ]);
if ('status' in result) {
return <Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm">{ result.statusText }</Alert>;
}
if (result.is_error) {
const message = 'error' in result.result ? result.result.error : result.result.message;
return <Alert status="error" mt={ 3 } p={ 4 } borderRadius="md" fontSize="sm">{ message }</Alert>;
}
return (
<Box mt={ 3 } p={ 4 } borderRadius="md" bgColor={ resultBgColor } fontSize="sm">
<p>
[ <chakra.span fontWeight={ 600 }>{ 'name' in item ? item.name : '' }</chakra.span> method response ]
</p>
<p>[</p>
{ result.result.output.map(({ type, value }, index) => (
<chakra.p key={ index } whiteSpace="break-spaces" wordBreak="break-all"> { type }: { String(value) }</chakra.p>
)) }
<p>]</p>
</Box>
);
};
export default React.memo(ContractReadResult);
import { Box, Flex, Link, Text, Tooltip } from '@chakra-ui/react';
import { Box, chakra, Flex, Link, Text, Tooltip } from '@chakra-ui/react';
import React from 'react';
import type { SmartContract } from 'types/api/contract';
import link from 'lib/link/link';
import CodeEditor from 'ui/shared/CodeEditor';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
......@@ -9,28 +11,62 @@ interface Props {
data: string;
hasSol2Yml: boolean;
address?: string;
isViper: boolean;
filePath?: string;
additionalSource?: SmartContract['additional_sources'];
}
const ContractSourceCode = ({ data, hasSol2Yml, address }: Props) => {
const ContractSourceCode = ({ data, hasSol2Yml, address, isViper, filePath, additionalSource }: Props) => {
const heading = (
<Text fontWeight={ 500 }>
<span>Contract source code</span>
<Text whiteSpace="pre" as="span" variant="secondary"> ({ isViper ? 'Vyper' : 'Solidity' })</Text>
</Text>
);
const diagramLink = hasSol2Yml && address ? (
<Tooltip label="Visualize contract code using Sol2Uml JS library">
<Link
href={ link('visualize_sol2uml', undefined, { address }) }
ml="auto"
mr={ 3 }
>
View UML diagram
</Link>
</Tooltip>
) : null;
if (!additionalSource?.length) {
return (
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
{ heading }
{ diagramLink }
<CopyToClipboard text={ data }/>
</Flex>
<CodeEditor value={ data } id="source_code"/>
</section>
);
}
return (
<Box>
<section>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
<Text fontWeight={ 500 }>Contract source code</Text>
{ hasSol2Yml && address && (
<Tooltip label="Visualize contract code using Sol2Uml JS library">
<Link
href={ link('visualize_sol2uml', undefined, { address }) }
ml="auto"
mr={ 3 }
>
View Sol2uml
</Link>
</Tooltip>
) }
<CopyToClipboard text={ data }/>
{ heading }
{ diagramLink }
</Flex>
<Flex flexDir="column" rowGap={ 3 }>
{ [ { file_path: filePath, source_code: data }, ...additionalSource ].map((item, index, array) => (
<Box key={ index }>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
<chakra.span fontSize="sm">File { index + 1 } of { array.length }: { item.file_path }</chakra.span>
<CopyToClipboard text={ item.source_code }/>
</Flex>
<CodeEditor value={ item.source_code } id={ `source_code_${ index }` }/>
</Box>
)) }
</Flex>
<CodeEditor value={ data } id="source_code"/>
</Box>
</section>
);
};
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as contractMethodsMock from 'mocks/contract/methods';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import ContractWrite from './ContractWrite';
const addressHash = 'hash';
const CONTRACT_WRITE_METHODS_API_URL = buildApiUrl('contract_methods_write', { id: addressHash }) + '?is_custom_abi=false';
const hooksConfig = {
router: {
query: { id: addressHash },
},
};
test('base view +@mobile', async({ mount, page }) => {
await page.route(CONTRACT_WRITE_METHODS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(contractMethodsMock.write),
}));
const component = await mount(
<TestApp>
<ContractWrite/>
</TestApp>,
{ hooksConfig },
);
await component.getByText(/expand all/i).click();
await expect(component).toHaveScreenshot();
});
import _capitalize from 'lodash/capitalize';
import { useRouter } from 'next/router';
import React from 'react';
import { useAccount, useSigner } from 'wagmi';
import type { SmartContractWriteMethod } from 'types/api/contract';
import config from 'configs/app/config';
import useApiQuery from 'lib/api/useApiQuery';
import ContractMethodsAccordion from 'ui/address/contract/ContractMethodsAccordion';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import { useContractContext } from './context';
import ContractConnectWallet from './ContractConnectWallet';
import ContractCustomAbiAlert from './ContractCustomAbiAlert';
import ContractImplementationAddress from './ContractImplementationAddress';
import ContractMethodCallable from './ContractMethodCallable';
import ContractWriteResult from './ContractWriteResult';
import { getNativeCoinValue, isExtendedError } from './utils';
interface Props {
isProxy?: boolean;
isCustomAbi?: boolean;
}
const ContractWrite = ({ isProxy }: Props) => {
const ContractWrite = ({ isProxy, isCustomAbi }: Props) => {
const router = useRouter();
const addressHash = router.query.id?.toString();
const { data: signer } = useSigner();
const { isConnected } = useAccount();
const { data, isLoading, isError } = useApiQuery(isProxy ? 'contract_methods_write_proxy' : 'contract_methods_write', {
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(router.query.id),
queryParams: {
is_custom_abi: isCustomAbi ? 'true' : 'false',
},
});
const contractInfo = useApiQuery('contract', {
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(router.query.id),
enabled: Boolean(addressHash),
},
});
const contractCaller = React.useCallback(async() => {
// eslint-disable-next-line no-console
console.log('__>__', contractInfo);
return [ [ 'string', 'this is mock' ] ];
}, [ contractInfo ]);
const { contract, proxy } = useContractContext();
const _contract = isProxy ? proxy : contract;
const handleMethodFormSubmit = React.useCallback(async(item: SmartContractWriteMethod, args: Array<string | Array<string>>) => {
if (!isConnected) {
throw new Error('Wallet is not connected');
}
try {
if (!_contract) {
return;
}
if (item.type === 'receive') {
const value = args[0] ? getNativeCoinValue(args[0]) : '0';
const result = await signer?.sendTransaction({
to: addressHash,
value,
});
return { hash: result?.hash as string };
}
const _args = item.stateMutability === 'payable' ? args.slice(0, -1) : args;
const value = item.stateMutability === 'payable' ? getNativeCoinValue(args[args.length - 1]) : undefined;
const methodName = item.type === 'fallback' ? 'fallback' : item.name;
const result = await _contract[methodName](..._args, {
gasLimit: 100_000,
value,
});
return { hash: result.hash as string };
} catch (error) {
if (isExtendedError(error)) {
if ('reason' in error && error.reason === 'underlying network changed') {
if ('detectedNetwork' in error && typeof error.detectedNetwork === 'object' && error.detectedNetwork !== null) {
const networkName = error.detectedNetwork.name;
if (networkName) {
throw new Error(
// eslint-disable-next-line max-len
`You connected to ${ _capitalize(networkName) } chain in the wallet, but the current instance of Blockscout is for ${ config.network.name } chain`,
);
}
}
throw new Error('Wrong network detected, please make sure you are switched to the correct network, and try again');
}
}
throw error;
}
}, [ _contract, addressHash, isConnected, signer ]);
const renderContent = React.useCallback((item: SmartContractWriteMethod, index: number, id: number) => {
return (
<ContractMethodCallable
key={ id + '_' + index }
data={ item }
caller={ contractCaller }
onSubmit={ handleMethodFormSubmit }
ResultComponent={ ContractWriteResult }
isWrite
/>
);
}, [ contractCaller ]);
}, [ handleMethodFormSubmit ]);
if (isError) {
return <DataFetchAlert/>;
......@@ -58,7 +114,18 @@ const ContractWrite = ({ isProxy }: Props) => {
return <ContentLoader/>;
}
return <ContractMethodsAccordion data={ data } renderContent={ renderContent }/>;
if (data.length === 0 && !isProxy) {
return <span>No public write functions were found for this contract.</span>;
}
return (
<>
{ isCustomAbi && <ContractCustomAbiAlert/> }
<ContractConnectWallet/>
{ isProxy && <ContractImplementationAddress hash={ addressHash }/> }
<ContractMethodsAccordion data={ data } renderContent={ renderContent }/>
</>
);
};
export default ContractWrite;
export default React.memo(ContractWrite);
import React from 'react';
import { useWaitForTransaction } from 'wagmi';
import type { ContractMethodWriteResult } from './types';
import ContractWriteResultDumb from './ContractWriteResultDumb';
interface Props {
result: ContractMethodWriteResult;
onSettle: () => void;
}
const ContractWriteResult = ({ result, onSettle }: Props) => {
const txHash = result && 'hash' in result ? result.hash as `0x${ string }` : undefined;
const txInfo = useWaitForTransaction({
hash: txHash,
});
return <ContractWriteResultDumb result={ result } onSettle={ onSettle } txInfo={ txInfo }/>;
};
export default React.memo(ContractWriteResult);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import TestApp from 'playwright/TestApp';
import ContractWriteResultDumb from './ContractWriteResultDumb';
test('loading', async({ mount }) => {
const props = {
txInfo: {
status: 'loading' as const,
error: null,
},
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('success', async({ mount }) => {
const props = {
txInfo: {
status: 'success' as const,
error: null,
},
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('error +@mobile', async({ mount }) => {
const props = {
txInfo: {
status: 'error' as const,
error: {
// eslint-disable-next-line max-len
message: 'missing revert data in call exception; Transaction reverted without a reason string [ See: https://links.ethers.org/v5-errors-CALL_EXCEPTION ]',
} as Error,
},
result: {
hash: '0x363574E6C5C71c343d7348093D84320c76d5Dd29',
},
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('error in result', async({ mount }) => {
const props = {
txInfo: {
status: 'idle' as const,
error: null,
},
result: {
message: 'wallet is not connected',
} as Error,
onSettle: () => {},
};
const component = await mount(
<TestApp>
<ContractWriteResultDumb { ...props }/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Box, chakra, Link, Spinner } from '@chakra-ui/react';
import React from 'react';
import type { ContractMethodWriteResult } from './types';
import link from 'lib/link/link';
interface Props {
result: ContractMethodWriteResult;
onSettle: () => void;
txInfo: {
status: 'loading' | 'success' | 'error' | 'idle';
error: Error | null;
};
}
const ContractWriteResultDumb = ({ result, onSettle, txInfo }: Props) => {
const txHash = result && 'hash' in result ? result.hash : undefined;
React.useEffect(() => {
if (txInfo.status !== 'loading') {
onSettle();
}
}, [ onSettle, txInfo.status ]);
if (!result) {
return null;
}
const isErrorResult = 'message' in result;
const txLink = (
<Link href={ link('tx', { id: txHash }) }>View transaction details</Link>
);
const content = (() => {
if (isErrorResult) {
return (
<>
<span>Error: </span>
<span>{ result.message }</span>
</>
);
}
switch (txInfo.status) {
case 'success': {
return (
<>
<span>Transaction has been confirmed. </span>
{ txLink }
</>
);
}
case 'loading': {
return (
<>
<Spinner size="sm" mr={ 3 }/>
<chakra.span verticalAlign="text-bottom">
{ 'Waiting for transaction\'s confirmation. ' }
{ txLink }
</chakra.span>
</>
);
}
case 'error': {
return (
<>
<span>Error: </span>
<span>{ txInfo.error ? txInfo.error.message : 'Something went wrong' } </span>
{ txLink }
</>
);
}
}
})();
return (
<Box
fontSize="sm"
pl={ 3 }
mt={ 3 }
alignItems="center"
whiteSpace="pre-wrap"
wordBreak="break-all"
color={ txInfo.status === 'error' || isErrorResult ? 'red.600' : undefined }
>
{ content }
</Box>
);
};
export default React.memo(ContractWriteResultDumb);
import { useQueryClient } from '@tanstack/react-query';
import type { Contract } from 'ethers';
import { useRouter } from 'next/router';
import React from 'react';
import { useContract, useProvider, useSigner } from 'wagmi';
import type { Address } from 'types/api/address';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
type ProviderProps = {
children: React.ReactNode;
}
type TContractContext = {
contract: Contract | null;
proxy: Contract | null;
};
const ContractContext = React.createContext<TContractContext>({
contract: null,
proxy: null,
});
export function ContractContextProvider({ children }: ProviderProps) {
const router = useRouter();
const provider = useProvider();
const { data: signer } = useSigner();
const queryClient = useQueryClient();
const addressHash = router.query.id?.toString();
const { data: contractInfo } = useApiQuery('contract', {
pathParams: { id: addressHash },
queryOptions: {
enabled: Boolean(addressHash),
refetchOnMount: false,
},
});
const addressInfo = queryClient.getQueryData<Address>(getResourceKey('address', {
pathParams: { id: addressHash },
}));
const { data: proxyInfo } = useApiQuery('contract', {
pathParams: { id: addressInfo?.implementation_address || '' },
queryOptions: {
enabled: Boolean(addressInfo?.implementation_address),
refetchOnMount: false,
},
});
const contract = useContract({
address: addressHash,
abi: contractInfo?.abi || undefined,
signerOrProvider: signer || provider,
});
const proxy = useContract({
address: addressInfo?.implementation_address ?? undefined,
abi: proxyInfo?.abi ?? undefined,
signerOrProvider: signer ?? provider,
});
const value = React.useMemo(() => ({
contract,
proxy,
}), [ contract, proxy ]);
return (
<ContractContext.Provider value={ value }>
{ children }
</ContractContext.Provider>
);
}
export function useContractContext() {
const context = React.useContext(ContractContext);
if (context === undefined) {
throw new Error('useContractContext must be used within a ContractContextProvider');
}
return context;
}
import type { SmartContractQueryMethodRead, SmartContractMethod } from 'types/api/contract';
import type { ResourceError } from 'lib/api/resources';
export type MethodFormFields = Record<string, string>;
export type ContractMethodReadResult = SmartContractQueryMethodRead | ResourceError;
export type ContractMethodWriteResult = Error | { hash: string } | undefined;
export type ContractMethodCallResult<T extends SmartContractMethod> =
T extends { method_id: string } ? ContractMethodReadResult : ContractMethodWriteResult;
import BigNumber from 'bignumber.js';
import config from 'configs/app/config';
export const getNativeCoinValue = (value: string | Array<string>) => {
const _value = Array.isArray(value) ? value[0] : value;
return BigNumber(_value).times(10 ** config.network.currency.decimals).toString();
};
interface ExtendedError extends Error {
detectedNetwork?: {
chain: number;
name: string;
};
reason?: string;
}
export function isExtendedError(error: unknown): error is ExtendedError {
return (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as Record<string, unknown>).message === 'string'
);
}
......@@ -16,7 +16,7 @@ const AddressAddToMetaMask = ({ className, token }: Props) => {
const handleClick = React.useCallback(async() => {
try {
const wasAdded = await window.ethereum.request({
const wasAdded = await window.ethereum.request?.({
method: 'wallet_watchAsset',
params: {
type: 'ERC20', // Initially only supports ERC20, but eventually more!
......@@ -50,7 +50,7 @@ const AddressAddToMetaMask = ({ className, token }: Props) => {
}
}, [ toast, token ]);
if (token.type !== 'ERC-20' || !('ethereum' in window)) {
if (!('ethereum' in window)) {
return null;
}
......
......@@ -71,6 +71,8 @@ const AddressBalance = ({ data }: Props) => {
<DetailsInfoItem
title="Balance"
hint={ `Address balance in ${ appConfig.network.currency.symbol }. Doesn't include ERC20, ERC721 and ERC1155 tokens.` }
flexWrap="nowrap"
alignItems="flex-start"
>
<TokenLogo
hash={ appConfig.network.currency.address }
......@@ -86,6 +88,7 @@ const AddressBalance = ({ data }: Props) => {
currency={ appConfig.network.currency.symbol }
accuracyUsd={ 2 }
accuracy={ 8 }
flexWrap="wrap"
/>
</DetailsInfoItem>
);
......
......@@ -54,7 +54,7 @@ const TxInternalsListItem = ({
<Box w="100%" display="flex" columnGap={ 3 }>
<Address width="calc((100% - 48px) / 2)">
<AddressIcon address={ from }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } isDisabled={ isOut }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ from.hash } isDisabled={ isOut }/>
</Address>
{ (isIn || isOut) ?
<InOutTag isIn={ isIn } isOut={ isOut }/> :
......@@ -62,7 +62,7 @@ const TxInternalsListItem = ({
}
<Address width="calc((100% - 48px) / 2)">
<AddressIcon address={ toData }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ toData.hash } isDisabled={ isIn }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ toData.hash } isDisabled={ isIn }/>
</Address>
</Box>
<HStack spacing={ 3 }>
......
import { Skeleton, SkeletonCircle, Flex, Box, useColorModeValue } from '@chakra-ui/react';
import { Skeleton, SkeletonCircle, Flex, Box } from '@chakra-ui/react';
import React from 'react';
const TxInternalsSkeletonMobile = () => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Box>
{ Array.from(Array(2)).map((item, index) => (
......@@ -13,7 +11,7 @@ const TxInternalsSkeletonMobile = () => {
flexDirection="column"
paddingY={ 6 }
borderTopWidth="1px"
borderColor={ borderColor }
borderColor="divider"
_last={{
borderBottomWidth: '1px',
}}
......
......@@ -62,7 +62,7 @@ const AddressIntTxsTableItem = ({
<Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%">
<AddressIcon address={ from }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 } isDisabled={ isOut }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 } isDisabled={ isOut }/>
</Address>
</Td>
<Td px={ 0 } verticalAlign="middle">
......@@ -74,7 +74,7 @@ const AddressIntTxsTableItem = ({
<Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%">
<AddressIcon address={ toData }/>
<AddressLink hash={ toData.hash } alias={ toData.name } fontWeight="500" ml={ 2 } isDisabled={ isIn }/>
<AddressLink type="address" hash={ toData.hash } alias={ toData.name } fontWeight="500" ml={ 2 } isDisabled={ isIn }/>
</Address>
</Td>
<Td isNumeric verticalAlign="middle">
......
......@@ -49,7 +49,7 @@ const TokenSelectItem = ({ data }: Props) => {
display="flex"
flexDir="column"
rowGap={ 2 }
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderColor="divider"
borderBottomWidth="1px"
_hover={{
bgColor: useColorModeValue('blue.50', 'gray.800'),
......
import { Flex, Tag, Text, HStack } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AddressesItem } from 'types/api/addresses';
import appConfig from 'configs/app/config';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import ListItemMobile from 'ui/shared/ListItemMobile';
type Props = {
item: AddressesItem;
index: number;
totalSupply: string;
}
const AddressesListItem = ({
item,
index,
totalSupply,
}: Props) => {
const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** appConfig.network.currency.decimals));
return (
<ListItemMobile rowGap={ 3 }>
<Flex alignItems="center" justifyContent="space-between" w="100%">
<Address maxW="100%" mr={ 8 }>
<AddressIcon address={ item } mr={ 2 }/>
<AddressLink
fontWeight={ 700 }
flexGrow={ 1 }
w="calc(100% - 32px)"
hash={ item.hash }
alias={ item.name }
type="address"
/>
</Address>
<Text fontSize="sm" ml="auto" variant="secondary">{ index }</Text>
</Flex>
{ item.public_tags !== null && item.public_tags.length > 0 && item.public_tags.map(tag => (
<Tag key={ tag.label }>{ tag.display_name }</Tag>
)) }
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>{ `Balance ${ appConfig.network.currency.symbol }` }</Text>
<Text fontSize="sm" variant="secondary">{ addressBalance.dp(8).toFormat() }</Text>
</HStack>
{ totalSupply && totalSupply !== '0' && (
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Percentage</Text>
<Text fontSize="sm" variant="secondary">{ addressBalance.div(BigNumber(totalSupply)).multipliedBy(100).dp(8).toFormat() + '%' }</Text>
</HStack>
) }
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Txn count</Text>
<Text fontSize="sm" variant="secondary">{ Number(item.tx_count).toLocaleString('en') }</Text>
</HStack>
</ListItemMobile>
);
};
export default React.memo(AddressesListItem);
import { Table, Tbody, Tr, Th } from '@chakra-ui/react';
import React from 'react';
import type { AddressesItem } from 'types/api/addresses';
import appConfig from 'configs/app/config';
import { default as Thead } from 'ui/shared/TheadSticky';
import AddressesTableItem from './AddressesTableItem';
interface Props {
items: Array<AddressesItem>;
totalSupply: string;
pageStartIndex: number;
}
const AddressesTable = ({ items, totalSupply, pageStartIndex }: Props) => {
const hasPercentage = Boolean(totalSupply && totalSupply !== '0');
return (
<Table variant="simple" size="sm">
<Thead top={ 80 }>
<Tr>
<Th width="64px">Rank</Th>
<Th width={ hasPercentage ? '30%' : '40%' }>Address</Th>
<Th width="20%" pl={ 10 }>Public tag</Th>
<Th width={ hasPercentage ? '20%' : '25%' } isNumeric>{ `Balance ${ appConfig.network.currency.symbol }` }</Th>
{ hasPercentage && <Th width="15%" isNumeric>Percentage</Th> }
<Th width="15%" isNumeric>Txn count</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<AddressesTableItem
key={ item.hash }
item={ item }
totalSupply={ totalSupply }
index={ pageStartIndex + index }
hasPercentage={ hasPercentage }
/>
)) }
</Tbody>
</Table>
);
};
export default AddressesTable;
import { Tr, Td, Tag, Text } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { AddressesItem } from 'types/api/addresses';
import appConfig from 'configs/app/config';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
type Props = {
item: AddressesItem;
index: number;
totalSupply: string;
hasPercentage: boolean;
}
const AddressesTableItem = ({
item,
index,
totalSupply,
hasPercentage,
}: Props) => {
const addressBalance = BigNumber(item.coin_balance).div(BigNumber(10 ** appConfig.network.currency.decimals));
const addressBalanceChunks = addressBalance.dp(8).toFormat().split('.');
return (
<Tr>
<Td>
<Text lineHeight="24px">{ index }</Text>
</Td>
<Td>
<Address display="inline-flex" maxW="100%">
<AddressIcon address={ item } mr={ 2 }/>
<AddressLink
fontWeight={ 700 }
flexGrow={ 1 }
w="calc(100% - 32px)"
hash={ item.hash }
alias={ item.name }
type="address"
/>
</Address>
</Td>
<Td pl={ 10 }>
{ item.public_tags && item.public_tags.length ? item.public_tags.map(tag => (
<Tag key={ tag.label }>{ tag.display_name }</Tag>
)) : <Text lineHeight="24px">-</Text> }
</Td>
<Td isNumeric>
<Text lineHeight="24px" as="span">{ addressBalanceChunks[0] }</Text>
{ addressBalanceChunks[1] && <Text lineHeight="24px" as="span">.</Text> }
<Text lineHeight="24px" variant="secondary" as="span">{ addressBalanceChunks[1] }</Text>
</Td>
{ hasPercentage && (
<Td isNumeric>
<Text lineHeight="24px">{ addressBalance.div(BigNumber(totalSupply)).multipliedBy(100).dp(8).toFormat() + '%' }</Text>
</Td>
) }
<Td isNumeric>
<Text lineHeight="24px">{ Number(item.tx_count).toLocaleString('en') }</Text>
</Td>
</Tr>
);
};
export default React.memo(AddressesTableItem);
......@@ -14,7 +14,7 @@ type Props = {
data: ApiKey;
}
const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const DeleteApiKeyModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
const queryClient = useQueryClient();
const apiFetch = useApiFetch();
......@@ -49,4 +49,4 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, data }) => {
);
};
export default DeleteAddressModal;
export default DeleteApiKeyModal;
import { Grid, GridItem, Text, Icon, Link, Box, Tooltip, useColorModeValue } from '@chakra-ui/react';
import { Grid, GridItem, Text, Icon, Link, Box, Tooltip } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import capitalize from 'lodash/capitalize';
import NextLink from 'next/link';
......@@ -52,8 +52,6 @@ const BlockDetails = () => {
router.push(url, undefined);
}, [ router ]);
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
if (isLoading) {
return <BlockDetailsSkeleton/>;
}
......@@ -76,7 +74,7 @@ const BlockDetails = () => {
mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }}
borderBottom="1px solid"
borderColor={ borderColor }
borderColor="divider"
/>
);
const { totalReward, staticReward, burntFees, txFees } = getBlockReward(data);
......@@ -129,7 +127,7 @@ const BlockDetails = () => {
hint="A block producer who successfully included the block onto the blockchain."
columnGap={ 1 }
>
<AddressLink hash={ data.miner.hash }/>
<AddressLink type="address" hash={ data.miner.hash }/>
{ data.miner.name && <Text>{ `(${ capitalize(validatorTitle) }: ${ data.miner.name })` }</Text> }
{ /* api doesn't return the block processing time yet */ }
{ /* <Text>{ dayjs.duration(block.minedIn, 'second').humanize(true) }</Text> */ }
......
import { Grid, GridItem, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { Grid, GridItem, Skeleton } from '@chakra-ui/react';
import React from 'react';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
const BlockDetailsSkeleton = () => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const sectionGap = (
<GridItem
colSpan={{ base: undefined, lg: 2 }}
mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }}
borderBottom="1px solid"
borderColor={ borderColor }
borderColor="divider"
/>
);
......
......@@ -48,7 +48,7 @@ const BlocksListItem = ({ data, isPending, enableTimeIncrement }: Props) => {
</Flex>
<Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>{ capitalize(getNetworkValidatorTitle()) }</Text>
<AddressLink alias={ data.miner.name } hash={ data.miner.hash } truncation="constant"/>
<AddressLink type="address" alias={ data.miner.name } hash={ data.miner.hash } truncation="constant"/>
</Flex>
<Flex columnGap={ 2 }>
<Text fontWeight={ 500 }>Txn</Text>
......
......@@ -50,7 +50,7 @@ const BlocksTableItem = ({ data, isPending, enableTimeIncrement }: Props) => {
</Td>
<Td fontSize="sm">{ data.size.toLocaleString('en') }</Td>
<Td fontSize="sm">
<AddressLink alias={ data.miner.name } hash={ data.miner.hash } truncation="constant" display="inline-flex" maxW="100%"/>
<AddressLink type="address" alias={ data.miner.name } hash={ data.miner.hash } truncation="constant" display="inline-flex" maxW="100%"/>
</Td>
<Td isNumeric fontSize="sm">{ data.tx_count }</Td>
<Td fontSize="sm">
......
import { Alert, AlertIcon, AlertTitle, chakra } from '@chakra-ui/react';
import { Alert, AlertIcon, AlertTitle, chakra, Skeleton } from '@chakra-ui/react';
import { useQueryClient } from '@tanstack/react-query';
import React from 'react';
......@@ -6,6 +6,8 @@ import type { SocketMessage } from 'lib/socket/types';
import type { IndexingStatus } from 'types/api/indexingStatus';
import useApiQuery, { getResourceKey } from 'lib/api/useApiQuery';
import { useAppContext } from 'lib/appContext';
import * as cookies from 'lib/cookies';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp, ndash } from 'lib/html-entities';
import useSocketChannel from 'lib/socket/useSocketChannel';
......@@ -14,7 +16,17 @@ import useSocketMessage from 'lib/socket/useSocketMessage';
const IndexingAlert = ({ className }: { className?: string }) => {
const isMobile = useIsMobile();
const { data } = useApiQuery('homepage_indexing_status');
const appProps = useAppContext();
const cookiesString = appProps.cookies;
const [ hasAlertCookie ] = React.useState(cookies.get(cookies.NAMES.INDEXING_ALERT, cookiesString) === 'true');
const { data, isError, isLoading } = useApiQuery('homepage_indexing_status');
React.useEffect(() => {
if (!isLoading && !isError) {
cookies.set(cookies.NAMES.INDEXING_ALERT, data.finished_indexing && data.finished_indexing_blocks ? 'false' : 'true');
}
}, [ data, isError, isLoading ]);
const queryClient = useQueryClient();
......@@ -62,17 +74,21 @@ const IndexingAlert = ({ className }: { className?: string }) => {
handler: handleIntermalTxsIndexStatus,
});
if (!data) {
if (isError) {
return null;
}
if (isLoading) {
return hasAlertCookie ? <Skeleton h={{ base: '96px', lg: '48px' }} mb={ 6 } w="100%" className={ className }/> : null;
}
let content;
if (data.finished_indexing_blocks === false) {
content = `${ data.indexed_blocks_ratio && `${ (Number(data.indexed_blocks_ratio) * 100).toFixed() }% Blocks Indexed${ nbsp }${ ndash } ` }
content = `${ data.indexed_blocks_ratio && `${ Math.floor(Number(data.indexed_blocks_ratio) * 100) }% Blocks Indexed${ nbsp }${ ndash } ` }
We're indexing this chain right now. Some of the counts may be inaccurate.` ;
} else if (data.finished_indexing === false) {
content = `${ data.indexed_inernal_transactions_ratio &&
`${ (Number(data.indexed_inernal_transactions_ratio) * 100).toFixed() }% Blocks With Internal Transactions Indexed${ nbsp }${ ndash } ` }
`${ Math.floor(Number(data.indexed_inernal_transactions_ratio) * 100) }% Blocks With Internal Transactions Indexed${ nbsp }${ ndash } ` }
We're indexing this chain right now. Some of the counts may be inaccurate.`;
}
......
......@@ -7,7 +7,6 @@ import {
Icon,
Link,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { motion } from 'framer-motion';
import React from 'react';
......@@ -36,14 +35,14 @@ const LatestBlocksItem = ({ block, h }: Props) => {
transitionTimingFunction="linear"
borderRadius="12px"
border="1px solid"
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderColor="divider"
p={ 6 }
h={ `${ h }px` }
minWidth={{ base: '100%', lg: '280px' }}
>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
<HStack spacing={ 2 }>
<Icon as={ blockIcon } boxSize="30px" color={ useColorModeValue('blue.600', 'blue.300') }/>
<Icon as={ blockIcon } boxSize="30px" color="link"/>
<Link
href={ link('block', { id: String(block.height) }) }
fontSize="xl"
......@@ -61,7 +60,7 @@ const LatestBlocksItem = ({ block, h }: Props) => {
<GridItem>Reward</GridItem>
<GridItem><Text variant="secondary">{ totalReward.toFixed() }</Text></GridItem>
<GridItem>Miner</GridItem>
<GridItem><AddressLink alias={ block.miner.name } hash={ block.miner.hash } truncation="constant" maxW="100%"/></GridItem>
<GridItem><AddressLink type="address" alias={ block.miner.name } hash={ block.miner.hash } truncation="constant" maxW="100%"/></GridItem>
</Grid>
</Box>
);
......
......@@ -5,7 +5,6 @@ import {
GridItem,
HStack,
Skeleton,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
......@@ -15,7 +14,7 @@ const LatestBlocksItemSkeleton = () => {
minWidth={{ base: '100%', lg: '280px' }}
borderRadius="12px"
border="1px solid"
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderColor="divider"
p={ 6 }
>
<Flex justifyContent="space-between" alignItems="center" mb={ 3 }>
......
......@@ -4,7 +4,6 @@ import {
HStack,
Icon,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
......@@ -28,9 +27,6 @@ type Props = {
}
const LatestBlocksItem = ({ tx }: Props) => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const iconColor = useColorModeValue('blue.600', 'blue.300');
const dataTo = tx.to ? tx.to : tx.created_contract;
const timeAgo = useTimeAgoIncrement(tx.timestamp || '0', true);
......@@ -40,10 +36,10 @@ const LatestBlocksItem = ({ tx }: Props) => {
<Box
width="100%"
borderTop="1px solid"
borderColor={ borderColor }
borderColor="divider"
py={ 4 }
px={{ base: 0, lg: 4 }}
_last={{ borderBottom: '1px solid', borderColor }}
_last={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<Flex justifyContent="space-between" width="100%" alignItems="start" flexDirection={{ base: 'column', lg: 'row' }}>
{ !isMobile && <Flex mr={ 3 }><TxAdditionalInfo tx={ tx }/></Flex> }
......@@ -67,7 +63,7 @@ const LatestBlocksItem = ({ tx }: Props) => {
as={ transactionIcon }
boxSize="30px"
mr={ 2 }
color={ iconColor }
color="link"
/>
<Address width="100%">
<AddressLink
......@@ -86,6 +82,7 @@ const LatestBlocksItem = ({ tx }: Props) => {
<Address>
<AddressIcon address={ tx.from }/>
<AddressLink
type="address"
hash={ tx.from.hash }
alias={ tx.from.name }
fontWeight="500"
......@@ -103,6 +100,7 @@ const LatestBlocksItem = ({ tx }: Props) => {
<Address>
<AddressIcon address={ dataTo }/>
<AddressLink
type="address"
hash={ dataTo.hash }
alias={ dataTo.name }
fontWeight="500"
......
......@@ -4,21 +4,18 @@ import {
HStack,
Skeleton,
SkeletonCircle,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
const LatestTxsItemSkeleton = () => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Box
width="100%"
borderTop="1px solid"
borderColor={ borderColor }
borderColor="divider"
py={ 4 }
px={{ base: 0, lg: 4 }}
_last={{ borderBottom: '1px solid', borderColor }}
_last={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
<Flex justifyContent="space-between" width="100%" alignItems="start" flexDirection={{ base: 'column', lg: 'row' }}>
<Box width="100%">
......
......@@ -30,13 +30,14 @@ const Stats = () => {
let content;
const lastItemTouchStyle = { gridColumn: { base: 'span 2', lg: 'unset' } };
if (isLoading) {
content = Array.from(Array(itemsCount)).map((item, index) => <StatsItemSkeleton key={ index }/>);
content = Array.from(Array(itemsCount)).map((item, index) => <StatsItemSkeleton key={ index } _last={ itemsCount % 2 ? lastItemTouchStyle : undefined }/>);
}
const lastItemTouchStyle = { gridColumn: { base: 'span 2', lg: 'unset' } };
if (data) {
const isOdd = Boolean(hasGasTracker && !data.gas_prices ? (itemsCount - 1) % 2 : itemsCount % 2);
const gasLabel = hasGasTracker && data.gas_prices ? <StatsGasPrices gasPrices={ data.gas_prices }/> : null;
content = (
<>
......@@ -63,14 +64,14 @@ const Stats = () => {
icon={ walletIcon }
title="Wallet addresses"
value={ Number(data.total_addresses).toLocaleString() }
_last={ itemsCount % 2 ? lastItemTouchStyle : undefined }
_last={ isOdd ? lastItemTouchStyle : undefined }
/>
{ hasGasTracker && data.gas_prices && (
<StatsItem
icon={ gasIcon }
title="Gas tracker"
value={ `${ Number(data.gas_prices.average).toLocaleString() } Gwei` }
_last={ itemsCount % 2 ? lastItemTouchStyle : undefined }
_last={ isOdd ? lastItemTouchStyle : undefined }
tooltipLabel={ gasLabel }
/>
) }
......
import { Flex, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { Flex, Skeleton, useColorModeValue, chakra } from '@chakra-ui/react';
import React from 'react';
const StatsItemSkeleton = () => {
const StatsItemSkeleton = ({ className }: {className?: string}) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
return (
......@@ -13,6 +13,7 @@ const StatsItemSkeleton = () => {
alignItems="center"
columnGap={ 3 }
rowGap={ 2 }
className={ className }
>
<Skeleton
w="40px"
......@@ -26,4 +27,4 @@ const StatsItemSkeleton = () => {
);
};
export default StatsItemSkeleton;
export default chakra(StatsItemSkeleton);
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import type { AddressesResponse } from 'types/api/addresses';
import * as addressMocks from 'mocks/address/address';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import Accounts from './Accounts';
const ADDRESSES_API_URL = buildApiUrl('addresses');
const addresses: AddressesResponse = {
items: [
{
...addressMocks.withName,
tx_count: '1',
coin_balance: '12345678901234567890000',
}, {
...addressMocks.token,
tx_count: '109123890123',
coin_balance: '22222345678901234567890000',
}, {
...addressMocks.withoutName,
tx_count: '11',
coin_balance: '1000000000000000000',
},
],
total_supply: '25222000',
next_page_params: {
items_count: 50,
fetched_coin_balance: '123',
hash: 'aa',
},
};
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(ADDRESSES_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify(addresses),
}));
await page.route('https://request-global.czilladx.com/serve/native.php?z=19260bf627546ab7242', (route) => route.fulfill({
status: 200,
body: '',
}));
const component = await mount(
<TestApp>
<Accounts/>
</TestApp>,
);
await expect(component.locator('main')).toHaveScreenshot();
});
import { Hide, Show } from '@chakra-ui/react';
import React from 'react';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import AddressesListItem from 'ui/addresses/AddressesListItem';
import AddressesTable from 'ui/addresses/AddressesTable';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
const PAGE_SIZE = 50;
const Accounts = () => {
const { isError, isLoading, data, isPaginationVisible, pagination } = useQueryWithPages({
resourceName: 'addresses',
});
const content = (() => {
if (isError) {
return <DataFetchAlert/>;
}
const bar = isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
);
if (isLoading) {
return (
<>
{ bar }
<Show below="lg">
<SkeletonList/>
</Show>
<Hide below="lg">
<SkeletonTable columns={ [ '64px', '30%', '20%', '20%', '15%', '15%' ] }/>
</Hide>
</>
);
}
const pageStartIndex = (pagination.page - 1) * PAGE_SIZE + 1;
return (
<>
{ bar }
<Hide below="lg" ssr={ false }>
<AddressesTable
items={ data.items }
totalSupply={ data.total_supply }
pageStartIndex={ pageStartIndex }
/>
</Hide>
<Show below="lg" ssr={ false }>
{ data.items.map((item, index) => {
return (
<AddressesListItem
key={ item.hash }
item={ item }
index={ pageStartIndex + index }
totalSupply={ data.total_supply }
/>
);
}) }
</Show>
</>
);
})();
return (
<Page>
<PageTitle text="Top accounts" withTextAd/>
{ content }
</Page>
);
};
export default Accounts;
import { Flex, Skeleton, Tag, Box } from '@chakra-ui/react';
import { Flex, Skeleton, Tag, Box, Icon } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import type { RoutedTab } from 'ui/shared/RoutedTabs/types';
import iconSuccess from 'icons/status/success.svg';
import useApiQuery from 'lib/api/useApiQuery';
import notEmpty from 'lib/notEmpty';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
......@@ -52,9 +53,10 @@ const AddressPageContent = () => {
const contractTabs = React.useMemo(() => {
return [
{ id: 'contact_code', title: 'Code', component: <ContractCode/> },
addressQuery.data?.has_decompiled_code ?
{ id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
undefined,
// this is not implemented in api yet
// addressQuery.data?.has_decompiled_code ?
// { id: 'contact_decompiled_code', title: 'Decompiled code', component: <div>Decompiled code</div> } :
// undefined,
addressQuery.data?.has_methods_read ?
{ id: 'read_contract', title: 'Read contract', component: <ContractRead/> } :
undefined,
......@@ -62,7 +64,7 @@ const AddressPageContent = () => {
{ id: 'read_proxy', title: 'Read proxy', component: <ContractRead isProxy/> } :
undefined,
addressQuery.data?.has_custom_methods_read ?
{ id: 'read_custom_methods', title: 'Read custom methods', component: <div>Read custom methods</div> } :
{ id: 'read_custom_methods', title: 'Read custom', component: <ContractRead isCustomAbi/> } :
undefined,
addressQuery.data?.has_methods_write ?
{ id: 'write_contract', title: 'Write contract', component: <ContractWrite/> } :
......@@ -71,7 +73,7 @@ const AddressPageContent = () => {
{ id: 'write_proxy', title: 'Write proxy', component: <ContractWrite isProxy/> } :
undefined,
addressQuery.data?.has_custom_methods_write ?
{ id: 'write_custom_methods', title: 'Write custom methods', component: <div>Write custom methods</div> } :
{ id: 'write_custom_methods', title: 'Write custom', component: <ContractWrite isCustomAbi/> } :
undefined,
].filter(notEmpty);
}, [ addressQuery.data ]);
......@@ -91,7 +93,18 @@ const AddressPageContent = () => {
addressQuery.data?.has_logs ? { id: 'logs', title: 'Logs', component: <AddressLogs scrollRef={ tabsScrollRef }/> } : undefined,
addressQuery.data?.is_contract ? {
id: 'contract',
title: 'Contract',
title: () => {
if (addressQuery.data.is_verified) {
return (
<>
<span>Contract</span>
<Icon as={ iconSuccess } boxSize="14px" color="green.500" ml={ 1 }/>
</>
);
}
return 'Contract';
},
component: <AddressContract tabs={ contractTabs }/>,
subTabs: contractTabs.map(tab => tab.id),
} : undefined,
......
......@@ -6,7 +6,7 @@ import PlusIcon from 'icons/plus.svg';
import AppList from 'ui/apps/AppList';
import AppListSkeleton from 'ui/apps/AppListSkeleton';
import CategoriesMenu from 'ui/apps/CategoriesMenu';
import FilterInput from 'ui/shared/FilterInput';
import FilterInput from 'ui/shared/filters/FilterInput';
import useMarketplaceApps from '../apps/useMarketplaceApps';
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { withToken as contract } from 'mocks/address/address';
import { token as contract } from 'mocks/address/address';
import { tokenInfo, tokenCounters } from 'mocks/tokens/tokenInfo';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
......@@ -10,6 +10,7 @@ import Token from './Token';
const TOKEN_API_URL = buildApiUrl('token', { hash: '1' });
const TOKEN_COUNTERS_API_URL = buildApiUrl('token_counters', { hash: '1' });
const TOKEN_TRANSFERS_API_URL = buildApiUrl('token_transfers', { hash: '1' });
const ADDRESS_API_URL = buildApiUrl('address', { id: '1' });
const hooksConfig = {
router: {
......@@ -33,6 +34,10 @@ test('base view +@dark-mode', async({ mount, page }) => {
status: 200,
body: JSON.stringify(tokenCounters),
}));
await page.route(TOKEN_TRANSFERS_API_URL, (route) => route.fulfill({
status: 200,
body: JSON.stringify({}),
}));
const component = await mount(
<TestApp>
......@@ -41,5 +46,5 @@ test('base view +@dark-mode', async({ mount, page }) => {
{ hooksConfig },
);
await expect(component).toHaveScreenshot();
await expect(component.locator('main')).toHaveScreenshot();
});
......@@ -15,6 +15,7 @@ import RoutedTabs from 'ui/shared/RoutedTabs/RoutedTabs';
import TokenContractInfo from 'ui/token/TokenContractInfo';
import TokenDetails from 'ui/token/TokenDetails';
import TokenHolders from 'ui/token/TokenHolders/TokenHolders';
import TokenTransfer from 'ui/token/TokenTransfer/TokenTransfer';
export type TokenTabs = 'token_transfers' | 'holders'
......@@ -29,13 +30,14 @@ const TokenPageContent = () => {
queryOptions: { enabled: Boolean(router.query.hash) },
});
// const transfersQuery = useQueryWithPages({
// resourceName: 'token_transfers',
// pathParams: { hash: router.query.hash?.toString() },
// options: {
// enabled: Boolean(router.query.hash && router.query.tab === 'holders' && tokenQuery.data),
// },
// });
const transfersQuery = useQueryWithPages({
resourceName: 'token_transfers',
pathParams: { hash: router.query.hash?.toString() },
scrollRef,
options: {
enabled: Boolean(router.query.hash && (!router.query.tab || router.query.tab === 'token_transfers') && tokenQuery.data),
},
});
const holdersQuery = useQueryWithPages({
resourceName: 'token_holders',
......@@ -47,16 +49,18 @@ const TokenPageContent = () => {
});
const tabs: Array<RoutedTab> = [
{ id: 'token_transfers', title: 'Token transfers', component: null },
{ id: 'token_transfers', title: 'Token transfers', component: <TokenTransfer transfersQuery={ transfersQuery } token={ tokenQuery.data }/> },
{ id: 'holders', title: 'Holders', component: <TokenHolders tokenQuery={ tokenQuery } holdersQuery={ holdersQuery }/> },
];
let hasPagination;
let pagination;
// if (router.query.tab === 'token_transfers') {
// hasPagination = transfersQuery.isPaginationVisible;
// pagination = transfersQuery.pagination;
// }
if (!router.query.tab || router.query.tab === 'token_transfers') {
hasPagination = transfersQuery.isPaginationVisible;
pagination = transfersQuery.pagination;
}
if (router.query.tab === 'holders') {
hasPagination = holdersQuery.isPaginationVisible;
pagination = holdersQuery.pagination;
......
import React from 'react';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
import TokensList from 'ui/tokens/Tokens';
const Tokens = () => {
return (
<Page>
<PageTitle text="Tokens" withTextAd/>
<TokensList/>
</Page>
);
};
export default Tokens;
......@@ -43,7 +43,7 @@ const SearchResultListItem = ({ data, searchTerm }: Props) => {
<Address>
<AddressIcon address={{ hash: data.address, is_contract: data.type === 'contract', implementation_name: null }} mr={ 2 } flexShrink={ 0 }/>
<Box as={ shouldHighlightHash ? 'mark' : 'span' } display="block" whiteSpace="nowrap" overflow="hidden">
<AddressLink hash={ data.address } fontWeight={ 700 } display="block" w="100%"/>
<AddressLink type="address" hash={ data.address } fontWeight={ 700 } display="block" w="100%"/>
</Box>
</Address>
);
......
......@@ -18,6 +18,10 @@ const ActionBar = ({ children, className }: Props) => {
const isSticky = useIsSticky(ref, TOP_UP + 5);
const bgColor = useColorModeValue('white', 'black');
if (!React.Children.toArray(children).filter(Boolean).length) {
return null;
}
return (
<Flex
className={ className }
......
......@@ -17,7 +17,6 @@ interface Props {
const AdditionalInfoButton = ({ isOpen, onClick, className }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const infoBgColor = useColorModeValue('blue.50', 'gray.600');
const infoColor = useColorModeValue('blue.600', 'blue.300');
return (
<Button
......@@ -36,8 +35,8 @@ const AdditionalInfoButton = ({ isOpen, onClick, className }: Props, ref: React.
<Icon
as={ infoIcon }
boxSize={ 5 }
color={ infoColor }
_hover={{ color: 'blue.400' }}
color="link"
_hover={{ color: 'link_hovered' }}
/>
</Button>
);
......
......@@ -18,7 +18,7 @@ const AddressSnippet = ({ address, subtitle }: Props) => {
<Box maxW="100%">
<Address>
<AddressIcon address={ address }/>
<AddressLink hash={ address.hash } fontWeight="600" ml={ 2 }/>
<AddressLink type="address" hash={ address.hash } fontWeight="600" ml={ 2 }/>
<CopyToClipboard text={ address.hash } ml={ 1 }/>
</Address>
{ subtitle && <Text fontSize="sm" variant="secondary" mt={ 0.5 } ml={ 8 }>{ subtitle }</Text> }
......
import { chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import AceEditor from 'react-ace';
import 'ace-builds/src-noconflict/mode-javascript';
import 'ace-builds/src-noconflict/theme-tomorrow';
import 'ace-builds/src-noconflict/theme-tomorrow_night';
import 'ace-builds/src-noconflict/ext-language_tools';
import type ReactAce from 'react-ace/lib/ace';
interface Props {
id: string;
......@@ -14,12 +10,35 @@ interface Props {
const CodeEditorBase = chakra(({ id, value, className }: Props) => {
const [ AceEditor, setAceEditor ] = React.useState<{default: typeof ReactAce} | null>(null);
React.useEffect(() => {
const load = async() => {
const component = await import('react-ace');
await import('ace-builds/src-noconflict/mode-csharp');
await import('ace-builds/src-noconflict/theme-tomorrow');
await import('ace-builds/src-noconflict/theme-tomorrow_night');
await import('ace-builds/src-noconflict/ext-language_tools');
setAceEditor(component);
};
load();
return () => {
setAceEditor(null);
};
}, []);
const theme = useColorModeValue('tomorrow', 'tomorrow_night');
if (!AceEditor) {
return null;
}
return (
<AceEditor
<AceEditor.default
className={ className }
mode="javascript" // TODO need to find mode for solidity
mode="csharp" // TODO need to find mode for solidity
theme={ theme }
value={ value }
name={ id }
......
import { Link, Icon } from '@chakra-ui/react';
import { Link, Icon, chakra } from '@chakra-ui/react';
import React from 'react';
import arrowIcon from 'icons/arrows/north-east.svg';
......@@ -6,15 +6,16 @@ import arrowIcon from 'icons/arrows/north-east.svg';
interface Props {
href: string;
title: string;
className?: string;
}
const ExternalLink = ({ href, title }: Props) => {
const ExternalLink = ({ href, title, className }: Props) => {
return (
<Link fontSize="sm" display="inline-flex" alignItems="center" target="_blank" href={ href }>
<Link className={ className } fontSize="sm" display="inline-flex" alignItems="center" target="_blank" href={ href }>
{ title }
<Icon as={ arrowIcon } boxSize={ 4 }/>
</Link>
);
};
export default React.memo(ExternalLink);
export default React.memo(chakra(ExternalLink));
......@@ -5,15 +5,17 @@ import errorIcon from 'icons/status/error.svg';
interface Props {
onClick: () => void;
isDisabled?: boolean;
className?: string;
}
const InputClearButton = ({ onClick, className }: Props) => {
const InputClearButton = ({ onClick, isDisabled, className }: Props) => {
const iconColor = useColorModeValue('gray.300', 'gray.600');
const iconColorHover = useColorModeValue('gray.200', 'gray.500');
return (
<IconButton
isDisabled={ isDisabled }
className={ className }
colorScheme="none"
aria-label="Clear input"
......
import { Flex, useColorModeValue, chakra } from '@chakra-ui/react';
import { Flex, chakra } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import React from 'react';
......@@ -20,7 +20,7 @@ const ListItemMobile = ({ children, className, isAnimated }: Props) => {
alignItems="flex-start"
flexDirection="column"
paddingY={ 6 }
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderColor="divider"
borderTopWidth="1px"
_last={{
borderBottomWidth: '1px',
......
import { Flex } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import appConfig from 'configs/app/config';
import buildUrl from 'lib/api/buildUrl';
import { getResourceKey } from 'lib/api/useApiQuery';
import * as cookies from 'lib/cookies';
import useFetch from 'lib/hooks/useFetch';
import useGetCsrfToken from 'lib/hooks/useGetCsrfToken';
import AppError from 'ui/shared/AppError/AppError';
import ErrorBoundary from 'ui/shared/ErrorBoundary';
import ErrorInvalidTxHash from 'ui/shared/ErrorInvalidTxHash';
......@@ -27,28 +22,8 @@ const Page = ({
isHomePage,
renderHeader,
}: Props) => {
const nodeApiFetch = useFetch();
useQuery(getResourceKey('csrf'), async() => {
if (appConfig.host === appConfig.api.host) {
const url = buildUrl('csrf');
const apiResponse = await fetch(url, { credentials: 'include' });
const csrfFromHeader = apiResponse.headers.get('x-bs-account-csrf');
// eslint-disable-next-line no-console
console.log('>>> RESPONSE HEADERS <<<');
// eslint-disable-next-line no-console
console.table([ {
'content-length': apiResponse.headers.get('content-length'),
'x-bs-account-csrf': csrfFromHeader,
} ]);
return csrfFromHeader ? { token: csrfFromHeader } : undefined;
}
return nodeApiFetch('/node-api/csrf');
}, {
enabled: Boolean(cookies.get(cookies.NAMES.API_TOKEN)),
});
useGetCsrfToken();
const renderErrorScreen = React.useCallback((error?: Error) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
......
import { Box, Flex, Text, Textarea, chakra } from '@chakra-ui/react';
import { Box, Flex, Text, chakra, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import CopyToClipboard from './CopyToClipboard';
interface Props {
data: string;
data: React.ReactNode;
title?: string;
className?: string;
rightSlot?: React.ReactNode;
textareaMinHeight?: string;
beforeSlot?: React.ReactNode;
textareaMaxHeight?: string;
}
const RawDataSnippet = ({ data, className, title, rightSlot, textareaMinHeight }: Props) => {
const RawDataSnippet = ({ data, className, title, rightSlot, beforeSlot, textareaMaxHeight }: Props) => {
// see issue in theme/components/Textarea.ts
const bgColor = useColorModeValue('#f5f5f6', '#1a1b1b');
return (
<Box className={ className }>
<Box className={ className } as="section" title={ title }>
<Flex justifyContent={ title ? 'space-between' : 'flex-end' } alignItems="center" mb={ 3 }>
{ title && <Text fontWeight={ 500 }>{ title }</Text> }
{ rightSlot }
<CopyToClipboard text={ data }/>
{ typeof data === 'string' && <CopyToClipboard text={ data }/> }
</Flex>
<Textarea
variant="filledInactive"
{ beforeSlot }
<Box
p={ 4 }
minHeight={ textareaMinHeight || '400px' }
value={ data }
bgColor={ bgColor }
maxH={ textareaMaxHeight || '400px' }
fontSize="sm"
borderRadius="md"
readOnly
/>
wordBreak="break-all"
whiteSpace="pre-wrap"
overflowY="auto"
>
{ data }
</Box>
</Box>
);
};
......
......@@ -11,7 +11,7 @@ import {
} from '@chakra-ui/react';
import type { StyleProps } from '@chakra-ui/styled-system';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import type { RoutedTab } from './types';
......@@ -31,7 +31,7 @@ const hiddenItemStyles: StyleProps = {
interface Props extends ThemingProps<'Tabs'> {
tabs: Array<RoutedTab>;
tabListProps?: ChakraProps;
tabListProps?: ChakraProps | (({ isSticky }: { isSticky: boolean }) => ChakraProps);
rightSlot?: React.ReactNode;
stickyEnabled?: boolean;
className?: string;
......@@ -43,6 +43,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
const [ activeTabIndex, setActiveTabIndex ] = useState<number>(tabs.length + 1);
const isMobile = useIsMobile();
const tabsRef = useRef<HTMLDivElement>(null);
const { tabsCut, tabsList, tabsRefs, listRef, rightSlotRef } = useAdaptiveTabs(tabs, isMobile);
const isSticky = useIsSticky(listRef, 5, stickyEnabled);
const listBgColor = useColorModeValue('white', 'black');
......@@ -57,6 +58,23 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
);
}, [ tabs, router ]);
useEffect(() => {
if (router.query.scroll_to_tabs) {
tabsRef?.current?.scrollIntoView(true);
delete router.query.scroll_to_tabs;
router.push(
{
pathname: router.pathname,
query: router.query,
},
undefined,
{ shallow: true },
);
}
// replicate componentDidMount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (router.isReady) {
let tabIndex = 0;
......@@ -90,6 +108,10 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ activeTabIndex, isMobile ]);
if (tabs.length === 1) {
return <div>{ tabs[0].component }</div>;
}
return (
<Tabs
className={ className }
......@@ -100,6 +122,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
index={ activeTabIndex }
position="relative"
size={ themeProps.size || 'md' }
ref={ tabsRef }
>
<TabList
marginBottom={{ base: 6, lg: 8 }}
......@@ -130,7 +153,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
zIndex: { base: 'sticky2', lg: 'docked' },
} : { })
}
{ ...tabListProps }
{ ...(typeof tabListProps === 'function' ? tabListProps({ isSticky }) : tabListProps) }
>
{ tabsList.map((tab, index) => {
if (!tab.id) {
......@@ -162,7 +185,7 @@ const RoutedTabs = ({ tabs, tabListProps, rightSlot, stickyEnabled, className, .
scrollSnapAlign="start"
flexShrink={ 0 }
>
{ tab.title }
{ typeof tab.title === 'function' ? tab.title() : tab.title }
</Tab>
);
}) }
......
......@@ -56,7 +56,7 @@ const RoutedTabsMenu = ({ tabs, tabsCut, isActive, styles, onItemClick, buttonRe
justifyContent="left"
data-index={ index }
>
{ tab.title }
{ typeof tab.title === 'function' ? tab.title() : tab.title }
</Button>
)) }
</PopoverBody>
......
import type React from 'react';
export interface RoutedTab {
id: string;
title: string;
title: string | (() => React.ReactNode);
component: React.ReactNode;
subTabs?: Array<string>;
}
......
......@@ -16,6 +16,9 @@ const EmptyElement = ({ className }: { className?: string }) => {
color={ color }
borderRadius="base"
as={ tokenPlaceholderIcon }
transitionProperty="background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
/>
);
};
......
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
CheckboxGroup,
Checkbox,
Text,
useDisclosure,
Radio,
RadioGroup,
Stack,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
import type { AddressFromToFilter } from 'types/api/address';
import type { TokenType } from 'types/api/tokenInfo';
import FilterButton from 'ui/shared/FilterButton';
import { TOKEN_TYPE } from './helpers';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter';
interface Props {
appliedFiltersNum?: number;
......@@ -38,47 +29,31 @@ const TokenTransferFilter = ({
onAddressFilterChange,
defaultAddressFilter,
}: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<FilterButton
isActive={ isOpen || Number(appliedFiltersNum) > 0 }
onClick={ onToggle }
appliedFiltersNum={ appliedFiltersNum }
/>
</PopoverTrigger>
<PopoverContent w="200px">
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 5 }>
{ withAddressFilter && (
<>
<Text variant="secondary" fontWeight={ 600 }>Address</Text>
<RadioGroup
size="lg"
onChange={ onAddressFilterChange }
defaultValue={ defaultAddressFilter || 'all' }
paddingBottom={ 4 }
borderBottom="1px solid"
borderColor={ borderColor }
>
<Stack spacing={ 4 }>
<Radio value="all"><Text fontSize="md">All</Text></Radio>
<Radio value="from"><Text fontSize="md">From</Text></Radio>
<Radio value="to"><Text fontSize="md">To</Text></Radio>
</Stack>
</RadioGroup>
</>
) }
<Text variant="secondary" fontWeight={ 600 }>Type</Text>
<CheckboxGroup size="lg" onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters }>
{ TOKEN_TYPE.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</PopoverBody>
</PopoverContent>
</Popover>
<PopoverFilter appliedFiltersNum={ appliedFiltersNum } contentProps={{ w: '200px' }}>
{ withAddressFilter && (
<>
<Text variant="secondary" fontWeight={ 600 }>Address</Text>
<RadioGroup
size="lg"
onChange={ onAddressFilterChange }
defaultValue={ defaultAddressFilter || 'all' }
paddingBottom={ 4 }
borderBottom="1px solid"
borderColor="divider"
>
<Stack spacing={ 4 }>
<Radio value="all"><Text fontSize="md">All</Text></Radio>
<Radio value="from"><Text fontSize="md">From</Text></Radio>
<Radio value="to"><Text fontSize="md">To</Text></Radio>
</Stack>
</RadioGroup>
</>
) }
<Text variant="secondary" fontWeight={ 600 }>Type</Text>
<TokenTypeFilter onChange={ onTypeFilterChange } defaultValue={ defaultTypeFilters }/>
</PopoverFilter>
);
};
......
import { Text, Flex, Tag, Icon, useColorModeValue } from '@chakra-ui/react';
import { Text, Flex, Tag, Icon } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
......@@ -43,8 +43,6 @@ const TokenTransferListItem = ({
return BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat();
})();
const iconColor = useColorModeValue('blue.600', 'blue.300');
const timeAgo = useTimeAgoIncrement(timestamp, enableTimeIncrement);
const addressWidth = `calc((100% - ${ baseAddress ? '50px' : '0px' }) / 2)`;
......@@ -68,7 +66,7 @@ const TokenTransferListItem = ({
as={ transactionIcon }
boxSize="30px"
mr={ 2 }
color={ iconColor }
color="link"
/>
<Address width="100%">
<AddressLink
......@@ -85,7 +83,7 @@ const TokenTransferListItem = ({
<Flex w="100%" columnGap={ 3 }>
<Address width={ addressWidth }>
<AddressIcon address={ from }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } isDisabled={ baseAddress === from.hash }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ from.hash } isDisabled={ baseAddress === from.hash }/>
</Address>
{ baseAddress ?
<InOutTag isIn={ baseAddress === to.hash } isOut={ baseAddress === from.hash } w="50px" textAlign="center"/> :
......@@ -93,7 +91,7 @@ const TokenTransferListItem = ({
}
<Address width={ addressWidth }>
<AddressIcon address={ to }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ to.hash } isDisabled={ baseAddress === to.hash }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ to.hash } isDisabled={ baseAddress === to.hash }/>
</Address>
</Flex>
{ value && (
......
import { Box, Icon, Link } from '@chakra-ui/react';
import { Box, Icon, Link, chakra } from '@chakra-ui/react';
import React from 'react';
import nftPlaceholder from 'icons/nft_shield.svg';
......@@ -8,11 +8,20 @@ import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
hash: string;
id: string;
className?: string;
}
const TokenTransferNft = ({ hash, id }: Props) => {
const TokenTransferNft = ({ hash, id, className }: Props) => {
return (
<Link href={ link('token_instance_item', { hash, id }) } overflow="hidden" whiteSpace="nowrap" display="flex" alignItems="center" w="100%">
<Link
href={ link('token_instance_item', { hash, id }) }
overflow="hidden"
whiteSpace="nowrap"
display="flex"
alignItems="center"
w="100%"
className={ className }
>
<Icon as={ nftPlaceholder } boxSize="30px" mr={ 1 } color="inherit"/>
<Box maxW="calc(100% - 34px)">
<HashStringShortenDynamic hash={ id } fontWeight={ 500 }/>
......@@ -21,4 +30,4 @@ const TokenTransferNft = ({ hash, id }: Props) => {
);
};
export default React.memo(TokenTransferNft);
export default React.memo(chakra(TokenTransferNft));
......@@ -70,7 +70,7 @@ const TokenTransferTableItem = ({
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ from }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 } isDisabled={ baseAddress === from.hash }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 } isDisabled={ baseAddress === from.hash }/>
</Address>
</Td>
{ baseAddress && (
......@@ -81,7 +81,7 @@ const TokenTransferTableItem = ({
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ to }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ to.hash } alias={ to.name } flexGrow={ 1 } isDisabled={ baseAddress === to.hash }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ to.hash } alias={ to.name } flexGrow={ 1 } isDisabled={ baseAddress === to.hash }/>
</Address>
</Td>
<Td isNumeric verticalAlign="top" lineHeight="30px">
......
import type { TokenType } from 'types/api/tokenInfo';
import type { TokenTransfer } from 'types/api/tokenTransfer';
export const flattenTotal = (result: Array<TokenTransfer>, item: TokenTransfer): Array<TokenTransfer> => {
......@@ -25,9 +24,3 @@ export const getTokenTransferTypeText = (type: TokenTransfer['type']) => {
return 'Token transfer';
}
};
export const TOKEN_TYPE: Array<{ title: string; id: TokenType }> = [
{ title: 'ERC-20', id: 'ERC-20' },
{ title: 'ERC-721', id: 'ERC-721' },
{ title: 'ERC-1155', id: 'ERC-1155' },
];
......@@ -22,6 +22,9 @@ const AddressContractIcon = ({ className }: Props) => {
alignItems="center"
justifyContent="center"
fontWeight="700"
transitionProperty="background-color,color"
transitionDuration="normal"
transitionTimingFunction="ease"
>
С
</Box>
......
......@@ -7,7 +7,7 @@ import type { AddressParam } from 'types/api/addressParams';
import AddressContractIcon from 'ui/shared/address/AddressContractIcon';
type Props = {
address: Pick<AddressParam, 'is_contract' | 'hash' | 'implementation_name'>;
address: Pick<AddressParam, 'hash' | 'is_contract' | 'implementation_name'>;
className?: string;
}
......
......@@ -7,49 +7,76 @@ import link from 'lib/link/link';
import HashStringShorten from 'ui/shared/HashStringShorten';
import HashStringShortenDynamic from 'ui/shared/HashStringShortenDynamic';
interface Props {
type?: 'address' | 'transaction' | 'token' | 'block' | 'token_instance_item';
alias?: string | null;
import TruncatedTextTooltip from '../TruncatedTextTooltip';
type CommonProps = {
className?: string;
hash: string;
truncation?: 'constant' | 'dynamic'| 'none';
fontWeight?: string;
id?: string;
target?: HTMLAttributeAnchorTarget;
isDisabled?: boolean;
fontWeight?: string;
alias?: string | null;
}
type AddressTokenTxProps = {
type: 'address' | 'token' | 'transaction';
hash: 'hash';
}
const AddressLink = ({ alias, type, className, truncation = 'dynamic', hash, id, fontWeight, target = '_self', isDisabled }: Props) => {
type BlockProps = {
type: 'block';
hash: string;
id: string;
}
type AddressTokenProps = {
type: 'address_token';
hash: string;
tokenHash: string;
}
type Props = CommonProps & (AddressTokenTxProps | BlockProps | AddressTokenProps);
const AddressLink = (props: Props) => {
const { alias, type, className, truncation = 'dynamic', hash, fontWeight, target = '_self', isDisabled } = props;
const isMobile = useIsMobile();
let url;
if (type === 'transaction') {
url = link('tx', { id: id || hash });
url = link('tx', { id: hash });
} else if (type === 'token') {
url = link('token_index', { hash: id || hash });
} else if (type === 'token_instance_item') {
url = link('token_instance_item', { hash, id });
url = link('token_index', { hash: hash });
} else if (type === 'block') {
url = link('block', { id: id || hash });
url = link('block', { id: props.id });
} else if (type === 'address_token') {
url = link('address_index', { id: hash }, { tab: 'token_transfers', token: props.tokenHash, scroll_to_tabs: 'true' });
} else {
url = link('address_index', { id: id || hash });
url = link('address_index', { id: hash });
}
const content = (() => {
if (alias) {
const text = <Box overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ alias }</Box>;
if (type === 'token') {
return (
<TruncatedTextTooltip label={ alias }>
{ text }
</TruncatedTextTooltip>
);
}
return (
<Tooltip label={ hash } isDisabled={ isMobile }>
<Box overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">{ alias }</Box>
{ text }
</Tooltip>
);
}
switch (truncation) {
case 'constant':
return <HashStringShorten hash={ id || hash } isTooltipDisabled={ isMobile }/>;
return <HashStringShorten hash={ hash } isTooltipDisabled={ isMobile }/>;
case 'dynamic':
return <HashStringShortenDynamic hash={ id || hash } fontWeight={ fontWeight } isTooltipDisabled={ isMobile }/>;
return <HashStringShortenDynamic hash={ hash } fontWeight={ fontWeight } isTooltipDisabled={ isMobile }/>;
case 'none':
return <span>{ id || hash }</span>;
return <span>{ hash }</span>;
}
})();
......
import { useColorModeValue, useToken } from '@chakra-ui/react';
import { useToken } from '@chakra-ui/react';
import * as d3 from 'd3';
import React from 'react';
......@@ -13,8 +13,7 @@ interface Props extends Omit<React.SVGProps<SVGGElement>, 'scale'> {
const ChartGridLine = ({ type, scale, ticks, size, disableAnimation, ...props }: Props) => {
const ref = React.useRef<SVGGElement>(null);
const strokeColorToken = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const strokeColor = useToken('colors', strokeColorToken);
const strokeColor = useToken('colors', 'divider');
React.useEffect(() => {
if (!ref.current) {
......
......@@ -18,10 +18,10 @@ import React, { useRef, useCallback, useState } from 'react';
import type { TimeChartItem } from './types';
import imageIcon from 'icons/image.svg';
import svgFileIcon from 'icons/files/csv.svg';
import imageIcon from 'icons/files/image.svg';
import repeatArrowIcon from 'icons/repeat_arrow.svg';
import scopeIcon from 'icons/scope.svg';
import svgFileIcon from 'icons/svg_file.svg';
import dotsIcon from 'icons/vertical_dots.svg';
import dayjs from 'lib/date/dayjs';
import { apos } from 'lib/html-entities';
......@@ -190,7 +190,7 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight, isErro
alignItems="center"
onClick={ showChartFullscreen }
>
<Icon as={ scopeIcon } boxSize={ 4 } mr={ 3 }/>
<Icon as={ scopeIcon } boxSize={ 5 } mr={ 3 }/>
View fullscreen
</MenuItem>
......@@ -199,7 +199,7 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight, isErro
alignItems="center"
onClick={ handleFileSaveClick }
>
<Icon as={ imageIcon } boxSize={ 4 } mr={ 3 }/>
<Icon as={ imageIcon } boxSize={ 5 } mr={ 3 }/>
Save as PNG
</MenuItem>
......@@ -208,7 +208,7 @@ const ChartWidget = ({ items, title, description, isLoading, chartHeight, isErro
alignItems="center"
onClick={ handleSVGSavingClick }
>
<Icon as={ svgFileIcon } boxSize={ 4 } mr={ 3 }/>
<Icon as={ svgFileIcon } boxSize={ 5 } mr={ 3 }/>
Save as CSV
</MenuItem>
</MenuList>
......
......@@ -10,10 +10,11 @@ type Props = {
className?: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
placeholder: string;
initialValue?: string;
}
const FilterInput = ({ onChange, className, size = 'sm', placeholder }: Props) => {
const [ filterQuery, setFilterQuery ] = useState('');
const FilterInput = ({ onChange, className, size = 'sm', placeholder, initialValue }: Props) => {
const [ filterQuery, setFilterQuery ] = useState(initialValue || '');
const inputRef = React.useRef<HTMLInputElement>(null);
const iconColor = useColorModeValue('blackAlpha.600', 'whiteAlpha.600');
......
import type { PopoverContentProps } from '@chakra-ui/react';
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import FilterButton from 'ui/shared/filters/FilterButton';
interface Props {
appliedFiltersNum?: number;
isActive?: boolean;
children: React.ReactNode;
contentProps?: PopoverContentProps;
}
const PopoverFilter = ({ appliedFiltersNum, children, contentProps, isActive }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<FilterButton
isActive={ isOpen || isActive || Number(appliedFiltersNum) > 0 }
onClick={ onToggle }
appliedFiltersNum={ appliedFiltersNum }
/>
</PopoverTrigger>
<PopoverContent { ...contentProps }>
<PopoverBody px={ 4 } py={ 6 } display="flex" flexDir="column" rowGap={ 5 }>
{ children }
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(PopoverFilter);
import { CheckboxGroup, Checkbox, Text } from '@chakra-ui/react';
import React from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import TOKEN_TYPE from 'lib/token/tokenTypes';
type Props = {
onChange: (nextValue: Array<TokenType>) => void;
defaultValue?: Array<TokenType>;
}
const TokenTypeFilter = ({ onChange, defaultValue }: Props) => {
return (
<CheckboxGroup size="lg" onChange={ onChange } defaultValue={ defaultValue }>
{ TOKEN_TYPE.map(({ title, id }) => (
<Checkbox key={ id } value={ id }>
<Text fontSize="md">{ title }</Text>
</Checkbox>
)) }
</CheckboxGroup>
);
};
export default TokenTypeFilter;
......@@ -159,7 +159,7 @@ const LogDecodedInputData = ({ data }: Props) => {
<TableRow key={ name } name={ name } type={ type } isLast={ index === data.parameters.length - 1 } indexed={ indexed }>
{ type === 'address' ? (
<Address justifyContent="space-between">
<AddressLink hash={ value }/>
<AddressLink type="address" hash={ value }/>
<CopyToClipboard text={ value }/>
</Address>
) : (
......
......@@ -53,7 +53,7 @@ const TxLogItem = ({ address, index, topics, data, decoded, type }: Props) => {
<GridItem display="flex" alignItems="center">
<Address mr={{ base: 9, lg: 0 }}>
<AddressIcon address={ address }/>
<AddressLink hash={ address.hash } alias={ address.name } ml={ 2 }/>
<AddressLink type="address" hash={ address.hash } alias={ address.name } ml={ 2 }/>
</Address>
{ /* api doesn't have find topic feature yet */ }
{ /* <Tooltip label="Find matches topic">
......
......@@ -51,7 +51,7 @@ const LogTopic = ({ hex, index }: Props) => {
case 'address': {
return (
<Address>
<AddressLink hash={ value }/>
<AddressLink type="address" hash={ value }/>
<CopyToClipboard text={ value }/>
</Address>
);
......@@ -70,7 +70,6 @@ const LogTopic = ({ hex, index }: Props) => {
borderRadius="base"
value={ selectedDataType }
onChange={ handleSelectChange }
focusBorderColor="none"
mr={ 3 }
flexShrink={ 0 }
w="auto"
......
import { Box, Flex, Skeleton, SkeletonCircle, useColorModeValue } from '@chakra-ui/react';
import { Box, Flex, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import React from 'react';
const SkeletonList = () => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Box>
{ Array.from(Array(2)).map((item, index) => (
......@@ -13,7 +11,7 @@ const SkeletonList = () => {
flexDirection="column"
paddingY={ 6 }
borderTopWidth="1px"
borderColor={ borderColor }
borderColor="divider"
_last={{
borderBottomWidth: '0px',
}}
......
import { Box, Flex, Skeleton, SkeletonCircle, useColorModeValue } from '@chakra-ui/react';
import { Box, Flex, Skeleton, SkeletonCircle } from '@chakra-ui/react';
import React from 'react';
interface Props {
......@@ -6,8 +6,6 @@ interface Props {
}
const SkeletonListAccount = ({ showFooterSlot }: Props) => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Box>
{ Array.from(Array(2)).map((item, index) => (
......@@ -17,7 +15,7 @@ const SkeletonListAccount = ({ showFooterSlot }: Props) => {
flexDirection="column"
paddingY={ 6 }
borderTopWidth="1px"
borderColor={ borderColor }
borderColor="divider"
_last={{
borderBottomWidth: '0px',
}}
......
import { Box, VStack, Text, Stack, Icon, Link, useColorModeValue } from '@chakra-ui/react';
import { Box, VStack, Text, Stack, Icon, Link } from '@chakra-ui/react';
import React from 'react';
import appConfig from 'configs/app/config';
......@@ -37,7 +37,7 @@ const NavFooter = ({ isCollapsed, hasAccount }: Props) => {
as="footer"
spacing={ 8 }
borderTop="1px solid"
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderColor="divider"
width={{ base: '100%', lg: isExpanded ? '180px' : '20px', xl: isCollapsed ? '20px' : '180px' }}
paddingTop={{ base: 6, lg: 8 }}
marginTop={ marginTop }
......
......@@ -38,11 +38,10 @@ const NavigationDesktop = () => {
cookies.set(cookies.NAMES.NAV_BAR_COLLAPSED, isCollapsed ? 'false' : 'true');
}, [ isCollapsed ]);
const containerBorderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const chevronIconStyles = {
bgColor: useColorModeValue('white', 'black'),
color: useColorModeValue('blackAlpha.400', 'whiteAlpha.400'),
borderColor: useColorModeValue('blackAlpha.200', 'whiteAlpha.200'),
borderColor: 'divider',
};
const isExpanded = isCollapsed === false;
......@@ -54,7 +53,7 @@ const NavigationDesktop = () => {
flexDirection="column"
alignItems="flex-start"
borderRight="1px solid"
borderColor={ containerBorderColor }
borderColor="divider"
px={{ lg: isExpanded ? 6 : 4, xl: isCollapsed ? 4 : 6 }}
py={ 12 }
width={{ lg: isExpanded ? '229px' : '92px', xl: isCollapsed ? '92px' : '229px' }}
......@@ -94,7 +93,7 @@ const NavigationDesktop = () => {
width={ 6 }
height={ 6 }
border="1px"
_hover={{ color: 'blue.400' }}
_hover={{ color: 'link_hovered' }}
borderRadius="base"
{ ...chevronIconStyles }
transform={{ lg: isExpanded ? 'rotate(0)' : 'rotate(180deg)', xl: isCollapsed ? 'rotate(180deg)' : 'rotate(0)' }}
......
......@@ -5,14 +5,14 @@ export default function useColors() {
text: {
'default': useColorModeValue('gray.600', 'gray.400'),
active: useColorModeValue('blue.700', 'gray.50'),
hover: 'blue.400',
hover: 'link_hovered',
},
bg: {
'default': 'transparent',
active: useColorModeValue('blue.50', 'gray.800'),
},
border: {
'default': useColorModeValue('blackAlpha.200', 'whiteAlpha.200'),
'default': 'divider',
active: useColorModeValue('blue.50', 'gray.800'),
},
};
......
......@@ -37,7 +37,7 @@ const NetworkMenuButton = ({ isMobile, isActive, onClick, className }: Props, re
height="36px"
padding="10px"
color={ isActive ? iconColorMobile : defaultIconColor }
_hover={{ color: isMobile ? undefined : 'blue.400' }}
_hover={{ color: isMobile ? undefined : 'link_hovered' }}
cursor="pointer"
{ ...getDefaultTransitionProps({ transitionProperty: 'margin' }) }
/>
......
......@@ -8,7 +8,7 @@ export default function useColors({ hasIcon }: {hasIcon: boolean}) {
text: {
'default': useColorModeValue('gray.600', 'gray.400'),
active: useColorModeValue('blackAlpha.900', 'whiteAlpha.900'),
hover: useColorModeValue('blue.600', 'blue.400'),
hover: useColorModeValue('blue.600', 'link_hovered'),
},
icon: {
'default': hasIcon ? iconDefaultColor : iconPlaceholderDefaultColor,
......@@ -19,7 +19,7 @@ export default function useColors({ hasIcon }: {hasIcon: boolean}) {
active: useColorModeValue('blue.50', 'gray.800'),
},
border: {
'default': useColorModeValue('blackAlpha.200', 'whiteAlpha.200'),
'default': 'divider',
active: useColorModeValue('blue.50', 'gray.800'),
},
};
......
......@@ -12,7 +12,6 @@ type Props = UserInfo;
const ProfileMenuContent = ({ name, nickname, email }: Props) => {
const { accountNavItems, profileItem } = useNavItems();
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const primaryTextColor = useColorModeValue('blackAlpha.800', 'whiteAlpha.800');
return (
......@@ -35,12 +34,12 @@ const ProfileMenuContent = ({ name, nickname, email }: Props) => {
{ email }
</Text>
<NavLink { ...profileItem } isActive={ undefined } px="0px" isCollapsed={ false }/>
<Box as="nav" mt={ 2 } pt={ 2 } borderTopColor={ borderColor } borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<Box as="nav" mt={ 2 } pt={ 2 } borderTopColor="divider" borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<VStack as="ul" spacing="0" alignItems="flex-start" overflow="hidden">
{ accountNavItems.map((item) => <NavLink key={ item.text } { ...item } isActive={ undefined } isCollapsed={ false } px="0px"/>) }
</VStack>
</Box>
<Box mt={ 2 } pt={ 3 } borderTopColor={ borderColor } borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<Box mt={ 2 } pt={ 3 } borderTopColor="divider" borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<Button size="sm" width="full" variant="outline" as="a" href={ appConfig.logoutUrl }>Sign Out</Button>
</Box>
</Box>
......
......@@ -79,8 +79,9 @@ const SearchBarInput = ({ onChange, onSubmit, isHomepage, onFocus, onBlur, onHid
paddingBottom={{ base: isHomepage ? 0 : 4, lg: 0 }}
boxShadow={ scrollDirection !== 'down' && isSticky ? 'md' : 'none' }
transform={{ base: isHomepage ? 'none' : transformMobile, lg: 'none' }}
transitionProperty="transform,box-shadow"
transitionDuration="slow"
transitionProperty="transform,box-shadow,background-color,color,border-color"
transitionDuration="normal"
transitionTimingFunction="ease"
>
<InputGroup size={{ base: isHomepage ? 'md' : 'sm', lg: 'md' }}>
<InputLeftElement w={{ base: isHomepage ? 6 : 4, lg: 6 }} ml={{ base: isHomepage ? 4 : 3, lg: 4 }} h="100%">
......
import { Text, useColorModeValue } from '@chakra-ui/react';
import { Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import React from 'react';
......@@ -40,11 +40,10 @@ const SearchBarSuggest = ({ query, searchTerm }: Props) => {
</>
);
})();
const dividerColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<>
{ !isMobile && <TextAd pb={ 4 } mb={ 5 } borderColor={ dividerColor } borderBottomWidth="1px"/> }
{ !isMobile && <TextAd pb={ 4 } mb={ 5 } borderColor="divider" borderBottomWidth="1px"/> }
{ content }
</>
);
......
......@@ -148,7 +148,7 @@ const SearchBarSuggestItem = ({ data, isMobile, searchTerm }: Props) => {
display="flex"
flexDir="column"
rowGap={ 2 }
borderColor={ useColorModeValue('blackAlpha.200', 'whiteAlpha.200') }
borderColor="divider"
borderBottomWidth="1px"
_last={{
borderBottomWidth: '0',
......
......@@ -4,7 +4,7 @@ import React from 'react';
import type { StatsChartsSection } from 'types/api/stats';
import type { StatsInterval, StatsIntervalIds } from 'types/client/stats';
import FilterInput from 'ui/shared/FilterInput';
import FilterInput from 'ui/shared/filters/FilterInput';
import { STATS_INTERVALS } from './constants';
import StatsDropdownMenu from './StatsDropdownMenu';
......
......@@ -48,7 +48,7 @@ const TokenContractInfo = ({ tokenQuery }: Props) => {
return (
<Flex alignItems="center">
<AddressContractIcon/>
<AddressLink hash={ hash } ml={ 2 } truncation={ isMobile ? 'constant' : 'none' }/>
<AddressLink type="address" hash={ hash } ml={ 2 } truncation={ isMobile ? 'constant' : 'none' }/>
<CopyToClipboard text={ hash } ml={ 1 }/>
{ contractQuery.data?.token && <AddressAddToMetaMask token={ contractQuery.data?.token } ml={ 2 }/> }
<AddressQrCode hash={ hash } ml={ 2 }/>
......
......@@ -22,7 +22,7 @@ const TokenHoldersListItem = ({ holder, token }: Props) => {
<ListItemMobile rowGap={ 3 }>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ holder.address }/>
<AddressLink ml={ 2 } fontWeight="700" hash={ holder.address.hash } alias={ holder.address.name } flexGrow={ 1 }/>
<AddressLink type="address" ml={ 2 } fontWeight="700" hash={ holder.address.hash } alias={ holder.address.name } flexGrow={ 1 }/>
</Address>
<Flex justifyContent="space-between" alignItems="center" width="100%">
{ quantity }
......
......@@ -22,7 +22,7 @@ const TokenTransferTableItem = ({ holder, token }: Props) => {
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ holder.address }/>
<AddressLink ml={ 2 } fontWeight="700" hash={ holder.address.hash } alias={ holder.address.name } flexGrow={ 1 }/>
<AddressLink type="address" ml={ 2 } fontWeight="700" hash={ holder.address.hash } alias={ holder.address.name } flexGrow={ 1 }/>
</Address>
</Td>
<Td isNumeric>
......
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import { tokenInfo } from 'mocks/tokens/tokenInfo';
import * as tokenTransferMock from 'mocks/tokens/tokenTransfer';
import TestApp from 'playwright/TestApp';
import TokenTransfer from './TokenTransfer';
test('erc20 +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: '100px' }}/>
<TokenTransfer
token={ tokenInfo }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
transfersQuery={{
data: {
items: [ tokenTransferMock.erc20 ],
next_page_params: null,
},
isPaginationVisible: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
pagination: { page: 1 },
}}
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('erc721 +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: '100px' }}/>
<TokenTransfer
token={{ ...tokenInfo, type: 'ERC-721' }}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
transfersQuery={{
data: {
items: [ tokenTransferMock.erc721 ],
next_page_params: null,
},
isPaginationVisible: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
pagination: { page: 1 },
}}
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('erc1155 +@mobile', async({ mount }) => {
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: '100px' }}/>
<TokenTransfer
token={{ ...tokenInfo, type: 'ERC-1155' }}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
transfersQuery={{
data: {
items: [ tokenTransferMock.erc1155, tokenTransferMock.erc1155multiple ],
next_page_params: null,
},
isPaginationVisible: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore:
pagination: { page: 1 },
}}
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Hide, Show, Text } from '@chakra-ui/react';
import type { UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/router';
import React from 'react';
import type { SocketMessage } from 'lib/socket/types';
import type { TokenInfo } from 'types/api/tokenInfo';
import type { TokenTransferResponse } from 'types/api/tokenTransfer';
import useGradualIncrement from 'lib/hooks/useGradualIncrement';
import useIsMobile from 'lib/hooks/useIsMobile';
import useSocketChannel from 'lib/socket/useSocketChannel';
import useSocketMessage from 'lib/socket/useSocketMessage';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import { flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferList from 'ui/token/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/token/TokenTransfer/TokenTransferTable';
type Props = {
token?: TokenInfo;
transfersQuery: UseQueryResult<TokenTransferResponse> & {
pagination: PaginationProps;
isPaginationVisible: boolean;
};
}
const TokenTransfer = ({ transfersQuery, token }: Props) => {
const isMobile = useIsMobile();
const router = useRouter();
const { isError, isLoading, data, pagination, isPaginationVisible } = transfersQuery;
const [ newItemsCount, setNewItemsCount ] = useGradualIncrement(0);
const [ socketAlert, setSocketAlert ] = React.useState('');
const handleNewTransfersMessage: SocketMessage.TokenTransfers['handler'] = (payload) => {
setNewItemsCount(payload.token_transfer);
};
const handleSocketClose = React.useCallback(() => {
setSocketAlert('Connection is lost. Please refresh the page to load new token transfers.');
}, []);
const handleSocketError = React.useCallback(() => {
setSocketAlert('An error has occurred while fetching new token transfers. Please refresh the page.');
}, []);
const channel = useSocketChannel({
topic: `tokens:${ router.query.hash?.toString().toLowerCase() }`,
onSocketClose: handleSocketClose,
onSocketError: handleSocketError,
isDisabled: isLoading || isError || pagination.page !== 1,
});
useSocketMessage({
channel,
event: 'token_transfer',
handler: handleNewTransfersMessage,
});
const content = (() => {
if (isLoading) {
return (
<>
<Hide below="lg">
<SkeletonTable columns={ [ '45%', '15%', '36px', '15%', '25%' ] }
/>
</Hide>
<Show below="lg">
<SkeletonList/>
</Show>
</>
);
}
if (isError) {
return <DataFetchAlert/>;
}
if (!data.items?.length) {
return <Text as="span">There are no token transfers</Text>;
}
const items = data.items.reduce(flattenTotal, []);
return (
<>
<Hide below="lg">
<TokenTransferTable
data={ items }
top={ 80 }
// token transfers query depends on token data
// so if we are here, we definitely have token data
token={ token as TokenInfo }
showSocketInfo={ pagination.page === 1 }
socketInfoAlert={ socketAlert }
socketInfoNum={ newItemsCount }
/>
</Hide>
<Show below="lg">
{ pagination.page === 1 && (
<SocketNewItemsNotice
url={ window.location.href }
num={ newItemsCount }
alert={ socketAlert }
type="token_transfer"
borderBottomRadius={ 0 }
/>
) }
<TokenTransferList data={ items }/>
</Show>
</>
);
})();
return (
<>
{ isMobile && isPaginationVisible && (
<ActionBar mt={ -6 }>
<Pagination ml="auto" { ...pagination }/>
</ActionBar>
) }
{ content }
</>
);
};
export default React.memo(TokenTransfer);
import { Box } from '@chakra-ui/react';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import TokenTransferListItem from 'ui/token/TokenTransfer/TokenTransferListItem';
interface Props {
data: Array<TokenTransfer>;
}
const TokenTransferList = ({ data }: Props) => {
return (
<Box>
{ data.map((item, index) => (
<TokenTransferListItem
key={ index }
{ ...item }
/>
)) }
</Box>
);
};
export default TokenTransferList;
import { Text, Flex, Tag, Icon, useColorModeValue } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg';
import transactionIcon from 'icons/transactions.svg';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import ListItemMobile from 'ui/shared/ListItemMobile';
import TokenTransferNft from 'ui/shared/TokenTransfer/TokenTransferNft';
type Props = TokenTransfer;
const TokenTransferListItem = ({
token,
total,
tx_hash: txHash,
from,
to,
method,
timestamp,
}: Props) => {
const value = (() => {
if (!('value' in total)) {
return null;
}
return BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat();
})();
const iconColor = useColorModeValue('blue.600', 'blue.300');
const timeAgo = useTimeAgoIncrement(timestamp, true);
return (
<ListItemMobile rowGap={ 3 } isAnimated>
<Flex justifyContent="space-between" alignItems="center" lineHeight="24px" width="100%">
<Flex>
<Icon
as={ transactionIcon }
boxSize="30px"
mr={ 2 }
color={ iconColor }
/>
<Address width="100%">
<AddressLink
hash={ txHash }
type="transaction"
fontWeight="700"
truncation="constant"
/>
</Address>
</Flex>
{ timestamp && <Text variant="secondary" fontWeight="400" fontSize="sm">{ timeAgo }</Text> }
</Flex>
{ method && <Tag colorScheme="gray">{ method }</Tag> }
<Flex w="100%" columnGap={ 3 }>
<Address width="50%">
<AddressIcon address={ from }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } type="address_token" tokenHash={ token.address }/>
</Address>
<Icon as={ eastArrowIcon } boxSize={ 6 } color="gray.500"/>
<Address width="50%">
<AddressIcon address={ to }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ to.hash } type="address_token" tokenHash={ token.address }/>
</Address>
</Flex>
{ value && (
<Flex columnGap={ 2 } w="100%">
<Text fontWeight={ 500 } flexShrink={ 0 }>Value</Text>
<Text variant="secondary">{ value }</Text>
<Text>{ token.symbol }</Text>
</Flex>
) }
{ 'token_id' in total && <TokenTransferNft hash={ token.address } id={ total.token_id }/> }
</ListItemMobile>
);
};
export default React.memo(TokenTransferListItem);
import { Table, Tbody, Tr, Th, Td } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import SocketNewItemsNotice from 'ui/shared/SocketNewItemsNotice';
import { default as Thead } from 'ui/shared/TheadSticky';
import TokenTransferTableItem from 'ui/token/TokenTransfer/TokenTransferTableItem';
interface Props {
data: Array<TokenTransfer>;
top: number;
token: TokenInfo;
showSocketInfo: boolean;
socketInfoAlert?: string;
socketInfoNum?: number;
}
const TokenTransferTable = ({ data, top, token, showSocketInfo, socketInfoAlert, socketInfoNum }: Props) => {
return (
<Table variant="simple" size="sm">
<Thead top={ top }>
<Tr>
<Th width="40%">Txn hash</Th>
<Th width="164px">Method</Th>
<Th width="148px">From</Th>
<Th width="36px" px={ 0 }/>
<Th width="218px" >To</Th>
{ (token.type === 'ERC-721' || token.type === 'ERC-1155') && <Th width="20%" isNumeric={ token.type === 'ERC-721' }>Token ID</Th> }
{ (token.type === 'ERC-20' || token.type === 'ERC-1155') && <Th width="20%" isNumeric>Value { token.symbol }</Th> }
</Tr>
</Thead>
<Tbody>
{ showSocketInfo && (
<Tr>
<Td colSpan={ 10 } p={ 0 }>
<SocketNewItemsNotice
borderRadius={ 0 }
pl="10px"
url={ window.location.href }
alert={ socketInfoAlert }
num={ socketInfoNum }
type="token_transfer"
/>
</Td>
</Tr>
) }
{ data.map((item, index) => (
<TokenTransferTableItem key={ index } { ...item }/>
)) }
</Tbody>
</Table>
);
};
export default React.memo(TokenTransferTable);
import { Tr, Td, Tag, Text, Icon } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
import type { TokenTransfer } from 'types/api/tokenTransfer';
import eastArrowIcon from 'icons/arrows/east.svg';
import useTimeAgoIncrement from 'lib/hooks/useTimeAgoIncrement';
import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink';
import TokenTransferNft from 'ui/shared/TokenTransfer/TokenTransferNft';
type Props = TokenTransfer
const TokenTransferTableItem = ({
token,
total,
tx_hash: txHash,
from,
to,
method,
timestamp,
}: Props) => {
const value = (() => {
if (!('value' in total)) {
return '-';
}
return BigNumber(total.value).div(BigNumber(10 ** Number(total.decimals))).dp(8).toFormat();
})();
const timeAgo = useTimeAgoIncrement(timestamp, true);
return (
<Tr alignItems="top">
<Td>
<Address display="inline-flex" maxW="100%" fontWeight={ 600 } lineHeight="30px">
<AddressLink type="transaction" hash={ txHash }/>
</Address>
{ timestamp && <Text color="gray.500" fontWeight="400" mt="10px">{ timeAgo }</Text> }
</Td>
<Td>
{ method ? <Tag colorScheme="gray">{ method }</Tag> : '-' }
</Td>
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ from }/>
<AddressLink
ml={ 2 }
flexGrow={ 1 }
fontWeight="500"
type="address_token"
hash={ from.hash }
alias={ from.name }
tokenHash={ token.address }
truncation="constant"
/>
</Address>
</Td>
<Td px={ 0 }>
<Icon as={ eastArrowIcon } boxSize={ 6 } color="gray.500"/>
</Td>
<Td>
<Address display="inline-flex" maxW="100%" lineHeight="30px">
<AddressIcon address={ to }/>
<AddressLink
ml={ 2 }
flexGrow={ 1 }
fontWeight="500"
type="address_token"
hash={ to.hash }
alias={ to.name }
tokenHash={ token.address }
truncation="constant"
/>
</Address>
</Td>
{ (token.type === 'ERC-721' || token.type === 'ERC-1155') && (
<Td lineHeight="30px">
{ 'token_id' in total ? (
<TokenTransferNft
hash={ token.address }
id={ total.token_id }
justifyContent={ token.type === 'ERC-721' ? 'end' : 'start' }/>
) : '-'
}
</Td>
) }
{ (token.type === 'ERC-20' || token.type === 'ERC-1155') && (
<Td isNumeric verticalAlign="top" lineHeight="30px">
{ value || '-' }
</Td>
) }
</Tr>
);
};
export default React.memo(TokenTransferTableItem);
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as tokens from 'mocks/tokens/tokenInfo';
import TestApp from 'playwright/TestApp';
import buildApiUrl from 'playwright/utils/buildApiUrl';
import Tokens from './Tokens';
const API_URL_TOKENS = buildApiUrl('tokens');
const tokensResponse = {
items: [
tokens.tokenInfoERC20a, tokens.tokenInfoERC20b, tokens.tokenInfoERC20c, tokens.tokenInfoERC20d,
tokens.tokenInfoERC721a, tokens.tokenInfoERC721b, tokens.tokenInfoERC721c,
tokens.tokenInfoERC1155a, tokens.tokenInfoERC1155b, tokens.tokenInfoERC1155WithoutName,
],
next_page_params: {
holder_count: 1,
items_count: 1,
name: 'a',
},
};
test('base view +@mobile +@dark-mode', async({ mount, page }) => {
await page.route(API_URL_TOKENS, (route) => route.fulfill({
status: 200,
body: JSON.stringify(tokensResponse),
}));
const component = await mount(
<TestApp>
<Box h={{ base: '134px', lg: 6 }}/>
<Tokens/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import { Hide, HStack, Show, Text } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import type { TokenType } from 'types/api/tokenInfo';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useDebounce from 'lib/hooks/useDebounce';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import TOKEN_TYPE from 'lib/token/tokenTypes';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import FilterInput from 'ui/shared/filters/FilterInput';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import TokenTypeFilter from 'ui/shared/filters/TokenTypeFilter';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import TokensListItem from './TokensListItem';
import TokensTable from './TokensTable';
const TOKEN_TYPES = TOKEN_TYPE.map(i => i.id);
const getTokenFilterValue = (getFilterValuesFromQuery<TokenType>).bind(null, TOKEN_TYPES);
const Tokens = () => {
const router = useRouter();
const [ filter, setFilter ] = React.useState<string>(router.query.filter?.toString() || '');
const [ type, setType ] = React.useState<Array<TokenType> | undefined>(getTokenFilterValue(router.query.type));
const debouncedFilter = useDebounce(filter, 300);
const { isError, isLoading, data, isPaginationVisible, pagination, onFilterChange } = useQueryWithPages({
resourceName: 'tokens',
filters: { filter: debouncedFilter, type },
});
const onSearchChange = useCallback((value: string) => {
onFilterChange({ filter: value, type });
setFilter(value);
}, [ type, onFilterChange ]);
const onTypeChange = useCallback((value: Array<TokenType>) => {
onFilterChange({ filter: debouncedFilter, type: value });
setType(value);
}, [ debouncedFilter, onFilterChange ]);
if (isError) {
return <DataFetchAlert/>;
}
const typeFilter = (
<PopoverFilter isActive={ type && type.length > 0 } contentProps={{ w: '200px' }}>
<TokenTypeFilter onChange={ onTypeChange } defaultValue={ type }/>
</PopoverFilter>
);
const filterInput = (
<FilterInput
w="100%"
size="xs"
onChange={ onSearchChange }
placeholder="Token name or symbol"
initialValue={ filter }
/>
);
const bar = (
<>
<Show below="lg">
<HStack spacing={ 3 } mb={ 6 }>
{ typeFilter }
{ filterInput }
</HStack>
</Show>
<ActionBar mt={ -6 }>
<Hide below="lg">
<HStack spacing={ 3 }>
{ typeFilter }
{ filterInput }
</HStack>
</Hide>
{ isPaginationVisible && <Pagination ml="auto" { ...pagination }/> }
</ActionBar>
</>
);
if (isLoading) {
return (
<>
{ bar }
<Show below="lg"><SkeletonList/></Show>
<Hide below="lg">
<SkeletonTable columns={ [ '25px', '33%', '33%', '33%', '110px' ] }/>
</Hide>
</>
);
}
if (!data.items.length) {
if (debouncedFilter) {
return (
<>
{ bar }
<EmptySearchResult text={ `Couldn${ apos }t find token that matches your filter query.` }/>;
</>
);
}
return <Text as="span">There are no tokens</Text>;
}
return (
<>
{ bar }
<Show below="lg" ssr={ false }>
{ data.items.map((item, index) => <TokensListItem key={ item.address } token={ item } index={ index } page={ pagination.page }/>) }
</Show>
<Hide below="lg" ssr={ false }><TokensTable items={ data.items } page={ pagination.page }/></Hide>
</>
);
};
export default Tokens;
import { Flex, Text, Tag, HStack, Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import ListItemMobile from 'ui/shared/ListItemMobile';
import TokenLogo from 'ui/shared/TokenLogo';
type Props = {
token: TokenInfo;
index: number;
page: number;
}
const PAGE_SIZE = 50;
const TokensTableItem = ({
token,
page,
index,
}: Props) => {
const {
address,
total_supply: totalSupply,
exchange_rate: exchangeRate,
type,
name,
symbol,
decimals,
holders,
} = token;
const totalValue = totalSupply !== null ? getCurrencyValue({ value: totalSupply, accuracy: 3, accuracyUsd: 2, exchangeRate, decimals }) : undefined;
const tokenString = [ name, symbol && `(${ symbol })` ].filter(Boolean).join(' ');
return (
<ListItemMobile rowGap={ 3 }>
<Grid
width="100%"
gridTemplateColumns="minmax(0, 1fr)"
>
<GridItem display="flex">
<Flex overflow="hidden" mr={ 3 } alignItems="center">
<TokenLogo hash={ address } name={ name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
<Tag flexShrink={ 0 } ml={ 3 }>{ type }</Tag>
</Flex>
<Text fontSize="sm" ml="auto" variant="secondary">{ (page - 1) * PAGE_SIZE + index + 1 }</Text>
</GridItem>
</Grid>
<Flex justifyContent="space-between" alignItems="center" width="100%">
<Flex alignItems="center" width="136px" justifyContent="space-between" ml={ 8 } mt="-8px">
<Flex alignItems="center">
<AddressLink fontSize="sm" hash={ address } type="address" truncation="constant"/>
<CopyToClipboard text={ address } ml={ 1 }/>
</Flex>
<AddressAddToMetaMask token={ token }/>
</Flex>
</Flex>
{ exchangeRate && (
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Price</Text>
<Text fontSize="sm" variant="secondary">{ exchangeRate || '-' }</Text>
</HStack>
) }
{ totalValue?.usd && (
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>On-chain market cap</Text>
<Text fontSize="sm" variant="secondary">{ totalValue.usd }</Text>
</HStack>
) }
<HStack spacing={ 3 }>
<Text fontSize="sm" fontWeight={ 500 }>Holders</Text>
<Text fontSize="sm" variant="secondary">{ holders }</Text>
</HStack>
</ListItemMobile>
);
};
export default TokensTableItem;
import { Table, Tbody, Th, Tr } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo';
import { default as Thead } from 'ui/shared/TheadSticky';
import TokensTableItem from './TokensTableItem';
type Props = {
items: Array<TokenInfo>;
page: number;
}
const TokensTable = ({ items, page }: Props) => {
return (
<Table style={{ tableLayout: 'auto' }}>
<Thead top={ 80 }>
<Tr>
<Th>Token</Th>
<Th isNumeric>Price</Th>
<Th isNumeric>On-chain market cap</Th>
<Th isNumeric>Holders</Th>
</Tr>
</Thead>
<Tbody>
{ items.map((item, index) => (
<TokensTableItem key={ item.address } token={ item } index={ index } page={ page }/>
)) }
</Tbody>
</Table>
);
};
export default TokensTable;
import { Box, Flex, Td, Tr, Text, Tag } from '@chakra-ui/react';
import React from 'react';
import type { TokenInfo } from 'types/api/tokenInfo';
import getCurrencyValue from 'lib/getCurrencyValue';
import AddressAddToMetaMask from 'ui/address/details/AddressAddToMetaMask';
import AddressLink from 'ui/shared/address/AddressLink';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
import TokenLogo from 'ui/shared/TokenLogo';
type Props = {
token: TokenInfo;
index: number;
page: number;
}
const PAGE_SIZE = 50;
const TokensTableItem = ({
token,
page,
index,
}: Props) => {
const {
address,
total_supply: totalSupply,
exchange_rate: exchangeRate,
type,
name,
symbol,
decimals,
holders,
} = token;
const totalValue = totalSupply !== null ? getCurrencyValue({ value: totalSupply, accuracy: 3, accuracyUsd: 2, exchangeRate, decimals }) : undefined;
const tokenString = [ name, symbol && `(${ symbol })` ].filter(Boolean).join(' ');
return (
<Tr>
<Td>
<Flex>
<Text
fontSize="sm"
lineHeight="24px"
fontWeight={ 600 }
mr={ 3 }
minW="28px"
>
{ (page - 1) * PAGE_SIZE + index + 1 }
</Text>
<Box>
<Flex alignItems="center">
<TokenLogo hash={ address } name={ name } boxSize={ 6 } mr={ 2 }/>
<AddressLink fontSize="sm" fontWeight="700" hash={ address } type="token" alias={ tokenString }/>
</Flex>
<Flex alignItems="center" width="136px" justifyContent="space-between" ml={ 8 } mt={ 2 }>
<Flex alignItems="center">
<AddressLink fontSize="sm" hash={ address } type="address" truncation="constant" fontWeight={ 500 }/>
<CopyToClipboard text={ address } ml={ 1 }/>
</Flex>
<AddressAddToMetaMask token={ token }/>
</Flex>
<Tag flexShrink={ 0 } ml={ 8 } mt={ 3 }>{ type }</Tag>
</Box>
</Flex>
</Td>
<Td isNumeric><Text fontSize="sm" lineHeight="24px" fontWeight={ 500 }>{ exchangeRate ? `$${ exchangeRate }` : '-' }</Text></Td>
<Td isNumeric maxWidth="300px" width="300px">
<Text fontSize="sm" lineHeight="24px" fontWeight={ 500 }>{ totalValue?.usd ? `$${ totalValue.usd }` : '-' }</Text>
</Td>
<Td isNumeric><Text fontSize="sm" lineHeight="24px" fontWeight={ 500 }>{ holders }</Text></Td>
</Tr>
);
};
export default TokensTableItem;
......@@ -10,7 +10,6 @@ import {
Flex,
Tooltip,
chakra,
useColorModeValue,
} from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
......@@ -51,8 +50,6 @@ const TxDetails = () => {
const isMobile = useIsMobile();
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const [ isExpanded, setIsExpanded ] = React.useState(false);
const handleCutClick = React.useCallback(() => {
......@@ -188,7 +185,7 @@ const TxDetails = () => {
mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }}
borderBottom="1px solid"
borderColor={ borderColor }
borderColor="divider"
/>
<DetailsInfoItem
title="From"
......@@ -197,7 +194,7 @@ const TxDetails = () => {
>
<Address>
<AddressIcon address={ data.from }/>
<AddressLink ml={ 2 } hash={ data.from.hash }/>
<AddressLink type="address" ml={ 2 } hash={ data.from.hash }/>
<CopyToClipboard text={ data.from.hash }/>
</Address>
{ data.from.name && <Text>{ data.from.name }</Text> }
......@@ -216,7 +213,7 @@ const TxDetails = () => {
{ data.to && data.to.hash ? (
<Address alignItems="center">
<AddressIcon address={ toAddress }/>
<AddressLink ml={ 2 } hash={ toAddress.hash }/>
<AddressLink type="address" ml={ 2 } hash={ toAddress.hash }/>
{ executionSuccessBadge }
{ executionFailedBadge }
<CopyToClipboard text={ toAddress.hash }/>
......@@ -224,7 +221,7 @@ const TxDetails = () => {
) : (
<Flex width={{ base: '100%', lg: 'auto' }} whiteSpace="pre" alignItems="center">
<span>[Contract </span>
<AddressLink hash={ toAddress.hash }/>
<AddressLink type="address" hash={ toAddress.hash }/>
<span> created]</span>
{ executionSuccessBadge }
{ executionFailedBadge }
......@@ -245,7 +242,7 @@ const TxDetails = () => {
mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }}
borderBottom="1px solid"
borderColor={ borderColor }
borderColor="divider"
/>
<DetailsInfoItem
title="Value"
......
......@@ -10,7 +10,7 @@ import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
// import FilterInput from 'ui/shared/FilterInput';
// import FilterInput from 'ui/shared/filters/FilterInput';
// import TxInternalsFilter from 'ui/tx/internals/TxInternalsFilter';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
......
......@@ -8,13 +8,14 @@ import { SECOND } from 'lib/consts';
import getFilterValuesFromQuery from 'lib/getFilterValuesFromQuery';
import useQueryWithPages from 'lib/hooks/useQueryWithPages';
import { apos } from 'lib/html-entities';
import TOKEN_TYPE from 'lib/token/tokenTypes';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import ActionBar from 'ui/shared/ActionBar';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Pagination from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
import SkeletonTable from 'ui/shared/skeletons/SkeletonTable';
import { TOKEN_TYPE, flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import { flattenTotal } from 'ui/shared/TokenTransfer/helpers';
import TokenTransferFilter from 'ui/shared/TokenTransfer/TokenTransferFilter';
import TokenTransferList from 'ui/shared/TokenTransfer/TokenTransferList';
import TokenTransferTable from 'ui/shared/TokenTransfer/TokenTransferTable';
......
import { Grid, GridItem, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { Grid, GridItem, Skeleton } from '@chakra-ui/react';
import React from 'react';
import DetailsSkeletonRow from 'ui/shared/skeletons/DetailsSkeletonRow';
const TxDetailsSkeleton = () => {
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const sectionGap = (
<GridItem
colSpan={{ base: undefined, lg: 2 }}
mt={{ base: 2, lg: 3 }}
mb={{ base: 0, lg: 3 }}
borderBottom="1px solid"
borderColor={ borderColor }
borderColor="divider"
/>
);
......
......@@ -77,9 +77,9 @@ const TxDetailsTokenTransfer = ({ token, total, to, from }: Props) => {
flexDir={ isColumnLayout ? 'column' : 'row' }
>
<Flex alignItems="center">
<AddressLink fontWeight="500" hash={ from.hash } truncation="constant"/>
<AddressLink type="address" fontWeight="500" hash={ from.hash } truncation="constant"/>
<Icon as={ rightArrowIcon } boxSize={ 6 } mx={ 2 } color="gray.500"/>
<AddressLink fontWeight="500" hash={ to.hash } truncation="constant"/>
<AddressLink type="address" fontWeight="500" hash={ to.hash } truncation="constant"/>
</Flex>
<Flex flexDir="column" rowGap={ 5 }>
{ content }
......
......@@ -12,6 +12,7 @@ const TxRevertReason = (props: Props) => {
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
if ('raw' in props) {
const decoded = hexToUtf8(props.raw);
return (
<Grid
bgColor={ bgColor }
......@@ -25,8 +26,12 @@ const TxRevertReason = (props: Props) => {
>
<GridItem fontWeight={ 500 }>Raw:</GridItem>
<GridItem>{ props.raw }</GridItem>
<GridItem fontWeight={ 500 }>Decoded:</GridItem>
<GridItem>{ hexToUtf8(props.raw) }</GridItem>
{ decoded.replace(/\s|\0/g, '') && (
<>
<GridItem fontWeight={ 500 }>Decoded:</GridItem>
<GridItem>{ decoded }</GridItem>
</>
) }
</Grid>
);
}
......
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, CheckboxGroup, Checkbox, Text, useDisclosure } from '@chakra-ui/react';
import { CheckboxGroup, Checkbox, Text } from '@chakra-ui/react';
import React from 'react';
import type { TxInternalsType } from 'types/api/internalTransaction';
import FilterButton from 'ui/shared/FilterButton';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
interface Props {
......@@ -13,25 +13,12 @@ interface Props {
}
const TxInternalsFilter = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<FilterButton
isActive={ isOpen || Number(appliedFiltersNum) > 0 }
onClick={ onToggle }
appliedFiltersNum={ appliedFiltersNum }
/>
</PopoverTrigger>
<PopoverContent w={{ md: '100%', lg: '438px' }}>
<PopoverBody px={ 4 } py={ 6 } display="grid" gridTemplateColumns="1fr 1fr" rowGap={ 5 }>
<CheckboxGroup size="lg" onChange={ onFilterChange } defaultValue={ defaultFilters }>
{ TX_INTERNALS_ITEMS.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</PopoverBody>
</PopoverContent>
</Popover>
<PopoverFilter appliedFiltersNum={ appliedFiltersNum } contentProps={{ w: { md: '100%', lg: '438px' } }}>
<CheckboxGroup size="lg" onChange={ onFilterChange } defaultValue={ defaultFilters }>
{ TX_INTERNALS_ITEMS.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</PopoverFilter>
);
};
......
......@@ -28,12 +28,12 @@ const TxInternalsListItem = ({ type, from, to, value, success, error, gas_limit:
<Box w="100%" display="flex" columnGap={ 3 }>
<Address width="calc((100% - 48px) / 2)">
<AddressIcon address={ from }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ from.hash }/>
</Address>
<Icon as={ eastArrowIcon } boxSize={ 6 } color="gray.500"/>
<Address width="calc((100% - 48px) / 2)">
<AddressIcon address={ toData }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ toData.hash }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ toData.hash }/>
</Address>
</Box>
<HStack spacing={ 3 }>
......
......@@ -33,7 +33,7 @@ const TxInternalTableItem = ({ type, from, to, value, success, error, gas_limit:
<Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%">
<AddressIcon address={ from }/>
<AddressLink ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 }/>
<AddressLink type="address" ml={ 2 } fontWeight="500" hash={ from.hash } alias={ from.name } flexGrow={ 1 }/>
</Address>
</Td>
<Td px={ 0 } verticalAlign="middle">
......@@ -42,7 +42,7 @@ const TxInternalTableItem = ({ type, from, to, value, success, error, gas_limit:
<Td verticalAlign="middle">
<Address display="inline-flex" maxW="100%">
<AddressIcon address={ toData }/>
<AddressLink hash={ toData.hash } alias={ toData.name } fontWeight="500" ml={ 2 }/>
<AddressLink type="address" hash={ toData.hash } alias={ toData.name } fontWeight="500" ml={ 2 }/>
</Address>
</Td>
<Td isNumeric verticalAlign="middle">
......
......@@ -53,7 +53,7 @@ const TxStateListItem = ({ storage, address, miner, after, before, diff }: Props
<Address flexGrow={ 1 }>
{ /* ??? */ }
{ /* <AddressIcon hash={ address }/> */ }
<AddressLink hash={ address } ml={ 2 }/>
<AddressLink type="address" hash={ address } ml={ 2 }/>
</Address>
</Flex>
{ hasStorageData && (
......
......@@ -60,7 +60,7 @@ const TxStateTableItem = ({ txStateItem }: { txStateItem: TTxStateItem }) => {
<Address height="30px">
{ /* ??? */ }
{ /* <AddressIcon hash={ txStateItem.address }/> */ }
<AddressLink hash={ txStateItem.address } fontWeight="500" truncation="constant" ml={ 2 }/>
<AddressLink type="address" hash={ txStateItem.address } fontWeight="500" truncation="constant" ml={ 2 }/>
</Address>
</Td>
<Td border={ 0 } lineHeight="30px"><Link>{ txStateItem.miner }</Link></Td>
......
......@@ -7,7 +7,6 @@ import {
PopoverContent,
PopoverBody,
useDisclosure,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
......@@ -32,7 +31,6 @@ type Props =
const TxAdditionalInfo = ({ hash, tx, isMobile }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const popoverBorderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const content = hash !== undefined ? <TxAdditionalInfoContainer hash={ hash }/> : <TxAdditionalInfoContent tx={ tx }/>;
......@@ -56,7 +54,7 @@ const TxAdditionalInfo = ({ hash, tx, isMobile }: Props) => {
<PopoverTrigger>
<AdditionalInfoButton isOpen={ isOpen }/>
</PopoverTrigger>
<PopoverContent border="1px solid" borderColor={ popoverBorderColor }>
<PopoverContent border="1px solid" borderColor="divider">
<PopoverBody>
{ content }
</PopoverBody>
......
import { Box, Heading, Text, Flex, Link, useColorModeValue } from '@chakra-ui/react';
import { Box, Heading, Text, Flex, Link } from '@chakra-ui/react';
import BigNumber from 'bignumber.js';
import React from 'react';
......@@ -12,10 +12,9 @@ import TextSeparator from 'ui/shared/TextSeparator';
import Utilization from 'ui/shared/Utilization/Utilization';
const TxAdditionalInfoContent = ({ tx }: { tx: Transaction }) => {
const sectionBorderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
const sectionProps = {
borderBottom: '1px solid',
borderColor: sectionBorderColor,
borderColor: 'divider',
paddingBottom: 4,
};
......
......@@ -5,6 +5,7 @@ import React from 'react';
import type { TxsResponse } from 'types/api/transaction';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressCsvExportLink from 'ui/address/AddressCsvExportLink';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
import SkeletonList from 'ui/shared/skeletons/SkeletonList';
......@@ -123,6 +124,7 @@ const TxsContent = ({
paginationProps={ query.pagination }
showPagination={ query.isPaginationVisible }
filterComponent={ filter }
linkSlot={ currentAddress ? <AddressCsvExportLink address={ currentAddress } type="transactions" ml={ 2 }/> : null }
/>
) }
{ content }
......
import {
Button,
Checkbox,
CheckboxGroup,
Grid,
Link,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
Text,
useColorModeValue,
useDisclosure,
Flex,
} from '@chakra-ui/react';
import React, { useCallback, useState } from 'react';
import type { TTxsFilters, TypeFilter, MethodFilter } from 'types/api/txsFilters';
import FilterButton from 'ui/shared/FilterButton';
import PopoverFilter from 'ui/shared/filters/PopoverFilter';
interface Props {
appliedFiltersNum?: number;
......@@ -41,13 +32,7 @@ const METHOD_OPTIONS = [
{ title: 'Commit', id: 'commit' },
];
// TODO: i think we need to reload page after applying filters,
// because we need to reset pagination, clear query caches, reconnect websocket...
// also mobile version of filters is not implemented
const TxsFilters = ({ onFiltersChange, filters, appliedFiltersNum }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const TxsFilters = ({ filters, appliedFiltersNum }: Props) => {
const [ typeFilter, setTypeFilter ] = useState<Array<TypeFilter>>(filters.type || []);
const [ methodFilter, setMethodFilter ] = useState<Array<MethodFilter>>(filters.method || []);
......@@ -60,50 +45,21 @@ const TxsFilters = ({ onFiltersChange, filters, appliedFiltersNum }: Props) => {
setMethodFilter(val);
}, []);
const onFilterReset = useCallback(() => {
setTypeFilter([]);
setMethodFilter([]);
onFiltersChange({ type: [], method: [] });
onClose();
}, [ onClose, onFiltersChange ]);
const onFilterApply = useCallback(() => {
onFiltersChange({ type: typeFilter, method: methodFilter } as Partial<TTxsFilters>);
onClose();
}, [ onClose, onFiltersChange, typeFilter, methodFilter ]);
const borderColor = useColorModeValue('blackAlpha.200', 'whiteAlpha.200');
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<FilterButton
isActive={ isOpen || Number(appliedFiltersNum) > 0 }
onClick={ onToggle }
appliedFiltersNum={ appliedFiltersNum }
/>
</PopoverTrigger>
<PopoverContent w={{ md: '100%', lg: '438px' }}>
<PopoverBody px={ 4 } py={ 6 }>
<Text variant="secondary" fontWeight="600" fontSize="sm">Type</Text>
<Grid gridTemplateColumns="1fr 1fr" rowGap={ 5 } mt={ 4 } mb={ 4 } pb={ 6 } borderBottom="1px solid" borderColor={ borderColor }>
<CheckboxGroup size="lg" onChange={ onTypeFilterChange } defaultValue={ typeFilter }>
{ TYPE_OPTIONS.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</Grid>
<Text variant="secondary" fontWeight="600" fontSize="sm">Method</Text>
<Grid gridTemplateColumns="1fr 1fr" rowGap={ 5 } mt={ 4 } mb={ 4 } pb={ 6 } borderBottom="1px solid" borderColor={ borderColor }>
<CheckboxGroup size="lg" onChange={ onMethodFilterChange } defaultValue={ methodFilter }>
{ METHOD_OPTIONS.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</Grid>
<Flex alignItems="center" justifyContent="space-between">
<Link fontSize="sm" onClick={ onFilterReset }>Reset filters</Link>
<Button variant="outline" size="sm" onClick={ onFilterApply }>Apply</Button>
</Flex>
</PopoverBody>
</PopoverContent>
</Popover>
<PopoverFilter contentProps={{ w: { md: '100%', lg: '438px' } }} appliedFiltersNum={ appliedFiltersNum }>
<Text variant="secondary" fontWeight="600" fontSize="sm">Type</Text>
<Grid gridTemplateColumns="1fr 1fr" rowGap={ 5 } mt={ 4 } mb={ 4 } pb={ 6 } borderBottom="1px solid" borderColor="divider">
<CheckboxGroup size="lg" onChange={ onTypeFilterChange } defaultValue={ typeFilter }>
{ TYPE_OPTIONS.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</Grid>
<Text variant="secondary" fontWeight="600" fontSize="sm">Method</Text>
<Grid gridTemplateColumns="1fr 1fr" rowGap={ 5 } mt={ 4 } mb={ 4 } pb={ 6 } borderBottom="1px solid" borderColor="divider">
<CheckboxGroup size="lg" onChange={ onMethodFilterChange } defaultValue={ methodFilter }>
{ METHOD_OPTIONS.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</Grid>
</PopoverFilter>
);
};
......
......@@ -3,7 +3,7 @@ import React from 'react';
import type { Sort } from 'types/client/txs-sort';
// import FilterInput from 'ui/shared/FilterInput';
// import FilterInput from 'ui/shared/filters/FilterInput';
import ActionBar from 'ui/shared/ActionBar';
import Pagination from 'ui/shared/Pagination';
import type { Props as PaginationProps } from 'ui/shared/Pagination';
......@@ -18,9 +18,10 @@ type Props = {
className?: string;
showPagination?: boolean;
filterComponent?: React.ReactNode;
linkSlot?: React.ReactNode;
}
const TxsHeaderMobile = ({ filterComponent, sorting, setSorting, paginationProps, className, showPagination = true }: Props) => {
const TxsHeaderMobile = ({ filterComponent, sorting, setSorting, paginationProps, className, showPagination = true, linkSlot }: Props) => {
return (
<ActionBar className={ className }>
<HStack>
......@@ -38,6 +39,7 @@ const TxsHeaderMobile = ({ filterComponent, sorting, setSorting, paginationProps
size="xs"
placeholder="Search by addresses, hash, method..."
/> */ }
{ linkSlot }
</HStack>
{ showPagination && <Pagination { ...paginationProps }/> }
</ActionBar>
......
......@@ -5,7 +5,6 @@ import {
Icon,
Link,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import React from 'react';
......@@ -37,7 +36,6 @@ const TAG_WIDTH = 48;
const ARROW_WIDTH = 24;
const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }: Props) => {
const iconColor = useColorModeValue('blue.600', 'blue.300');
const dataTo = tx.to ? tx.to : tx.created_contract;
const isOut = Boolean(currentAddress && currentAddress === tx.from.hash);
......@@ -60,7 +58,7 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }:
as={ transactionIcon }
boxSize="30px"
mr={ 2 }
color={ iconColor }
color="link"
/>
<Address width="100%">
<AddressLink
......@@ -97,6 +95,7 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }:
<Address width={ `calc((100%-${ currentAddress ? TAG_WIDTH : ARROW_WIDTH + 8 }px)/2)` }>
<AddressIcon address={ tx.from }/>
<AddressLink
type="address"
hash={ tx.from.hash }
alias={ tx.from.name }
fontWeight="500"
......@@ -116,6 +115,7 @@ const TxsListItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }:
<Address width="calc((100%-40px)/2)">
<AddressIcon address={ dataTo }/>
<AddressLink
type="address"
hash={ dataTo.hash }
alias={ dataTo.name }
fontWeight="500"
......
......@@ -45,7 +45,7 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }
const addressFrom = (
<Address>
<AddressIcon address={ tx.from }/>
<AddressLink hash={ tx.from.hash } alias={ tx.from.name } fontWeight="500" ml={ 2 } truncation="constant" isDisabled={ isOut }/>
<AddressLink type="address" hash={ tx.from.hash } alias={ tx.from.name } fontWeight="500" ml={ 2 } truncation="constant" isDisabled={ isOut }/>
</Address>
);
......@@ -54,7 +54,7 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement }
const addressTo = (
<Address>
<AddressIcon address={ dataTo }/>
<AddressLink hash={ dataTo.hash } alias={ dataTo.name } fontWeight="500" ml={ 2 } truncation="constant" isDisabled={ isIn }/>
<AddressLink type="address" hash={ dataTo.hash } alias={ dataTo.name } fontWeight="500" ml={ 2 } truncation="constant" isDisabled={ isIn }/>
</Address>
);
......
......@@ -19,7 +19,7 @@ const DeleteAddressModal: React.FC<Props> = ({ isOpen, onClose, onSuccess, data
const apiFetch = useApiFetch();
const mutationFn = useCallback(() => {
return apiFetch('custom_abi', {
return apiFetch('watchlist', {
pathParams: { id: data.id },
fetchParams: { method: 'DELETE' },
});
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment