Commit cc9cb368 authored by Juan Leandro Costa's avatar Juan Leandro Costa Committed by GitHub

Noves integration (#1398)

* noves integration

* code refactored

* PR changes added

* Code set up for new proxy 'describeTxs'

* minor fix

* Rename Novestranslate.ts to NovesTranslate.ts

* some quick stuff

* partial fixes and commit for changing how useDescribeTxs and txsContent work (new GET endpoint)

* Pending PR fixes

* tx asset flows pageSize of 50

* PR comments fixes

* rename expected api endpoint for the describe_txs endpoint, more accurate and descriptive

* one final re-name for api endpoint (make it clear it's an object vs an action)

* scrollRef fix

* build error fix

* design fixes

* sub heading fix

* Removed pagination in account history

* remove duplicate route

* updated table theme and icon gap

* Removed wrong color in table

* removed null validation in page params

* updated margin

* margin fix

* add icons to contracts

* Sub-heading interpretation simplified

* token alignment fix

* tests added for new functions

* margin fix

* remove divider on mobile asset flows

---------
Co-authored-by: default avatarNahuelNoves <Nahuel@noves.fi>
Co-authored-by: default avatarfrancisco-noves <francisco@noves.fi>
parent 9cdbf6d6
...@@ -79,6 +79,7 @@ frontend: ...@@ -79,6 +79,7 @@ frontend:
NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]" NEXT_PUBLIC_VIEWS_NFT_MARKETPLACES: "[{'name':'LooksRare','collection_url':'https://goerli.looksrare.org/collections/{hash}','instance_url':'https://goerli.looksrare.org/collections/{hash}/{id}','logo_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/nft-marketplace-logos/looks-rare.png'}]"
NEXT_PUBLIC_HAS_USER_OPS: true NEXT_PUBLIC_HAS_USER_OPS: true
NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]" NEXT_PUBLIC_CONTRACT_CODE_IDES: "[{'title':'Remix IDE','url':'https://remix.blockscout.com/?address={hash}&blockscout=eth-goerli.blockscout.com','icon_url':'https://raw.githubusercontent.com/blockscout/frontend-configs/main/configs/ide-icons/remix.png'}]"
NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER: noves
NEXT_PUBLIC_SWAP_BUTTON_URL: uniswap NEXT_PUBLIC_SWAP_BUTTON_URL: uniswap
NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true
NEXT_PUBLIC_AD_BANNER_PROVIDER: getit NEXT_PUBLIC_AD_BANNER_PROVIDER: getit
......
...@@ -524,7 +524,7 @@ This feature is **enabled by default** with the `['metamask']` value. To switch ...@@ -524,7 +524,7 @@ This feature is **enabled by default** with the `['metamask']` value. To switch
| Variable | Type| Description | Compulsoriness | Default value | Example value | | Variable | Type| Description | Compulsoriness | Default value | Example value |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER | `blockscout` \| `none` | Transaction interpretation provider that displays human readable transaction description | - | `none` | `blockscout` | | NEXT_PUBLIC_TRANSACTION_INTERPRETATION_PROVIDER | `blockscout` \| `noves` \| `none` | Transaction interpretation provider that displays human readable transaction description | - | `none` | `blockscout` |
&nbsp; &nbsp;
......
<svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.121 1C3.131 1 .667 3.464.667 6.455V41H26.12V28.273h3.637v7.272c0 2.99 2.464 5.455 5.454 5.455 2.99 0 5.455-2.465 5.455-5.455V17.023a5.33 5.33 0 0 0-1.591-3.807l-8.58-8.58-2.557 2.557 5.17 5.17c-1.952.832-3.352 2.756-3.352 5 0 2.99 2.465 5.455 5.455 5.455.64 0 1.243-.135 1.818-.34v13.067c0 1.03-.788 1.819-1.818 1.819a1.786 1.786 0 0 1-1.818-1.819v-7.272c0-1.989-1.648-3.637-3.636-3.637H26.12V6.455c0-2.99-2.464-5.455-5.454-5.455H6.12Zm0 3.636h14.546c1.03 0 1.818.789 1.818 1.819v7.272H4.303V6.455c0-1.03.788-1.819 1.818-1.819Zm29.091 10.91c1.023 0 1.818.795 1.818 1.818a1.795 1.795 0 0 1-1.818 1.818 1.795 1.795 0 0 1-1.818-1.818c0-1.023.795-1.819 1.818-1.819ZM4.303 17.364h18.182v20H4.303v-20Z" fill="currentColor" stroke="currentColor"/> <path d="M6.121 1C3.131 1 .667 3.464.667 6.455V41H26.12V28.273h3.637v7.272c0 2.99 2.464 5.455 5.454 5.455 2.99 0 5.455-2.465 5.455-5.455V17.023a5.33 5.33 0 0 0-1.591-3.807l-8.58-8.58-2.557 2.557 5.17 5.17c-1.952.832-3.352 2.756-3.352 5 0 2.99 2.465 5.455 5.455 5.455.64 0 1.243-.135 1.818-.34v13.067c0 1.03-.788 1.819-1.818 1.819a1.786 1.786 0 0 1-1.818-1.819v-7.272c0-1.989-1.648-3.637-3.636-3.637H26.12V6.455C26.12 3.465 23.656 1 20.666 1H6.12Zm0 3.636h14.546c1.03 0 1.818.789 1.818 1.819v7.272H4.303V6.455c0-1.03.788-1.819 1.818-1.819Zm29.091 10.91c1.023 0 1.818.795 1.818 1.818a1.795 1.795 0 0 1-1.818 1.818 1.795 1.795 0 0 1-1.818-1.818c0-1.023.795-1.819 1.818-1.819ZM4.303 17.364h18.182v20H4.303v-20Z" fill="currentColor" stroke="currentColor"/>
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M10.833 7.5H17.5L9.167 20v-7.5H3.333l7.5-12.5v7.5ZM9.167 9.167v-3.15l-2.89 4.816h4.556v3.662l3.553-5.328h-5.22Z"/> <path d="M10.833 7.5H17.5L9.167 20v-7.5H3.333l7.5-12.5v7.5ZM9.167 9.167v-3.15l-2.89 4.816h4.556v3.662l3.553-5.328h-5.22Z" fill="currentColor"/>
</svg> </svg>
...@@ -56,6 +56,7 @@ import type { ...@@ -56,6 +56,7 @@ import type {
import type { IndexingStatus } from 'types/api/indexingStatus'; import type { IndexingStatus } from 'types/api/indexingStatus';
import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction';
import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log'; import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log';
import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves';
import type { import type {
OptimisticL2DepositsResponse, OptimisticL2DepositsResponse,
OptimisticL2DepositsItem, OptimisticL2DepositsItem,
...@@ -649,6 +650,20 @@ export const RESOURCES = { ...@@ -649,6 +650,20 @@ export const RESOURCES = {
path: '/api/v2/shibarium/withdrawals/count', path: '/api/v2/shibarium/withdrawals/count',
}, },
// NOVES-FI
noves_transaction: {
path: '/api/v2/proxy/noves-fi/transactions/:hash',
pathParams: [ 'hash' as const ],
},
noves_address_history: {
path: '/api/v2/proxy/noves-fi/addresses/:address/transactions',
pathParams: [ 'address' as const ],
filterFields: [],
},
noves_describe_txs: {
path: '/api/v2/proxy/noves-fi/transaction-descriptions',
},
// USER OPS // USER OPS
user_ops: { user_ops: {
path: '/api/v2/proxy/account-abstraction/operations', path: '/api/v2/proxy/account-abstraction/operations',
...@@ -757,7 +772,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | ...@@ -757,7 +772,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' |
'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' | 'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' |
'withdrawals' | 'address_withdrawals' | 'block_withdrawals' | 'withdrawals' | 'address_withdrawals' | 'block_withdrawals' |
'watchlist' | 'private_tags_address' | 'private_tags_tx' | 'watchlist' | 'private_tags_address' | 'private_tags_tx' |
'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators'; 'domains_lookup' | 'addresses_lookup' | 'user_ops' | 'validators' | 'noves_address_history';
export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>; export type PaginatedResponse<Q extends PaginatedResources> = ResourcePayload<Q>;
...@@ -886,6 +901,9 @@ Q extends 'user_ops' ? UserOpsResponse : ...@@ -886,6 +901,9 @@ Q extends 'user_ops' ? UserOpsResponse :
Q extends 'user_op' ? UserOp : Q extends 'user_op' ? UserOp :
Q extends 'user_ops_account' ? UserOpsAccount : Q extends 'user_ops_account' ? UserOpsAccount :
Q extends 'user_op_interpretation'? TxInterpretationResponse : Q extends 'user_op_interpretation'? TxInterpretationResponse :
Q extends 'noves_transaction' ? NovesResponseData :
Q extends 'noves_address_history' ? NovesAccountHistoryResponse :
Q extends 'noves_describe_txs' ? NovesDescribeTxsResponse :
never; never;
/* eslint-enable @typescript-eslint/indent */ /* eslint-enable @typescript-eslint/indent */
......
import type { NovesResponseData } from 'types/api/noves';
import type { TokensData } from 'ui/tx/assetFlows/utils/getTokensData';
export const hash = '0x380400d04ebb4179a35b1d7fdef260776915f015e978f8587ef2704b843d4e53';
export const transaction: NovesResponseData = {
accountAddress: '0xef6595A423c99f3f2821190A4d96fcE4DcD89a80',
chain: 'eth-goerli',
classificationData: {
description: 'Called function \'stake\' on contract 0xef326CdAdA59D3A740A76bB5f4F88Fb2.',
protocol: {
name: null,
},
received: [],
sent: [
{
action: 'sent',
actionFormatted: 'Sent',
amount: '3000',
from: {
address: '0xef6595A423c99f3f2821190A4d96fcE4DcD89a80',
name: 'This wallet',
},
to: {
address: '0xdD15D2650387Fb6FEDE27ae7392C402a393F8A37',
name: null,
},
token: {
address: '0x1bfe4298796198f8664b18a98640cec7c89b5baa',
decimals: 18,
name: 'PQR-Test',
symbol: 'PQR',
},
},
{
action: 'paidGas',
actionFormatted: 'Paid Gas',
amount: '0.000395521502109448',
from: {
address: '0xef6595A423c99f3f2821190A4d96fcE4DcD89a80',
name: 'This wallet',
},
to: {
address: null,
name: 'Validators',
},
token: {
address: 'ETH',
decimals: 18,
name: 'ETH',
symbol: 'ETH',
},
},
],
source: {
type: null,
},
type: 'unclassified',
typeFormatted: 'Unclassified',
},
rawTransactionData: {
blockNumber: 10388918,
fromAddress: '0xef6595A423c99f3f2821190A4d96fcE4DcD89a80',
gas: 275079,
gasPrice: 1500000008,
timestamp: 1705488588,
toAddress: '0xef326CdAdA59D3A740A76bB5f4F88Fb2f1076164',
transactionFee: {
amount: '395521502109448',
token: {
decimals: 18,
symbol: 'ETH',
},
},
transactionHash: '0x380400d04ebb4179a35b1d7fdef260776915f015e978f8587ef2704b843d4e53',
},
txTypeVersion: 2,
};
export const tokenData: TokensData = {
nameList: [ 'PQR-Test', 'ETH' ],
symbolList: [ 'PQR' ],
idList: [],
byName: {
'PQR-Test': {
name: 'PQR-Test',
symbol: 'PQR',
address: '0x1bfe4298796198f8664b18a98640cec7c89b5baa',
id: undefined,
},
ETH: { name: 'ETH', symbol: null, address: '', id: undefined },
},
bySymbol: {
PQR: {
name: 'PQR-Test',
symbol: 'PQR',
address: '0x1bfe4298796198f8664b18a98640cec7c89b5baa',
id: undefined,
},
'null': { name: 'ETH', symbol: null, address: '', id: undefined },
},
};
...@@ -21,11 +21,12 @@ declare module "nextjs-routes" { ...@@ -21,11 +21,12 @@ declare module "nextjs-routes" {
| StaticRoute<"/api/media-type"> | StaticRoute<"/api/media-type">
| StaticRoute<"/api/proxy"> | StaticRoute<"/api/proxy">
| StaticRoute<"/api-docs"> | StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/apps"> | StaticRoute<"/apps">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/auth/auth0"> | StaticRoute<"/auth/auth0">
| StaticRoute<"/auth/profile"> | StaticRoute<"/auth/profile">
| StaticRoute<"/auth/unverified-email"> | StaticRoute<"/auth/unverified-email">
| StaticRoute<"/batches">
| DynamicRoute<"/batches/[number]", { "number": string }> | DynamicRoute<"/batches/[number]", { "number": string }>
| StaticRoute<"/batches"> | StaticRoute<"/batches">
| DynamicRoute<"/blobs/[hash]", { "hash": string }> | DynamicRoute<"/blobs/[hash]", { "hash": string }>
...@@ -38,8 +39,8 @@ declare module "nextjs-routes" { ...@@ -38,8 +39,8 @@ declare module "nextjs-routes" {
| StaticRoute<"/graphiql"> | StaticRoute<"/graphiql">
| StaticRoute<"/"> | StaticRoute<"/">
| StaticRoute<"/login"> | StaticRoute<"/login">
| DynamicRoute<"/name-domains/[name]", { "name": string }>
| StaticRoute<"/name-domains"> | StaticRoute<"/name-domains">
| DynamicRoute<"/name-domains/[name]", { "name": string }>
| DynamicRoute<"/op/[hash]", { "hash": string }> | DynamicRoute<"/op/[hash]", { "hash": string }>
| StaticRoute<"/ops"> | StaticRoute<"/ops">
| StaticRoute<"/output-roots"> | StaticRoute<"/output-roots">
......
import type { NovesResponseData, NovesClassificationData, NovesRawTransactionData } from 'types/api/noves';
const NOVES_TRANSLATE_CLASSIFIED: NovesClassificationData = {
description: 'Sent 0.04 ETH',
received: [ {
action: 'Sent Token',
actionFormatted: 'Sent Token',
amount: '45',
from: { name: '', address: '0xa0393A76b132526a70450273CafeceB45eea6dEE' },
to: { name: '', address: '0xa0393A76b132526a70450273CafeceB45eea6dEE' },
token: {
address: '',
name: 'ETH',
symbol: 'ETH',
decimals: 18,
},
} ],
sent: [],
source: {
type: '',
},
type: '0x2',
typeFormatted: 'Send NFT',
};
const NOVES_TRANSLATE_RAW: NovesRawTransactionData = {
blockNumber: 1,
fromAddress: '0xCFC123a23dfeD71bDAE054e487989d863C525C73',
gas: 2,
gasPrice: 3,
timestamp: 20000,
toAddress: '0xCFC123a23dfeD71bDAE054e487989d863C525C73',
transactionFee: 2,
transactionHash: '0x128b79937a0eDE33258992c9668455f997f1aF24',
};
export const NOVES_TRANSLATE: NovesResponseData = {
accountAddress: '0x2b824349b320cfa72f292ab26bf525adb00083ba9fa097141896c3c8c74567cc',
chain: 'base',
txTypeVersion: 2,
rawTransactionData: NOVES_TRANSLATE_RAW,
classificationData: NOVES_TRANSLATE_CLASSIFIED,
};
export interface NovesResponseData {
txTypeVersion: number;
chain: string;
accountAddress: string;
classificationData: NovesClassificationData;
rawTransactionData: NovesRawTransactionData;
}
export interface NovesClassificationData {
type: string;
typeFormatted?: string;
description: string;
sent: Array<NovesSentReceived>;
received: Array<NovesSentReceived>;
approved?: Approved;
protocol?: {
name: string | null;
};
source: {
type: string | null;
};
message?: string;
}
export interface Approved {
amount: string;
spender: string;
token?: NovesToken;
nft?: NovesNft;
}
export interface NovesSentReceived {
action: string;
actionFormatted?: string;
amount: string;
to: NovesTo;
from: NovesFrom;
token?: NovesToken;
nft?: NovesNft;
}
export interface NovesToken {
symbol: string;
name: string;
decimals: number;
address: string;
id?: string;
}
export interface NovesNft {
name: string;
id: string;
symbol: string;
address: string;
}
export interface NovesFrom {
name: string | null;
address: string;
}
export interface NovesTo {
name: string | null;
address: string | null;
}
export interface NovesRawTransactionData {
transactionHash: string;
fromAddress: string;
toAddress: string;
blockNumber: number;
gas: number;
gasPrice: number;
transactionFee: NovesTransactionFee | number;
timestamp: number;
}
export interface NovesTransactionFee {
amount: string;
currency?: string;
token?: {
decimals: number;
symbol: string;
};
}
export interface NovesAccountHistoryResponse {
hasNextPage: boolean;
items: Array<NovesResponseData>;
pageNumber: number;
pageSize: number;
next_page_params?: {
startBlock: string;
endBlock: string;
pageNumber: number;
pageSize: number;
ignoreTransactions: string;
viewAsAccountAddress: string;
};
}
export const NovesHistoryFilterValues = [ 'received', 'sent' ] as const;
export type NovesHistoryFilterValue = typeof NovesHistoryFilterValues[number] | undefined;
export interface NovesHistoryFilters {
filter?: NovesHistoryFilterValue;
}
export interface NovesDescribeResponse {
type: string;
description: string;
}
export interface NovesDescribeTxsResponse {
txHash: string;
type: string;
description: string;
}[];
export interface NovesTxTranslation {
data?: NovesDescribeTxsResponse;
isLoading: boolean;
}
...@@ -2,6 +2,7 @@ import type { AddressParam } from './addressParams'; ...@@ -2,6 +2,7 @@ import type { AddressParam } from './addressParams';
import type { BlockTransactionsResponse } from './block'; import type { BlockTransactionsResponse } from './block';
import type { DecodedInput } from './decodedInput'; import type { DecodedInput } from './decodedInput';
import type { Fee } from './fee'; import type { Fee } from './fee';
import type { NovesTxTranslation } from './noves';
import type { OptimisticL2WithdrawalStatus } from './optimisticL2'; import type { OptimisticL2WithdrawalStatus } from './optimisticL2';
import type { TokenInfo } from './token'; import type { TokenInfo } from './token';
import type { TokenTransfer } from './tokenTransfer'; import type { TokenTransfer } from './tokenTransfer';
...@@ -85,6 +86,8 @@ export type Transaction = { ...@@ -85,6 +86,8 @@ export type Transaction = {
blob_gas_price?: string; blob_gas_price?: string;
burnt_blob_fee?: string; burnt_blob_fee?: string;
max_fee_per_blob_gas?: string; max_fee_per_blob_gas?: string;
// Noves-fi
translation?: NovesTxTranslation;
} }
export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ]; export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ];
......
...@@ -2,6 +2,7 @@ import type { ArrayElement } from 'types/utils'; ...@@ -2,6 +2,7 @@ import type { ArrayElement } from 'types/utils';
export const PROVIDERS = [ export const PROVIDERS = [
'blockscout', 'blockscout',
'noves',
'none', 'none',
] as const; ] as const;
......
import { Box, Hide, Show, Table,
Tbody, Th, Tr } from '@chakra-ui/react';
import { useRouter } from 'next/router';
import React from 'react';
import type { NovesHistoryFilterValue } from 'types/api/noves';
import { NovesHistoryFilterValues } from 'types/api/noves';
import getFilterValueFromQuery from 'lib/getFilterValueFromQuery';
import getQueryParamString from 'lib/router/getQueryParamString';
import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate';
import { generateListStub } from 'stubs/utils';
import AddressAccountHistoryTableItem from 'ui/address/accountHistory/AddressAccountHistoryTableItem';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import { getFromToValue } from 'ui/shared/Noves/utils';
import Pagination from 'ui/shared/pagination/Pagination';
import useQueryWithPages from 'ui/shared/pagination/useQueryWithPages';
import TheadSticky from 'ui/shared/TheadSticky';
import AddressAccountHistoryListItem from './accountHistory/AddressAccountHistoryListItem';
import AccountHistoryFilter from './AddressAccountHistoryFilter';
const getFilterValue = (getFilterValueFromQuery<NovesHistoryFilterValue>).bind(null, NovesHistoryFilterValues);
type Props = {
scrollRef?: React.RefObject<HTMLDivElement>;
}
const AddressAccountHistory = ({ scrollRef }: Props) => {
const router = useRouter();
const currentAddress = getQueryParamString(router.query.hash).toLowerCase();
const [ filterValue, setFilterValue ] = React.useState<NovesHistoryFilterValue>(getFilterValue(router.query.filter));
const { data, isError, pagination, isPlaceholderData } = useQueryWithPages({
resourceName: 'noves_address_history',
pathParams: { address: currentAddress },
scrollRef,
options: {
placeholderData: generateListStub<'noves_address_history'>(NOVES_TRANSLATE, 10, { hasNextPage: false, pageNumber: 1, pageSize: 10 }),
},
});
const handleFilterChange = React.useCallback((val: string | Array<string>) => {
const newVal = getFilterValue(val);
setFilterValue(newVal);
}, [ ]);
const actionBar = (
<ActionBar mt={ -6 } pb={{ base: 6, md: 5 }}>
<AccountHistoryFilter
defaultFilter={ filterValue }
onFilterChange={ handleFilterChange }
isActive={ Boolean(filterValue) }
isLoading={ pagination.isLoading }
/>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...pagination }/>
</ActionBar>
);
const filteredData = isPlaceholderData ? data?.items : data?.items.filter(i => filterValue ? getFromToValue(i, currentAddress) === filterValue : i);
const content = (
<Box position="relative">
<Hide above="lg" ssr={ false }>
{ filteredData?.map((item, i) => (
<AddressAccountHistoryListItem
key={ `${ i }-${ item.rawTransactionData.transactionHash }` }
tx={ item }
currentAddress={ currentAddress }
isPlaceholderData={ isPlaceholderData }
/>
)) }
</Hide>
<Show above="lg" ssr={ false }>
<Table variant="simple" >
<TheadSticky top={ 75 }>
<Tr>
<Th width="120px">
Age
</Th>
<Th>
Action
</Th>
<Th width="320px">
From/To
</Th>
</Tr>
</TheadSticky>
<Tbody maxWidth="full">
{ filteredData?.map((item, i) => (
<AddressAccountHistoryTableItem
key={ `${ i }-${ item.rawTransactionData.transactionHash }` }
tx={ item }
currentAddress={ currentAddress }
isPlaceholderData={ isPlaceholderData }
/>
)) }
</Tbody>
</Table>
</Show>
</Box>
);
return (
<DataListDisplay
isError={ isError }
items={ filteredData }
emptyText="There are no transactions."
content={ content }
actionBar={ actionBar }
filterProps={{
hasActiveFilters: Boolean(filterValue),
emptyFilteredText: 'No match found for current filter',
}}
/>
);
};
export default AddressAccountHistory;
import {
Menu,
MenuButton,
MenuList,
MenuOptionGroup,
MenuItemOption,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import type { NovesHistoryFilterValue } from 'types/api/noves';
import useIsInitialLoading from 'lib/hooks/useIsInitialLoading';
import FilterButton from 'ui/shared/filters/FilterButton';
interface Props {
isActive: boolean;
defaultFilter: NovesHistoryFilterValue;
onFilterChange: (nextValue: string | Array<string>) => void;
isLoading?: boolean;
}
const AccountHistoryFilter = ({ onFilterChange, defaultFilter, isActive, isLoading }: Props) => {
const { isOpen, onToggle } = useDisclosure();
const isInitialLoading = useIsInitialLoading(isLoading);
const onCloseMenu = React.useCallback(() => {
if (isOpen) {
onToggle();
}
}, [ isOpen, onToggle ]);
return (
<Menu isOpen={ isOpen } onClose={ onCloseMenu }>
<MenuButton onClick={ onToggle }>
<FilterButton
isActive={ isOpen || isActive }
isLoading={ isInitialLoading }
onClick={ onToggle }
appliedFiltersNum={ isActive ? 1 : 0 }
as="div"
/>
</MenuButton>
<MenuList zIndex={ 2 }>
<MenuOptionGroup defaultValue={ defaultFilter || 'all' } type="radio" onChange={ onFilterChange }>
<MenuItemOption value="all">All</MenuItemOption>
<MenuItemOption value="received">Received from</MenuItemOption>
<MenuItemOption value="sent">Sent to</MenuItemOption>
</MenuOptionGroup>
</MenuList>
</Menu>
);
};
export default React.memo(AccountHistoryFilter);
import { Box, Flex, Skeleton, Text } from '@chakra-ui/react';
import React, { useMemo } from 'react';
import type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
type Props = {
isPlaceholderData: boolean;
tx: NovesResponseData;
currentAddress: string;
};
const AddressAccountHistoryListItem = (props: Props) => {
const parsedDescription = useMemo(() => {
const description = props.tx.classificationData.description;
return description.endsWith('.') ? description.substring(0, description.length - 1) : description;
}, [ props.tx.classificationData.description ]);
return (
<ListItemMobile rowGap={ 4 } w="full">
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData } w="full">
<Flex justifyContent="space-between" w="full">
<Flex columnGap={ 2 }>
<IconSvg
name="lightning"
height="5"
width="5"
color="gray.500"
_dark={{ color: 'gray.400' }}
/>
<Text fontSize="sm" fontWeight={ 500 }>
Action
</Text>
</Flex>
<Text color="text_secondary" fontSize="sm" fontWeight={ 500 }>
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() }
</Text>
</Flex>
</Skeleton>
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }>
<LinkInternal
href={ `/tx/${ props.tx.rawTransactionData.transactionHash }` }
fontWeight="bold"
whiteSpace="break-spaces"
wordBreak="break-word"
>
{ parsedDescription }
</LinkInternal>
</Skeleton>
<Box maxW="full">
<NovesFromTo txData={ props.tx } currentAddress={ props.currentAddress } isLoaded={ !props.isPlaceholderData }/>
</Box>
</ListItemMobile>
);
};
export default React.memo(AddressAccountHistoryListItem);
import { Td, Tr, Skeleton, Text, Box } from '@chakra-ui/react';
import React, { useMemo } from 'react';
import type { NovesResponseData } from 'types/api/noves';
import dayjs from 'lib/date/dayjs';
import IconSvg from 'ui/shared/IconSvg';
import LinkInternal from 'ui/shared/LinkInternal';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
type Props = {
isPlaceholderData: boolean;
tx: NovesResponseData;
currentAddress: string;
};
const AddressAccountHistoryTableItem = (props: Props) => {
const parsedDescription = useMemo(() => {
const description = props.tx.classificationData.description;
return description.endsWith('.') ? description.substring(0, description.length - 1) : description;
}, [ props.tx.classificationData.description ]);
return (
<Tr>
<Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" flexShrink={ 0 } isLoaded={ !props.isPlaceholderData }>
<Text as="span" color="text_secondary">
{ dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() }
</Text>
</Skeleton>
</Td>
<Td px={ 3 } py="18px" fontSize="sm" >
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData }>
<Box display="flex">
<IconSvg
name="lightning"
height="5"
width="5"
color="gray.500"
mr="8px"
_dark={{ color: 'gray.400' }}
/>
<LinkInternal
href={ `/tx/${ props.tx.rawTransactionData.transactionHash }` }
fontWeight="bold"
whiteSpace="break-spaces"
wordBreak="break-word"
>
{ parsedDescription }
</LinkInternal>
</Box>
</Skeleton>
</Td>
<Td px={ 3 } py="18px" fontSize="sm">
<Box flexShrink={ 0 } >
<NovesFromTo txData={ props.tx } currentAddress={ props.currentAddress } isLoaded={ !props.isPlaceholderData }/>
</Box>
</Td>
</Tr>
);
};
export default React.memo(AddressAccountHistoryTableItem);
...@@ -12,6 +12,7 @@ import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; ...@@ -12,6 +12,7 @@ import useIsSafeAddress from 'lib/hooks/useIsSafeAddress';
import getQueryParamString from 'lib/router/getQueryParamString'; import getQueryParamString from 'lib/router/getQueryParamString';
import { ADDRESS_TABS_COUNTERS } from 'stubs/address'; import { ADDRESS_TABS_COUNTERS } from 'stubs/address';
import { USER_OPS_ACCOUNT } from 'stubs/userOps'; import { USER_OPS_ACCOUNT } from 'stubs/userOps';
import AddressAccountHistory from 'ui/address/AddressAccountHistory';
import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressBlocksValidated from 'ui/address/AddressBlocksValidated';
import AddressCoinBalance from 'ui/address/AddressCoinBalance'; import AddressCoinBalance from 'ui/address/AddressCoinBalance';
import AddressContract from 'ui/address/AddressContract'; import AddressContract from 'ui/address/AddressContract';
...@@ -42,6 +43,8 @@ import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; ...@@ -42,6 +43,8 @@ import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ]; const TOKEN_TABS = [ 'tokens_erc20', 'tokens_nfts', 'tokens_nfts_collection', 'tokens_nfts_list' ];
const txInterpretation = config.features.txInterpretation;
const AddressPageContent = () => { const AddressPageContent = () => {
const router = useRouter(); const router = useRouter();
const appProps = useAppContext(); const appProps = useAppContext();
...@@ -80,6 +83,13 @@ const AddressPageContent = () => { ...@@ -80,6 +83,13 @@ const AddressPageContent = () => {
count: addressTabsCountersQuery.data?.transactions_count, count: addressTabsCountersQuery.data?.transactions_count,
component: <AddressTxs scrollRef={ tabsScrollRef }/>, component: <AddressTxs scrollRef={ tabsScrollRef }/>,
}, },
txInterpretation.isEnabled && txInterpretation.provider === 'noves' ?
{
id: 'account_history',
title: 'Account history',
component: <AddressAccountHistory scrollRef={ tabsScrollRef }/>,
} :
undefined,
config.features.userOps.isEnabled && Boolean(userOpsAccountQuery.data?.total_ops) ? config.features.userOps.isEnabled && Boolean(userOpsAccountQuery.data?.total_ops) ?
{ {
id: 'user_ops', id: 'user_ops',
......
...@@ -15,6 +15,7 @@ import PageTitle from 'ui/shared/Page/PageTitle'; ...@@ -15,6 +15,7 @@ import PageTitle from 'ui/shared/Page/PageTitle';
import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs';
import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton';
import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery'; import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery';
import TxAssetFlows from 'ui/tx/TxAssetFlows';
import TxBlobs from 'ui/tx/TxBlobs'; import TxBlobs from 'ui/tx/TxBlobs';
import TxDetails from 'ui/tx/TxDetails'; import TxDetails from 'ui/tx/TxDetails';
import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded'; import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded';
...@@ -28,6 +29,8 @@ import TxTokenTransfer from 'ui/tx/TxTokenTransfer'; ...@@ -28,6 +29,8 @@ import TxTokenTransfer from 'ui/tx/TxTokenTransfer';
import TxUserOps from 'ui/tx/TxUserOps'; import TxUserOps from 'ui/tx/TxUserOps';
import useTxQuery from 'ui/tx/useTxQuery'; import useTxQuery from 'ui/tx/useTxQuery';
const txInterpretation = config.features.txInterpretation;
const TransactionPageContent = () => { const TransactionPageContent = () => {
const router = useRouter(); const router = useRouter();
const appProps = useAppContext(); const appProps = useAppContext();
...@@ -49,6 +52,9 @@ const TransactionPageContent = () => { ...@@ -49,6 +52,9 @@ const TransactionPageContent = () => {
title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details', title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details',
component: detailsComponent, component: detailsComponent,
}, },
txInterpretation.isEnabled && txInterpretation.provider === 'noves' ?
{ id: 'asset_flows', title: 'Asset Flows', component: <TxAssetFlows hash={ hash }/> } :
undefined,
config.features.suave.isEnabled && data?.wrapped ? config.features.suave.isEnabled && data?.wrapped ?
{ id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } : { id: 'wrapped', title: 'Regular tx details', component: <TxDetailsWrapped data={ data.wrapped }/> } :
undefined, undefined,
......
import { Box, Skeleton } from '@chakra-ui/react';
import type { FC } from 'react';
import React from 'react';
import type { NovesResponseData } from 'types/api/noves';
import type { NovesFlowViewItem } from 'ui/tx/assetFlows/utils/generateFlowViewData';
import Tag from '../chakra/Tag';
import AddressEntity from '../entities/address/AddressEntity';
import { getActionFromTo, getFromTo } from './utils';
interface Props {
isLoaded: boolean;
txData?: NovesResponseData;
currentAddress?: string;
item?: NovesFlowViewItem;
}
const NovesFromTo: FC<Props> = ({ isLoaded, txData, currentAddress = '', item }) => {
const data = React.useMemo(() => {
if (txData) {
return getFromTo(txData, currentAddress);
}
if (item) {
return getActionFromTo(item);
}
return { text: 'Sent to', address: '' };
}, [ currentAddress, item, txData ]);
const isSent = data.text.startsWith('Sent');
const address = { hash: data.address || '', name: data.name || '' };
return (
<Skeleton borderRadius="sm" isLoaded={ isLoaded }>
<Box display="flex">
<Tag
colorScheme={ isSent ? 'yellow' : 'green' }
px={ 0 }
w="113px"
textAlign="center"
>
{ data.text }
</Tag>
<AddressEntity
address={ address }
fontWeight="500"
noCopy={ !data.address }
noLink={ !data.address }
noIcon={ address.name === 'Validators' }
ml={ 2 }
truncation="dynamic"
/>
</Box>
</Skeleton>
);
};
export default NovesFromTo;
import * as transactionMock from 'mocks/noves/transaction';
import type { NovesFlowViewItem } from 'ui/tx/assetFlows/utils/generateFlowViewData';
import { getActionFromTo, getFromTo, getFromToValue } from './utils';
it('get data for FromTo component from transaction', async() => {
const result = getFromTo(transactionMock.transaction, transactionMock.transaction.accountAddress);
expect(result).toEqual({
text: 'Sent to',
address: '0xef6595A423c99f3f2821190A4d96fcE4DcD89a80',
});
});
it('get what type of FromTo component will be', async() => {
const result = getFromToValue(transactionMock.transaction, transactionMock.transaction.accountAddress);
expect(result).toEqual('sent');
});
it('get data for FromTo component from flow item', async() => {
const item: NovesFlowViewItem = {
action: {
label: 'Sent',
amount: '3000',
flowDirection: 'toRight',
nft: undefined,
token: {
address: '0x1bfe4298796198f8664b18a98640cec7c89b5baa',
decimals: 18,
name: 'PQR-Test',
symbol: 'PQR',
},
},
rightActor: {
address: '0xdD15D2650387Fb6FEDE27ae7392C402a393F8A37',
name: null,
},
accountAddress: '0xef6595a423c99f3f2821190a4d96fce4dcd89a80',
};
const result = getActionFromTo(item);
expect(result).toEqual({
text: 'Sent to',
address: '0xdD15D2650387Fb6FEDE27ae7392C402a393F8A37',
name: null,
});
});
import type { NovesResponseData, NovesSentReceived } from 'types/api/noves';
import type { NovesFlowViewItem } from 'ui/tx/assetFlows/utils/generateFlowViewData';
export interface FromToData {
text: string;
address: string;
name?: string | null;
}
export const getFromTo = (txData: NovesResponseData, currentAddress: string): FromToData => {
const raw = txData.rawTransactionData;
const sent = txData.classificationData.sent;
let sentFound: Array<NovesSentReceived> = [];
if (sent && sent[0]) {
sentFound = sent
.filter((sent) => sent.from.address.toLocaleLowerCase() === currentAddress)
.filter((sent) => sent.to.address);
}
const received = txData.classificationData.received;
let receivedFound: Array<NovesSentReceived> = [];
if (received && received[0]) {
receivedFound = received
.filter((received) => received.to.address?.toLocaleLowerCase() === currentAddress)
.filter((received) => received.from.address);
}
if (sentFound[0] && receivedFound[0]) {
if (sentFound.length === receivedFound.length) {
if (raw.toAddress.toLocaleLowerCase() === currentAddress) {
return { text: 'Received from', address: raw.fromAddress };
}
if (raw.fromAddress.toLocaleLowerCase() === currentAddress) {
return { text: 'Sent to', address: raw.toAddress };
}
}
if (sentFound.length > receivedFound.length) {
// already filtered if null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return { text: 'Sent to', address: sentFound[0].to.address! } ;
} else {
return { text: 'Received from', address: receivedFound[0].from.address } ;
}
}
if (sent && sentFound[0]) {
// already filtered if null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return { text: 'Sent to', address: sentFound[0].to.address! } ;
}
if (received && receivedFound[0]) {
return { text: 'Received from', address: receivedFound[0].from.address };
}
if (raw.toAddress && raw.toAddress.toLocaleLowerCase() === currentAddress) {
return { text: 'Received from', address: raw.fromAddress };
}
if (raw.fromAddress && raw.fromAddress.toLocaleLowerCase() === currentAddress) {
return { text: 'Sent to', address: raw.toAddress };
}
if (!raw.toAddress && raw.fromAddress) {
return { text: 'Received from', address: raw.fromAddress };
}
if (!raw.fromAddress && raw.toAddress) {
return { text: 'Sent to', address: raw.toAddress };
}
return { text: 'Sent to', address: currentAddress };
};
export const getFromToValue = (txData: NovesResponseData, currentAddress: string) => {
const fromTo = getFromTo(txData, currentAddress);
return fromTo.text.split(' ').shift()?.toLowerCase();
};
export const getActionFromTo = (item: NovesFlowViewItem): FromToData => {
return {
text: item.action.flowDirection === 'toRight' ? 'Sent to' : 'Received from',
address: item.rightActor.address,
name: item.rightActor.name,
};
};
import { Table, Tbody, Tr, Th, Box, Skeleton, Text, Show, Hide } from '@chakra-ui/react';
import _ from 'lodash';
import React, { useMemo, useState } from 'react';
import type { PaginationParams } from 'ui/shared/pagination/types';
import useApiQuery from 'lib/api/useApiQuery';
import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate';
import ActionBar from 'ui/shared/ActionBar';
import DataListDisplay from 'ui/shared/DataListDisplay';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import Pagination from 'ui/shared/pagination/Pagination';
import TheadSticky from 'ui/shared/TheadSticky';
import TxAssetFlowsListItem from './assetFlows/TxAssetFlowsListItem';
import TxAssetFlowsTableItem from './assetFlows/TxAssetFlowsTableItem';
import { generateFlowViewData } from './assetFlows/utils/generateFlowViewData';
interface FlowViewProps {
hash: string;
}
export default function TxAssetFlows(props: FlowViewProps) {
const { data: queryData, isPlaceholderData, isError } = useApiQuery('noves_transaction', {
pathParams: { hash: props.hash },
queryOptions: {
enabled: Boolean(props.hash),
placeholderData: NOVES_TRANSLATE,
},
});
const [ page, setPage ] = useState<number>(1);
const ViewData = useMemo(() => (queryData ? generateFlowViewData(queryData) : []), [ queryData ]);
const chunkedViewData = _.chunk(ViewData, 50);
const paginationProps: PaginationParams = useMemo(() => ({
onNextPageClick: () => setPage(page + 1),
onPrevPageClick: () => setPage(page - 1),
resetPage: () => setPage(1),
canGoBackwards: page > 1,
isLoading: isPlaceholderData,
page: page,
hasNextPage: Boolean(chunkedViewData[page]),
hasPages: Boolean(chunkedViewData[1]),
isVisible: Boolean(chunkedViewData[1]),
}), [ chunkedViewData, page, isPlaceholderData ]);
const data = chunkedViewData [page - 1];
const actionBar = (
<ActionBar mt={ -6 } pb={{ base: 6, md: 5 }} flexDir={{ base: 'column', md: 'initial' }} gap={{ base: '2', md: 'initial' }} >
<Box display="flex" alignItems="center" gap={ 1 }>
<Skeleton borderRadius="sm" isLoaded={ !isPlaceholderData } >
<Text fontWeight="400" mr={ 1 }>
Wallet
</Text>
</Skeleton>
<AddressEntity
address={{ hash: queryData?.accountAddress || '' }}
fontWeight="400"
truncation="dynamic"
isLoading={ isPlaceholderData }
/>
</Box>
<Pagination ml={{ base: 'auto', lg: 8 }} { ...paginationProps }/>
</ActionBar>
);
const content = (
<>
<Hide above="lg" >
{ data?.map((item, i) => (
<TxAssetFlowsListItem
key={ `${ i }-${ item.accountAddress }` }
item={ item }
isPlaceholderData={ isPlaceholderData }
/>
)) }
</Hide>
<Show above="lg">
<Table variant="simple" size="sm">
<TheadSticky top={ 75 }>
<Tr>
<Th>
Actions
</Th>
<Th width="450px">
From/To
</Th>
</Tr>
</TheadSticky>
<Tbody>
{ data?.map((item, i) => (
<TxAssetFlowsTableItem
key={ `${ i }-${ item.accountAddress }` }
item={ item }
isPlaceholderData={ isPlaceholderData }
/>
)) }
</Tbody>
</Table>
</Show>
</>
);
return (
<DataListDisplay
isError={ isError }
items={ data }
emptyText="There are no transfers."
content={ content }
actionBar={ actionBar }
/>
);
}
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import config from 'configs/app'; import config from 'configs/app';
import useApiQuery from 'lib/api/useApiQuery'; import useApiQuery from 'lib/api/useApiQuery';
import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate';
import { TX_INTERPRETATION } from 'stubs/txInterpretation'; import { TX_INTERPRETATION } from 'stubs/txInterpretation';
import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu';
import { TX_ACTIONS_BLOCK_ID } from 'ui/shared/DetailsActionsWrapper'; import { TX_ACTIONS_BLOCK_ID } from 'ui/shared/DetailsActionsWrapper';
...@@ -10,6 +11,7 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity'; ...@@ -10,6 +11,7 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity';
import NetworkExplorers from 'ui/shared/NetworkExplorers'; import NetworkExplorers from 'ui/shared/NetworkExplorers';
import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation'; import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation';
import { createNovesSummaryObject } from './assetFlows/utils/createNovesSummaryObject';
import type { TxQuery } from './useTxQuery'; import type { TxQuery } from './useTxQuery';
type Props = { type Props = {
...@@ -18,25 +20,50 @@ type Props = { ...@@ -18,25 +20,50 @@ type Props = {
txQuery: TxQuery; txQuery: TxQuery;
} }
const feature = config.features.txInterpretation;
const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => { const TxSubHeading = ({ hash, hasTag, txQuery }: Props) => {
const hasInterpretationFeature = config.features.txInterpretation.isEnabled; const hasInterpretationFeature = feature.isEnabled;
const isNovesInterpretation = hasInterpretationFeature && feature.provider === 'noves';
const txInterpretationQuery = useApiQuery('tx_interpretation', { const txInterpretationQuery = useApiQuery('tx_interpretation', {
pathParams: { hash }, pathParams: { hash },
queryOptions: { queryOptions: {
enabled: Boolean(hash) && hasInterpretationFeature, enabled: Boolean(hash) && (hasInterpretationFeature && !isNovesInterpretation),
placeholderData: TX_INTERPRETATION, placeholderData: TX_INTERPRETATION,
}, },
}); });
const novesInterpretationQuery = useApiQuery('noves_transaction', {
pathParams: { hash },
queryOptions: {
enabled: Boolean(hash) && isNovesInterpretation,
placeholderData: NOVES_TRANSLATE,
},
});
const content = (() => { const content = (() => {
const hasInterpretation = hasInterpretationFeature && const hasNovesInterpretation = isNovesInterpretation &&
(novesInterpretationQuery.isPlaceholderData || Boolean(novesInterpretationQuery.data?.classificationData.description));
const hasInternalInterpretation = (hasInterpretationFeature && !isNovesInterpretation) &&
(txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length)); (txInterpretationQuery.isPlaceholderData || Boolean(txInterpretationQuery.data?.data.summaries.length));
const hasViewAllInterpretationsLink = const hasViewAllInterpretationsLink =
!txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1; !txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1;
if (hasInterpretation) { if (hasNovesInterpretation && novesInterpretationQuery.data) {
const novesSummary = createNovesSummaryObject(novesInterpretationQuery.data);
return (
<TxInterpretation
summary={ novesSummary }
isLoading={ novesInterpretationQuery.isPlaceholderData }
fontSize="lg"
mr={{ base: 0, lg: 6 }}
/>
);
} else if (hasInternalInterpretation) {
return ( return (
<Flex mr={{ base: 0, lg: 6 }} flexWrap="wrap" alignItems="center"> <Flex mr={{ base: 0, lg: 6 }} flexWrap="wrap" alignItems="center">
<TxInterpretation <TxInterpretation
......
import { Box, Skeleton, Text } from '@chakra-ui/react';
import React from 'react';
import IconSvg from 'ui/shared/IconSvg';
import ListItemMobile from 'ui/shared/ListItemMobile/ListItemMobile';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import NovesActionSnippet from './components/NovesActionSnippet';
import type { NovesFlowViewItem } from './utils/generateFlowViewData';
type Props = {
isPlaceholderData: boolean;
item: NovesFlowViewItem;
};
const TxAssetFlowsListItem = (props: Props) => {
return (
<ListItemMobile rowGap={ 4 } w="full" >
<Skeleton borderRadius="sm" isLoaded={ !props.isPlaceholderData } w="full">
<Box display="flex" >
<IconSvg
name="lightning"
height="5"
width="5"
color="text_secondary"
/>
<Text fontSize="sm" fontWeight={ 500 }>
Action
</Text>
</Box>
</Skeleton>
<NovesActionSnippet item={ props.item } isLoaded={ !props.isPlaceholderData }/>
<Box maxW="full">
<NovesFromTo item={ props.item } isLoaded={ !props.isPlaceholderData }/>
</Box>
</ListItemMobile>
);
};
export default React.memo(TxAssetFlowsListItem);
import { Td, Tr } from '@chakra-ui/react';
import React from 'react';
import NovesFromTo from 'ui/shared/Noves/NovesFromTo';
import NovesActionSnippet from './components/NovesActionSnippet';
import type { NovesFlowViewItem } from './utils/generateFlowViewData';
type Props = {
isPlaceholderData: boolean;
item: NovesFlowViewItem;
};
const TxAssetFlowsTableItem = (props: Props) => {
return (
<Tr >
<Td px={ 3 } py={ 5 } fontSize="sm" borderColor="gray.200" _dark={{ borderColor: 'whiteAlpha.200' }}>
<NovesActionSnippet item={ props.item } isLoaded={ !props.isPlaceholderData }/>
</Td>
<Td px={ 3 } py="18px" fontSize="sm" borderColor="gray.200" _dark={{ borderColor: 'whiteAlpha.200' }}>
<NovesFromTo item={ props.item } isLoaded={ !props.isPlaceholderData }/>
</Td>
</Tr>
);
};
export default React.memo(TxAssetFlowsTableItem);
import { Box, Hide, Popover, PopoverArrow, PopoverContent, PopoverTrigger, Show, Skeleton, Text, useColorModeValue } from '@chakra-ui/react';
import type { FC } from 'react';
import React from 'react';
import { HEX_REGEXP } from 'lib/regexp';
import TokenEntity from 'ui/shared/entities/token/TokenEntity';
import IconSvg from 'ui/shared/IconSvg';
import type { NovesFlowViewItem } from '../utils/generateFlowViewData';
import NovesTokenTooltipContent from './NovesTokenTooltipContent';
interface Props {
item: NovesFlowViewItem;
isLoaded: boolean;
}
const NovesActionSnippet: FC<Props> = ({ item, isLoaded }) => {
const popoverBg = useColorModeValue('gray.700', 'gray.300');
const token = React.useMemo(() => {
const action = item.action;
const name = action.nft?.name || action.token?.name;
const symbol = action.nft?.symbol || action.token?.symbol;
const token = {
name: name,
symbol: symbol?.toLowerCase() === name?.toLowerCase() ? undefined : symbol,
address: action.nft?.address || action.token?.address,
};
return token;
}, [ item.action ]);
const validTokenAddress = token.address ? HEX_REGEXP.test(token.address) : false;
return (
<Skeleton borderRadius="sm" isLoaded={ isLoaded }>
<Hide above="lg">
<Box display="flex" gap={ 2 } cursor="pointer" flexWrap="wrap">
<Text fontWeight="700" >
{ item.action.label }
</Text>
<Text fontWeight="500">
{ item.action.amount }
</Text>
<TokenEntity
token={ token }
noCopy
noSymbol
noLink={ !validTokenAddress }
fontWeight="500"
color="link"
w="fit-content"
/>
</Box>
</Hide>
<Show above="lg">
<Popover
trigger="hover"
openDelay={ 50 }
closeDelay={ 50 }
arrowSize={ 15 }
arrowShadowColor="transparent"
placement="bottom"
flip={ false }
>
<PopoverTrigger>
<Box display="flex" gap={ 2 } cursor="pointer" w="fit-content" maxW="100%" alignItems="center">
<IconSvg
name="lightning"
height="5"
width="5"
color="gray.500"
_dark={{ color: 'gray.400' }}
/>
<Text fontWeight="700" >
{ item.action.label }
</Text>
<Text fontWeight="500">
{ item.action.amount }
</Text>
<TokenEntity
token={ token }
noCopy
jointSymbol
noLink={ !validTokenAddress }
fontWeight="500"
color="link"
w="fit-content"
/>
</Box>
</PopoverTrigger>
<PopoverContent
bg={ popoverBg }
shadow="lg"
width="fit-content"
zIndex="modal"
padding={ 2 }
>
<PopoverArrow bg={ popoverBg }/>
<NovesTokenTooltipContent
token={ item.action.token || item.action.nft }
amount={ item.action.amount }
/>
</PopoverContent>
</Popover>
</Show>
</Skeleton>
);
};
export default React.memo(NovesActionSnippet);
import { Box, Text, useColorModeValue } from '@chakra-ui/react';
import type { FC } from 'react';
import React from 'react';
import type { NovesNft, NovesToken } from 'types/api/noves';
import { HEX_REGEXP } from 'lib/regexp';
import CopyToClipboard from 'ui/shared/CopyToClipboard';
interface Props {
amount?: string;
token: NovesToken | NovesNft | undefined;
}
const NovesTokenTooltipContent: FC<Props> = ({ token, amount }) => {
const textColor = useColorModeValue('white', 'blackAlpha.900');
if (!token) {
return null;
}
const showTokenName = token.symbol !== token.name;
const showTokenAddress = HEX_REGEXP.test(token.address);
return (
<Box color={ textColor } display="flex" flexDir="column" alignItems="center" gap={ 1 }>
<Text as="p" color="inherit" fontWeight="600">
<Text color="inherit" as="span">
{ amount }
</Text>
<Text color="inherit" as="span" ml={ 1 }>
{ token.symbol }
</Text>
</Text>
{ showTokenName && (
<Text as="p" color="inherit" fontWeight="600" mt="6px">
{ token.name }
</Text>
) }
{ showTokenAddress && (
<Box display="flex" alignItems="center">
<Text color="inherit" fontWeight="400">
{ token.address }
</Text>
<CopyToClipboard text={ token.address }/>
</Box>
) }
</Box>
);
};
export default React.memo(NovesTokenTooltipContent);
import * as transactionMock from 'mocks/noves/transaction';
import { createNovesSummaryObject } from './createNovesSummaryObject';
it('creates interpretation summary object', async() => {
const result = createNovesSummaryObject(transactionMock.transaction);
expect(result).toEqual({
summary_template: ' Called function \'stake\' on contract{0xef326CdAdA59D3A740A76bB5f4F88Fb2}',
summary_template_variables: {
'0xef326CdAdA59D3A740A76bB5f4F88Fb2': {
type: 'address',
value: {
hash: '0xef326CdAdA59D3A740A76bB5f4F88Fb2f1076164',
is_contract: true,
},
},
},
});
});
import type { NovesResponseData } from 'types/api/noves';
import type { TxInterpretationSummary } from 'types/api/txInterpretation';
import { createAddressValues } from './getAddressValues';
import type { NovesTokenInfo, TokensData } from './getTokensData';
import { getTokensData } from './getTokensData';
export interface SummaryAddress {
hash: string;
name?: string | null;
is_contract?: boolean;
}
export interface SummaryValues {
match: string;
value: NovesTokenInfo | SummaryAddress;
type: 'token' | 'address';
}
interface NovesSummary {
summary_template: string;
summary_template_variables: {[x: string]: unknown};
}
export const createNovesSummaryObject = (translateData: NovesResponseData) => {
// Remove final dot and add space at the start to avoid matching issues
const description = translateData.classificationData.description;
const removedFinalDot = description.endsWith('.') ? description.slice(0, description.length - 1) : description;
let parsedDescription = ' ' + removedFinalDot + ' ';
const tokenData = getTokensData(translateData);
const idsMatched = tokenData.idList.filter(id => parsedDescription.includes(`#${ id }`));
const tokensMatchedByName = tokenData.nameList.filter(name => parsedDescription.toUpperCase().includes(` ${ name.toUpperCase() }`));
let tokensMatchedBySymbol = tokenData.symbolList.filter(symbol => parsedDescription.toUpperCase().includes(` ${ symbol.toUpperCase() }`));
// Filter symbols if they're already matched by name
tokensMatchedBySymbol = tokensMatchedBySymbol.filter(symbol => !tokensMatchedByName.includes(tokenData.bySymbol[symbol]?.name || ''));
const summaryValues: Array<SummaryValues> = [];
if (idsMatched.length) {
parsedDescription = removeIds(tokensMatchedByName, tokensMatchedBySymbol, idsMatched, tokenData, parsedDescription);
}
if (tokensMatchedByName.length) {
const values = createTokensSummaryValues(tokensMatchedByName, tokenData.byName);
summaryValues.push(...values);
}
if (tokensMatchedBySymbol.length) {
const values = createTokensSummaryValues(tokensMatchedBySymbol, tokenData.bySymbol);
summaryValues.push(...values);
}
const addressSummaryValues = createAddressValues(translateData, parsedDescription);
if (addressSummaryValues.length) {
summaryValues.push(...addressSummaryValues);
}
return createSummaryTemplate(summaryValues, parsedDescription) as TxInterpretationSummary;
};
const removeIds = (
tokensMatchedByName: Array<string>,
tokensMatchedBySymbol: Array<string>,
idsMatched: Array<string>,
tokenData: TokensData,
parsedDescription: string,
) => {
// Remove ids from the description since we already have that info in the token object
let description = parsedDescription;
tokensMatchedByName.forEach(name => {
const hasId = idsMatched.includes(tokenData.byName[name].id || '');
if (hasId) {
description = description.replaceAll(`#${ tokenData.byName[name].id }`, '');
}
});
tokensMatchedBySymbol.forEach(name => {
const hasId = idsMatched.includes(tokenData.bySymbol[name].id || '');
if (hasId) {
description = description.replaceAll(`#${ tokenData.bySymbol[name].id }`, '');
}
});
return description;
};
const createTokensSummaryValues = (
matchedStrings: Array<string>,
tokens: {
[x: string]: NovesTokenInfo;
},
) => {
const summaryValues: Array<SummaryValues> = matchedStrings.map(match => ({
match,
type: 'token',
value: tokens[match],
}));
return summaryValues;
};
const createSummaryTemplate = (summaryValues: Array<SummaryValues | undefined>, parsedDescription: string) => {
let newDescription = parsedDescription;
const result: NovesSummary = {
summary_template: newDescription,
summary_template_variables: {},
};
if (!summaryValues[0]) {
return result;
}
const createTemplate = (data: SummaryValues, index = 0) => {
newDescription = newDescription.replaceAll(new RegExp(` ${ data.match } `, 'gi'), `{${ data.match }}`);
const variable = {
type: data.type,
value: data.value,
};
result.summary_template_variables[data.match] = variable;
const nextValue = summaryValues[index + 1];
if (nextValue) {
createTemplate(nextValue, index + 1);
}
};
createTemplate(summaryValues[0]);
result.summary_template = newDescription;
return result;
};
import * as transactionMock from 'mocks/noves/transaction';
import { generateFlowViewData } from './generateFlowViewData';
it('creates asset flows items', async() => {
const result = generateFlowViewData(transactionMock.transaction);
expect(result).toEqual(
[
{
action: {
label: 'Sent',
amount: '3000',
flowDirection: 'toRight',
token: {
address: '0x1bfe4298796198f8664b18a98640cec7c89b5baa',
decimals: 18,
name: 'PQR-Test',
symbol: 'PQR',
},
},
rightActor: {
address: '0xdD15D2650387Fb6FEDE27ae7392C402a393F8A37',
name: null,
},
accountAddress: '0xef6595a423c99f3f2821190a4d96fce4dcd89a80',
},
{
action: {
label: 'Paid Gas',
amount: '0.000395521502109448',
flowDirection: 'toRight',
token: {
address: 'ETH',
decimals: 18,
name: 'ETH',
symbol: 'ETH',
},
},
rightActor: {
address: '',
name: 'Validators',
},
accountAddress: '0xef6595a423c99f3f2821190a4d96fce4dcd89a80',
},
],
);
});
import _ from 'lodash';
import type { NovesNft, NovesResponseData, NovesSentReceived, NovesToken } from 'types/api/noves';
export interface NovesAction {
label: string;
amount: string | undefined;
flowDirection: 'toLeft' | 'toRight';
nft: NovesNft | undefined;
token: NovesToken | undefined;
}
export interface NovesFlowViewItem {
action: NovesAction;
rightActor: {
address: string ;
name: string | null;
};
accountAddress: string;
}
export function generateFlowViewData(data: NovesResponseData): Array<NovesFlowViewItem> {
const perspectiveAddress = data.accountAddress.toLowerCase();
const sent = data.classificationData.sent || [];
const received = data.classificationData.received || [];
const txItems = [ ...sent, ...received ];
const paidGasIndex = _.findIndex(txItems, (item) => item.action === 'paidGas');
if (paidGasIndex >= 0) {
const element = txItems.splice(paidGasIndex, 1)[0];
element.to.name = 'Validators';
txItems.splice(txItems.length, 0, element);
}
const flowViewData = txItems.map((item) => {
const action = {
label: item.actionFormatted || item.action,
amount: item.amount || undefined,
flowDirection: getFlowDirection(item, perspectiveAddress),
nft: item.nft || undefined,
token: item.token || undefined,
};
if (item.from.name && item.from.name.includes('(this wallet)')) {
item.from.name = item.from.name.split('(this wallet)')[0];
}
if (item.to.name && item.to.name.includes('(this wallet)')) {
item.to.name = item.to.name.split('(this wallet)')[0];
}
const rightActor = getRightActor(item, perspectiveAddress);
return { action, rightActor, accountAddress: perspectiveAddress };
});
return flowViewData;
}
function getRightActor(item: NovesSentReceived, perspectiveAddress: string) {
if (!item.to.address || item.to.address.toLowerCase() !== perspectiveAddress) {
return { address: item.to.address || '', name: item.to.name };
}
return { address: item.from.address, name: item.from.name };
}
function getFlowDirection(item: NovesSentReceived, perspectiveAddress: string): 'toLeft' | 'toRight' {
if (item.from.address && item.from.address.toLowerCase() === perspectiveAddress) {
return 'toRight';
}
return 'toLeft';
}
import * as transactionMock from 'mocks/noves/transaction';
import { createAddressValues } from './getAddressValues';
it('creates addresses summary values', async() => {
const result = createAddressValues(transactionMock.transaction, transactionMock.transaction.classificationData.description);
expect(result).toEqual([
{
match: '0xef326CdAdA59D3A740A76bB5f4F88Fb2',
type: 'address',
value: {
hash: '0xef326CdAdA59D3A740A76bB5f4F88Fb2f1076164',
is_contract: true,
},
},
]);
});
import type { NovesResponseData } from 'types/api/noves';
import type { SummaryAddress, SummaryValues } from './createNovesSummaryObject';
const ADDRESS_REGEXP = /(0x[\da-f]+\b)/gi;
const CONTRACT_REGEXP = /(contract 0x[\da-f]+\b)/gi;
export const createAddressValues = (translateData: NovesResponseData, description: string) => {
const addressMatches = description.match(ADDRESS_REGEXP);
const contractMatches = description.match(CONTRACT_REGEXP);
let descriptionAddresses: Array<string> = addressMatches ? addressMatches : [];
let contractAddresses: Array<string> = [];
if (contractMatches?.length) {
contractAddresses = contractMatches.map(text => text.split(ADDRESS_REGEXP)[1]);
descriptionAddresses = addressMatches?.filter(address => !contractAddresses.includes(address)) || [];
}
const addresses = extractAddresses(translateData);
const descriptionSummaryValues = createAddressSummaryValues(descriptionAddresses, addresses);
const contractSummaryValues = createAddressSummaryValues(contractAddresses, addresses, true);
const summaryValues = [ ...descriptionSummaryValues, ...contractSummaryValues ];
return summaryValues;
};
const createAddressSummaryValues = (descriptionAddresses: Array<string>, addresses: Array<SummaryAddress>, isContract = false) => {
const summaryValues: Array<SummaryValues | undefined> = descriptionAddresses.map(match => {
const address = addresses.find(address => address.hash.toUpperCase().startsWith(match.toUpperCase()));
if (!address) {
return undefined;
}
const value: SummaryValues = {
match: match,
type: 'address',
value: isContract ? { ...address, is_contract: true } : address,
};
return value;
});
return summaryValues.filter(value => value !== undefined) as Array<SummaryValues>;
};
function extractAddresses(data: NovesResponseData) {
const addressesSet: Set<{ hash: string | null; name?: string | null }> = new Set(); // Use a Set to store unique addresses
addressesSet.add({ hash: data.rawTransactionData.fromAddress });
addressesSet.add({ hash: data.rawTransactionData.toAddress });
if (data.classificationData.approved) {
addressesSet.add({ hash: data.classificationData.approved.spender });
}
if (data.txTypeVersion === 2) {
data.classificationData.sent.forEach((transaction) => {
addressesSet.add({ hash: transaction.from.address, name: transaction.from.name });
addressesSet.add({ hash: transaction.to.address, name: transaction.to.name });
});
data.classificationData.received.forEach((transaction) => {
addressesSet.add({ hash: transaction.from.address, name: transaction.from.name });
addressesSet.add({ hash: transaction.to.address, name: transaction.to.name });
});
}
const addresses = Array.from(addressesSet) as Array<{hash: string; name?: string}>; // Convert Set to an array
// Remove empty and null values
return addresses.filter(address => address.hash !== null && address.hash !== '');
}
import * as transactionMock from 'mocks/noves/transaction';
import { getTokensData } from './getTokensData';
it('creates a tokens data object', async() => {
const result = getTokensData(transactionMock.transaction);
expect(result).toEqual(transactionMock.tokenData);
});
import _ from 'lodash';
import type { NovesResponseData } from 'types/api/noves';
import type { TokenInfo } from 'types/api/token';
import { HEX_REGEXP } from 'lib/regexp';
export interface NovesTokenInfo extends Pick<TokenInfo, 'address' | 'name' | 'symbol'> {
id?: string | undefined;
}
export interface TokensData {
nameList: Array<string>;
symbolList: Array<string>;
idList: Array<string>;
byName: {
[x: string]: NovesTokenInfo;
};
bySymbol: {
[x: string]: NovesTokenInfo;
};
}
export function getTokensData(data: NovesResponseData): TokensData {
const sent = data.classificationData.sent || [];
const received = data.classificationData.received || [];
const approved = data.classificationData.approved ? [ data.classificationData.approved ] : [];
const txItems = [ ...sent, ...received, ...approved ];
// Extract all tokens data
const tokens = txItems.map((item) => {
const name = item.nft?.name || item.token?.name || null;
const symbol = item.nft?.symbol || item.token?.symbol || null;
const address = item.nft?.address || item.token?.address || '';
const validTokenAddress = address ? HEX_REGEXP.test(address) : false;
const token = {
name: name,
symbol: symbol?.toLowerCase() === name?.toLowerCase() ? null : symbol,
address: validTokenAddress ? address : '',
id: item.nft?.id || item.token?.id,
};
return token;
});
// Group tokens by property into arrays
const tokensGroupByname = _.groupBy(tokens, 'name');
const tokensGroupBySymbol = _.groupBy(tokens, 'symbol');
const tokensGroupById = _.groupBy(tokens, 'id');
// Map properties to an object and remove duplicates
const mappedNames = _.mapValues(tokensGroupByname, (i) => {
return i[0];
});
const mappedSymbols = _.mapValues(tokensGroupBySymbol, (i) => {
return i[0];
});
const mappedIds = _.mapValues(tokensGroupById, (i) => {
return i[0];
});
const filters = [ 'undefined', 'null' ];
// Array of keys to match in string
const nameList = _.keysIn(mappedNames).filter(i => !filters.includes(i));
const symbolList = _.keysIn(mappedSymbols).filter(i => !filters.includes(i));
const idList = _.keysIn(mappedIds).filter(i => !filters.includes(i));
return {
nameList,
symbolList,
idList,
byName: mappedNames,
bySymbol: mappedSymbols,
};
}
import React from 'react';
import type { TransactionType } from 'types/api/transaction';
import Tag from 'ui/shared/chakra/Tag';
import { camelCaseToSentence } from './noves/utils';
import TxType from './TxType';
export interface Props {
types: Array<TransactionType>;
isLoading?: boolean;
translatationType: string | undefined;
}
const TxTranslationType = ({ types, isLoading, translatationType }: Props) => {
const filteredTypes = [ 'unclassified' ];
if (!translatationType || filteredTypes.includes(translatationType)) {
return <TxType types={ types } isLoading={ isLoading }/>;
}
return (
<Tag colorScheme="purple" isLoading={ isLoading }>
{ camelCaseToSentence(translatationType) }
</Tag>
);
};
export default TxTranslationType;
...@@ -10,6 +10,7 @@ import DataListDisplay from 'ui/shared/DataListDisplay'; ...@@ -10,6 +10,7 @@ import DataListDisplay from 'ui/shared/DataListDisplay';
import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages';
import getNextSortValue from 'ui/shared/sort/getNextSortValue'; import getNextSortValue from 'ui/shared/sort/getNextSortValue';
import useDescribeTxs from './noves/useDescribeTxs';
import TxsHeaderMobile from './TxsHeaderMobile'; import TxsHeaderMobile from './TxsHeaderMobile';
import TxsList from './TxsList'; import TxsList from './TxsList';
import TxsTable from './TxsTable'; import TxsTable from './TxsTable';
...@@ -62,7 +63,9 @@ const TxsContent = ({ ...@@ -62,7 +63,9 @@ const TxsContent = ({
setSorting(value); setSorting(value);
}, [ sort, setSorting ]); }, [ sort, setSorting ]);
const content = items ? ( const itemsWithTranslation = useDescribeTxs(items, currentAddress, query.isPlaceholderData);
const content = itemsWithTranslation ? (
<> <>
<Show below="lg" ssr={ false }> <Show below="lg" ssr={ false }>
<TxsList <TxsList
...@@ -73,12 +76,12 @@ const TxsContent = ({ ...@@ -73,12 +76,12 @@ const TxsContent = ({
isLoading={ isPlaceholderData } isLoading={ isPlaceholderData }
enableTimeIncrement={ enableTimeIncrement } enableTimeIncrement={ enableTimeIncrement }
currentAddress={ currentAddress } currentAddress={ currentAddress }
items={ items } items={ itemsWithTranslation }
/> />
</Show> </Show>
<Hide below="lg" ssr={ false }> <Hide below="lg" ssr={ false }>
<TxsTable <TxsTable
txs={ items } txs={ itemsWithTranslation }
sort={ onSortToggle } sort={ onSortToggle }
sorting={ sort } sorting={ sort }
showBlockInfo={ showBlockInfo } showBlockInfo={ showBlockInfo }
...@@ -116,7 +119,7 @@ const TxsContent = ({ ...@@ -116,7 +119,7 @@ const TxsContent = ({
return ( return (
<DataListDisplay <DataListDisplay
isError={ isError } isError={ isError }
items={ items } items={ itemsWithTranslation }
emptyText="There are no transactions." emptyText="There are no transactions."
content={ content } content={ content }
actionBar={ actionBar } actionBar={ actionBar }
......
...@@ -22,6 +22,8 @@ import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; ...@@ -22,6 +22,8 @@ import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TxType from 'ui/txs/TxType'; import TxType from 'ui/txs/TxType';
import TxTranslationType from './TxTranslationType';
type Props = { type Props = {
tx: Transaction; tx: Transaction;
showBlockInfo: boolean; showBlockInfo: boolean;
...@@ -39,7 +41,10 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI ...@@ -39,7 +41,10 @@ const TxsListItem = ({ tx, isLoading, showBlockInfo, currentAddress, enableTimeI
<ListItemMobile display="block" width="100%" isAnimated key={ tx.hash }> <ListItemMobile display="block" width="100%" isAnimated key={ tx.hash }>
<Flex justifyContent="space-between" mt={ 4 }> <Flex justifyContent="space-between" mt={ 4 }>
<HStack flexWrap="wrap"> <HStack flexWrap="wrap">
{ tx.translation ?
<TxTranslationType types={ tx.tx_types } isLoading={ isLoading || tx.translation.isLoading } translatationType={ tx.translation.data?.type }/> :
<TxType types={ tx.tx_types } isLoading={ isLoading }/> <TxType types={ tx.tx_types } isLoading={ isLoading }/>
}
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined } isLoading={ isLoading }/> <TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined } isLoading={ isLoading }/>
<TxWatchListTags tx={ tx } isLoading={ isLoading }/> <TxWatchListTags tx={ tx } isLoading={ isLoading }/>
</HStack> </HStack>
......
...@@ -21,6 +21,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability'; ...@@ -21,6 +21,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability';
import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags';
import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo';
import TxTranslationType from './TxTranslationType';
import TxType from './TxType'; import TxType from './TxType';
type Props = { type Props = {
...@@ -62,7 +63,10 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement, ...@@ -62,7 +63,10 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement,
</Td> </Td>
<Td> <Td>
<VStack alignItems="start"> <VStack alignItems="start">
{ tx.translation ?
<TxTranslationType types={ tx.tx_types } isLoading={ isLoading || tx.translation.isLoading } translatationType={ tx.translation.data?.type }/> :
<TxType types={ tx.tx_types } isLoading={ isLoading }/> <TxType types={ tx.tx_types } isLoading={ isLoading }/>
}
<TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined } isLoading={ isLoading }/> <TxStatus status={ tx.status } errorText={ tx.status === 'error' ? tx.result : undefined } isLoading={ isLoading }/>
<TxWatchListTags tx={ tx } isLoading={ isLoading }/> <TxWatchListTags tx={ tx } isLoading={ isLoading }/>
</VStack> </VStack>
......
import { useQuery } from '@tanstack/react-query';
import _ from 'lodash';
import React from 'react';
import type { NovesDescribeTxsResponse } from 'types/api/noves';
import type { Transaction } from 'types/api/transaction';
import config from 'configs/app';
import useApiFetch from 'lib/api/useApiFetch';
const feature = config.features.txInterpretation;
const translateEnabled = feature.isEnabled && feature.provider === 'noves';
export default function useDescribeTxs(items: Array<Transaction> | undefined, viewAsAccountAddress: string | undefined, isPlaceholderData: boolean) {
const apiFetch = useApiFetch();
const txsHash = _.uniq(items?.map(i => i.hash));
const txChunks = _.chunk(txsHash, 10);
const queryKey = {
viewAsAccountAddress,
firstHash: txsHash[0] || '',
lastHash: txsHash[txsHash.length - 1] || '',
};
const describeQuery = useQuery({
queryKey: [ 'noves_describe_txs', queryKey ],
queryFn: async() => {
const queries = txChunks.map((hashes) => {
if (hashes.length === 0) {
return Promise.resolve([]);
}
return apiFetch('noves_describe_txs', {
queryParams: {
viewAsAccountAddress,
hashes,
},
}) as Promise<NovesDescribeTxsResponse>;
});
return Promise.all(queries);
},
select: (data) => {
return data.flat();
},
enabled: translateEnabled && !isPlaceholderData,
});
const itemsWithTranslation = React.useMemo(() => items?.map(tx => {
const queryData = describeQuery.data;
const isLoading = describeQuery.isLoading;
if (isLoading) {
return {
...tx,
translation: {
isLoading,
},
};
}
if (!queryData || !translateEnabled) {
return tx;
}
const query = queryData.find(data => data.txHash.toLowerCase() === tx.hash.toLowerCase());
if (query) {
return {
...tx,
translation: {
data: query,
isLoading: false,
},
};
}
return tx;
}), [ items, describeQuery ]);
if (!translateEnabled || isPlaceholderData) {
return items;
}
// return same "items" array of Transaction with a new "translation" field.
return itemsWithTranslation;
}
export function camelCaseToSentence(camelCaseString: string | undefined) {
if (!camelCaseString) {
return '';
}
let sentence = camelCaseString.replace(/([a-z])([A-Z])/g, '$1 $2');
sentence = sentence.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2');
sentence = sentence.charAt(0).toUpperCase() + sentence.slice(1);
return sentence;
}
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