diff --git a/deploy/values/review/values.yaml.gotmpl b/deploy/values/review/values.yaml.gotmpl index a703bbc4ae..110d58ca08 100644 --- a/deploy/values/review/values.yaml.gotmpl +++ b/deploy/values/review/values.yaml.gotmpl @@ -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_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_TRANSACTION_INTERPRETATION_PROVIDER: noves NEXT_PUBLIC_SWAP_BUTTON_URL: uniswap NEXT_PUBLIC_HAS_CONTRACT_AUDIT_REPORTS: true NEXT_PUBLIC_AD_BANNER_PROVIDER: getit diff --git a/docs/ENVS.md b/docs/ENVS.md index fbdff1e706..5db4f1865d 100644 --- a/docs/ENVS.md +++ b/docs/ENVS.md @@ -524,7 +524,7 @@ This feature is **enabled by default** with the `['metamask']` value. To switch | 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` |   diff --git a/icons/gas_xl.svg b/icons/gas_xl.svg index a3c436b5d4..5a3913ac16 100644 --- a/icons/gas_xl.svg +++ b/icons/gas_xl.svg @@ -1,3 +1,3 @@ - + diff --git a/icons/lightning.svg b/icons/lightning.svg index 91b1ae92ca..03fea73d75 100644 --- a/icons/lightning.svg +++ b/icons/lightning.svg @@ -1,3 +1,3 @@ - - + + diff --git a/lib/api/resources.ts b/lib/api/resources.ts index a124e65bbb..fff2b5249f 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -56,6 +56,7 @@ import type { import type { IndexingStatus } from 'types/api/indexingStatus'; import type { InternalTransactionsResponse } from 'types/api/internalTransaction'; import type { LogsResponseTx, LogsResponseAddress } from 'types/api/log'; +import type { NovesAccountHistoryResponse, NovesDescribeTxsResponse, NovesResponseData } from 'types/api/noves'; import type { OptimisticL2DepositsResponse, OptimisticL2DepositsItem, @@ -649,6 +650,20 @@ export const RESOURCES = { 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: { path: '/api/v2/proxy/account-abstraction/operations', @@ -757,7 +772,7 @@ export type PaginatedResources = 'blocks' | 'block_txs' | 'zkevm_l2_txn_batches' | 'zkevm_l2_txn_batch_txs' | 'withdrawals' | 'address_withdrawals' | 'block_withdrawals' | '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 = ResourcePayload; @@ -886,6 +901,9 @@ Q extends 'user_ops' ? UserOpsResponse : Q extends 'user_op' ? UserOp : Q extends 'user_ops_account' ? UserOpsAccount : Q extends 'user_op_interpretation'? TxInterpretationResponse : +Q extends 'noves_transaction' ? NovesResponseData : +Q extends 'noves_address_history' ? NovesAccountHistoryResponse : +Q extends 'noves_describe_txs' ? NovesDescribeTxsResponse : never; /* eslint-enable @typescript-eslint/indent */ diff --git a/mocks/noves/transaction.ts b/mocks/noves/transaction.ts new file mode 100644 index 0000000000..6feb72a564 --- /dev/null +++ b/mocks/noves/transaction.ts @@ -0,0 +1,103 @@ +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 }, + }, +}; diff --git a/nextjs/nextjs-routes.d.ts b/nextjs/nextjs-routes.d.ts index ee87eb5dc1..eb2d95e541 100644 --- a/nextjs/nextjs-routes.d.ts +++ b/nextjs/nextjs-routes.d.ts @@ -21,11 +21,12 @@ declare module "nextjs-routes" { | StaticRoute<"/api/media-type"> | StaticRoute<"/api/proxy"> | StaticRoute<"/api-docs"> - | DynamicRoute<"/apps/[id]", { "id": string }> | StaticRoute<"/apps"> + | DynamicRoute<"/apps/[id]", { "id": string }> | StaticRoute<"/auth/auth0"> | StaticRoute<"/auth/profile"> | StaticRoute<"/auth/unverified-email"> + | StaticRoute<"/batches"> | DynamicRoute<"/batches/[number]", { "number": string }> | StaticRoute<"/batches"> | DynamicRoute<"/blobs/[hash]", { "hash": string }> @@ -38,8 +39,8 @@ declare module "nextjs-routes" { | StaticRoute<"/graphiql"> | StaticRoute<"/"> | StaticRoute<"/login"> - | DynamicRoute<"/name-domains/[name]", { "name": string }> | StaticRoute<"/name-domains"> + | DynamicRoute<"/name-domains/[name]", { "name": string }> | DynamicRoute<"/op/[hash]", { "hash": string }> | StaticRoute<"/ops"> | StaticRoute<"/output-roots"> diff --git a/stubs/noves/NovesTranslate.ts b/stubs/noves/NovesTranslate.ts new file mode 100644 index 0000000000..848ed6dab9 --- /dev/null +++ b/stubs/noves/NovesTranslate.ts @@ -0,0 +1,43 @@ +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, +}; diff --git a/types/api/noves.ts b/types/api/noves.ts new file mode 100644 index 0000000000..1d4b396716 --- /dev/null +++ b/types/api/noves.ts @@ -0,0 +1,124 @@ +export interface NovesResponseData { + txTypeVersion: number; + chain: string; + accountAddress: string; + classificationData: NovesClassificationData; + rawTransactionData: NovesRawTransactionData; +} + +export interface NovesClassificationData { + type: string; + typeFormatted?: string; + description: string; + sent: Array; + received: Array; + 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; + 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; +} diff --git a/types/api/transaction.ts b/types/api/transaction.ts index 53ce2ead22..3901c8f11b 100644 --- a/types/api/transaction.ts +++ b/types/api/transaction.ts @@ -2,6 +2,7 @@ import type { AddressParam } from './addressParams'; import type { BlockTransactionsResponse } from './block'; import type { DecodedInput } from './decodedInput'; import type { Fee } from './fee'; +import type { NovesTxTranslation } from './noves'; import type { OptimisticL2WithdrawalStatus } from './optimisticL2'; import type { TokenInfo } from './token'; import type { TokenTransfer } from './tokenTransfer'; @@ -85,6 +86,8 @@ export type Transaction = { blob_gas_price?: string; burnt_blob_fee?: string; max_fee_per_blob_gas?: string; + // Noves-fi + translation?: NovesTxTranslation; } export const ZKEVM_L2_TX_STATUSES = [ 'Confirmed by Sequencer', 'L1 Confirmed' ]; diff --git a/types/client/txInterpretation.ts b/types/client/txInterpretation.ts index e264b267bc..23f55ed217 100644 --- a/types/client/txInterpretation.ts +++ b/types/client/txInterpretation.ts @@ -2,6 +2,7 @@ import type { ArrayElement } from 'types/utils'; export const PROVIDERS = [ 'blockscout', + 'noves', 'none', ] as const; diff --git a/ui/address/AddressAccountHistory.tsx b/ui/address/AddressAccountHistory.tsx new file mode 100644 index 0000000000..5b7cf3aa7e --- /dev/null +++ b/ui/address/AddressAccountHistory.tsx @@ -0,0 +1,125 @@ +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).bind(null, NovesHistoryFilterValues); + +type Props = { + scrollRef?: React.RefObject; +} + +const AddressAccountHistory = ({ scrollRef }: Props) => { + const router = useRouter(); + + const currentAddress = getQueryParamString(router.query.hash).toLowerCase(); + + const [ filterValue, setFilterValue ] = React.useState(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) => { + + const newVal = getFilterValue(val); + setFilterValue(newVal); + }, [ ]); + + const actionBar = ( + + + + + + ); + + const filteredData = isPlaceholderData ? data?.items : data?.items.filter(i => filterValue ? getFromToValue(i, currentAddress) === filterValue : i); + + const content = ( + + + { filteredData?.map((item, i) => ( + + )) } + + + + + + + + + + + + + { filteredData?.map((item, i) => ( + + )) } + +
+ Age + + Action + + From/To +
+ + + ); + + return ( + + ); +}; + +export default AddressAccountHistory; diff --git a/ui/address/AddressAccountHistoryFilter.tsx b/ui/address/AddressAccountHistoryFilter.tsx new file mode 100644 index 0000000000..d66519d635 --- /dev/null +++ b/ui/address/AddressAccountHistoryFilter.tsx @@ -0,0 +1,55 @@ +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) => 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 ( + + + + + + + All + Received from + Sent to + + + + ); +}; + +export default React.memo(AccountHistoryFilter); diff --git a/ui/address/accountHistory/AddressAccountHistoryListItem.tsx b/ui/address/accountHistory/AddressAccountHistoryListItem.tsx new file mode 100644 index 0000000000..46cf969932 --- /dev/null +++ b/ui/address/accountHistory/AddressAccountHistoryListItem.tsx @@ -0,0 +1,66 @@ +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 ( + + + + + + + + Action + + + + { dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } + + + + + + { parsedDescription } + + + + + + + + ); +}; + +export default React.memo(AddressAccountHistoryListItem); diff --git a/ui/address/accountHistory/AddressAccountHistoryTableItem.tsx b/ui/address/accountHistory/AddressAccountHistoryTableItem.tsx new file mode 100644 index 0000000000..c3aa61a283 --- /dev/null +++ b/ui/address/accountHistory/AddressAccountHistoryTableItem.tsx @@ -0,0 +1,66 @@ +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 ( + + + + + { dayjs(props.tx.rawTransactionData.timestamp * 1000).fromNow() } + + + + + + + + + + { parsedDescription } + + + + + + + + + + + ); +}; + +export default React.memo(AddressAccountHistoryTableItem); diff --git a/ui/pages/Address.tsx b/ui/pages/Address.tsx index 96a8a581b3..9f3d0b4da6 100644 --- a/ui/pages/Address.tsx +++ b/ui/pages/Address.tsx @@ -12,6 +12,7 @@ import useIsSafeAddress from 'lib/hooks/useIsSafeAddress'; import getQueryParamString from 'lib/router/getQueryParamString'; import { ADDRESS_TABS_COUNTERS } from 'stubs/address'; import { USER_OPS_ACCOUNT } from 'stubs/userOps'; +import AddressAccountHistory from 'ui/address/AddressAccountHistory'; import AddressBlocksValidated from 'ui/address/AddressBlocksValidated'; import AddressCoinBalance from 'ui/address/AddressCoinBalance'; import AddressContract from 'ui/address/AddressContract'; @@ -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 txInterpretation = config.features.txInterpretation; + const AddressPageContent = () => { const router = useRouter(); const appProps = useAppContext(); @@ -80,6 +83,13 @@ const AddressPageContent = () => { count: addressTabsCountersQuery.data?.transactions_count, component: , }, + txInterpretation.isEnabled && txInterpretation.provider === 'noves' ? + { + id: 'account_history', + title: 'Account history', + component: , + } : + undefined, config.features.userOps.isEnabled && Boolean(userOpsAccountQuery.data?.total_ops) ? { id: 'user_ops', diff --git a/ui/pages/Transaction.tsx b/ui/pages/Transaction.tsx index 07ccc1d687..a41b8addcd 100644 --- a/ui/pages/Transaction.tsx +++ b/ui/pages/Transaction.tsx @@ -15,6 +15,7 @@ import PageTitle from 'ui/shared/Page/PageTitle'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; import TabsSkeleton from 'ui/shared/Tabs/TabsSkeleton'; import useTabIndexFromQuery from 'ui/shared/Tabs/useTabIndexFromQuery'; +import TxAssetFlows from 'ui/tx/TxAssetFlows'; import TxBlobs from 'ui/tx/TxBlobs'; import TxDetails from 'ui/tx/TxDetails'; import TxDetailsDegraded from 'ui/tx/TxDetailsDegraded'; @@ -28,6 +29,8 @@ import TxTokenTransfer from 'ui/tx/TxTokenTransfer'; import TxUserOps from 'ui/tx/TxUserOps'; import useTxQuery from 'ui/tx/useTxQuery'; +const txInterpretation = config.features.txInterpretation; + const TransactionPageContent = () => { const router = useRouter(); const appProps = useAppContext(); @@ -49,6 +52,9 @@ const TransactionPageContent = () => { title: config.features.suave.isEnabled && data?.wrapped ? 'Confidential compute tx details' : 'Details', component: detailsComponent, }, + txInterpretation.isEnabled && txInterpretation.provider === 'noves' ? + { id: 'asset_flows', title: 'Asset Flows', component: } : + undefined, config.features.suave.isEnabled && data?.wrapped ? { id: 'wrapped', title: 'Regular tx details', component: } : undefined, diff --git a/ui/shared/Noves/NovesFromTo.tsx b/ui/shared/Noves/NovesFromTo.tsx new file mode 100644 index 0000000000..db1ea47205 --- /dev/null +++ b/ui/shared/Noves/NovesFromTo.tsx @@ -0,0 +1,62 @@ +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 = ({ 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 ( + + + + { data.text } + + + + + + ); +}; + +export default NovesFromTo; diff --git a/ui/shared/Noves/utils.test.ts b/ui/shared/Noves/utils.test.ts new file mode 100644 index 0000000000..52bdc97a9c --- /dev/null +++ b/ui/shared/Noves/utils.test.ts @@ -0,0 +1,49 @@ +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, + }); +}); diff --git a/ui/shared/Noves/utils.ts b/ui/shared/Noves/utils.ts new file mode 100644 index 0000000000..9e0bf88677 --- /dev/null +++ b/ui/shared/Noves/utils.ts @@ -0,0 +1,89 @@ +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 = []; + 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 = []; + 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, + }; +}; diff --git a/ui/tx/TxAssetFlows.tsx b/ui/tx/TxAssetFlows.tsx new file mode 100644 index 0000000000..931332151b --- /dev/null +++ b/ui/tx/TxAssetFlows.tsx @@ -0,0 +1,119 @@ +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(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 = ( + + + + + Wallet + + + + + + + + ); + + const content = ( + <> + + { data?.map((item, i) => ( + + )) } + + + + + + + + + + + + { data?.map((item, i) => ( + + )) } + +
+ Actions + + From/To +
+
+ + ); + + return ( + + ); +} diff --git a/ui/tx/TxSubHeading.tsx b/ui/tx/TxSubHeading.tsx index 49976765ef..7ee1336512 100644 --- a/ui/tx/TxSubHeading.tsx +++ b/ui/tx/TxSubHeading.tsx @@ -3,6 +3,7 @@ import React from 'react'; import config from 'configs/app'; import useApiQuery from 'lib/api/useApiQuery'; +import { NOVES_TRANSLATE } from 'stubs/noves/NovesTranslate'; import { TX_INTERPRETATION } from 'stubs/txInterpretation'; import AccountActionsMenu from 'ui/shared/AccountActionsMenu/AccountActionsMenu'; import { TX_ACTIONS_BLOCK_ID } from 'ui/shared/DetailsActionsWrapper'; @@ -10,6 +11,7 @@ import TxEntity from 'ui/shared/entities/tx/TxEntity'; import NetworkExplorers from 'ui/shared/NetworkExplorers'; import TxInterpretation from 'ui/shared/tx/interpretation/TxInterpretation'; +import { createNovesSummaryObject } from './assetFlows/utils/createNovesSummaryObject'; import type { TxQuery } from './useTxQuery'; type Props = { @@ -18,25 +20,50 @@ type Props = { txQuery: TxQuery; } +const feature = config.features.txInterpretation; + 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', { pathParams: { hash }, queryOptions: { - enabled: Boolean(hash) && hasInterpretationFeature, + enabled: Boolean(hash) && (hasInterpretationFeature && !isNovesInterpretation), placeholderData: TX_INTERPRETATION, }, }); + const novesInterpretationQuery = useApiQuery('noves_transaction', { + pathParams: { hash }, + queryOptions: { + enabled: Boolean(hash) && isNovesInterpretation, + placeholderData: NOVES_TRANSLATE, + }, + }); + 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)); const hasViewAllInterpretationsLink = !txInterpretationQuery.isPlaceholderData && txInterpretationQuery.data?.data.summaries && txInterpretationQuery.data?.data.summaries.length > 1; - if (hasInterpretation) { + if (hasNovesInterpretation && novesInterpretationQuery.data) { + const novesSummary = createNovesSummaryObject(novesInterpretationQuery.data); + + return ( + + ); + } else if (hasInternalInterpretation) { return ( { + + return ( + + + + + + + + Action + + + + + + + + + + + + ); +}; + +export default React.memo(TxAssetFlowsListItem); diff --git a/ui/tx/assetFlows/TxAssetFlowsTableItem.tsx b/ui/tx/assetFlows/TxAssetFlowsTableItem.tsx new file mode 100644 index 0000000000..0e2056b038 --- /dev/null +++ b/ui/tx/assetFlows/TxAssetFlowsTableItem.tsx @@ -0,0 +1,28 @@ +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 ( + + + + + + + + + ); +}; + +export default React.memo(TxAssetFlowsTableItem); diff --git a/ui/tx/assetFlows/components/NovesActionSnippet.tsx b/ui/tx/assetFlows/components/NovesActionSnippet.tsx new file mode 100644 index 0000000000..c1acc6b0db --- /dev/null +++ b/ui/tx/assetFlows/components/NovesActionSnippet.tsx @@ -0,0 +1,116 @@ +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 = ({ 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 ( + + + + + { item.action.label } + + + { item.action.amount } + + + + + + + + + + + + { item.action.label } + + + { item.action.amount } + + + + + + + + + + + + + + ); +}; + +export default React.memo(NovesActionSnippet); diff --git a/ui/tx/assetFlows/components/NovesTokenTooltipContent.tsx b/ui/tx/assetFlows/components/NovesTokenTooltipContent.tsx new file mode 100644 index 0000000000..f2bebb068c --- /dev/null +++ b/ui/tx/assetFlows/components/NovesTokenTooltipContent.tsx @@ -0,0 +1,55 @@ +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 = ({ 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 ( + + + + { amount } + + + { token.symbol } + + + + { showTokenName && ( + + { token.name } + + ) } + + { showTokenAddress && ( + + + { token.address } + + + + ) } + + + ); +}; + +export default React.memo(NovesTokenTooltipContent); diff --git a/ui/tx/assetFlows/utils/createNovesSummaryObject.test.ts b/ui/tx/assetFlows/utils/createNovesSummaryObject.test.ts new file mode 100644 index 0000000000..35275d22bc --- /dev/null +++ b/ui/tx/assetFlows/utils/createNovesSummaryObject.test.ts @@ -0,0 +1,20 @@ +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, + }, + }, + }, + }); +}); diff --git a/ui/tx/assetFlows/utils/createNovesSummaryObject.ts b/ui/tx/assetFlows/utils/createNovesSummaryObject.ts new file mode 100644 index 0000000000..81c2e956f0 --- /dev/null +++ b/ui/tx/assetFlows/utils/createNovesSummaryObject.ts @@ -0,0 +1,139 @@ +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 = []; + + 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, + tokensMatchedBySymbol: Array, + idsMatched: Array, + 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, + tokens: { + [x: string]: NovesTokenInfo; + }, +) => { + const summaryValues: Array = matchedStrings.map(match => ({ + match, + type: 'token', + value: tokens[match], + })); + + return summaryValues; +}; + +const createSummaryTemplate = (summaryValues: Array, 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; +}; diff --git a/ui/tx/assetFlows/utils/generateFlowViewData.test.ts b/ui/tx/assetFlows/utils/generateFlowViewData.test.ts new file mode 100644 index 0000000000..0cbb2770dd --- /dev/null +++ b/ui/tx/assetFlows/utils/generateFlowViewData.test.ts @@ -0,0 +1,48 @@ +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', + }, + ], + ); +}); diff --git a/ui/tx/assetFlows/utils/generateFlowViewData.ts b/ui/tx/assetFlows/utils/generateFlowViewData.ts new file mode 100644 index 0000000000..9564c574d8 --- /dev/null +++ b/ui/tx/assetFlows/utils/generateFlowViewData.ts @@ -0,0 +1,76 @@ +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 { + 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'; +} diff --git a/ui/tx/assetFlows/utils/getAddressValues.test.ts b/ui/tx/assetFlows/utils/getAddressValues.test.ts new file mode 100644 index 0000000000..f911253cce --- /dev/null +++ b/ui/tx/assetFlows/utils/getAddressValues.test.ts @@ -0,0 +1,18 @@ +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, + }, + }, + ]); +}); diff --git a/ui/tx/assetFlows/utils/getAddressValues.ts b/ui/tx/assetFlows/utils/getAddressValues.ts new file mode 100644 index 0000000000..9588a44392 --- /dev/null +++ b/ui/tx/assetFlows/utils/getAddressValues.ts @@ -0,0 +1,76 @@ +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 = addressMatches ? addressMatches : []; + let contractAddresses: Array = []; + + 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, addresses: Array, isContract = false) => { + const summaryValues: Array = 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; +}; + +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 !== ''); +} diff --git a/ui/tx/assetFlows/utils/getTokensData.test.ts b/ui/tx/assetFlows/utils/getTokensData.test.ts new file mode 100644 index 0000000000..37124e000c --- /dev/null +++ b/ui/tx/assetFlows/utils/getTokensData.test.ts @@ -0,0 +1,9 @@ +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); +}); diff --git a/ui/tx/assetFlows/utils/getTokensData.ts b/ui/tx/assetFlows/utils/getTokensData.ts new file mode 100644 index 0000000000..2b14c9231c --- /dev/null +++ b/ui/tx/assetFlows/utils/getTokensData.ts @@ -0,0 +1,80 @@ +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 { + id?: string | undefined; +} + +export interface TokensData { + nameList: Array; + symbolList: Array; + idList: Array; + 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, + }; +} diff --git a/ui/txs/TxTranslationType.tsx b/ui/txs/TxTranslationType.tsx new file mode 100644 index 0000000000..4086efb80f --- /dev/null +++ b/ui/txs/TxTranslationType.tsx @@ -0,0 +1,32 @@ +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; + isLoading?: boolean; + translatationType: string | undefined; +} + +const TxTranslationType = ({ types, isLoading, translatationType }: Props) => { + + const filteredTypes = [ 'unclassified' ]; + + if (!translatationType || filteredTypes.includes(translatationType)) { + return ; + } + + return ( + + { camelCaseToSentence(translatationType) } + + ); + +}; + +export default TxTranslationType; diff --git a/ui/txs/TxsContent.tsx b/ui/txs/TxsContent.tsx index a7011e033c..44f687afc5 100644 --- a/ui/txs/TxsContent.tsx +++ b/ui/txs/TxsContent.tsx @@ -10,6 +10,7 @@ import DataListDisplay from 'ui/shared/DataListDisplay'; import type { QueryWithPagesResult } from 'ui/shared/pagination/useQueryWithPages'; import getNextSortValue from 'ui/shared/sort/getNextSortValue'; +import useDescribeTxs from './noves/useDescribeTxs'; import TxsHeaderMobile from './TxsHeaderMobile'; import TxsList from './TxsList'; import TxsTable from './TxsTable'; @@ -62,7 +63,9 @@ const TxsContent = ({ setSorting(value); }, [ sort, setSorting ]); - const content = items ? ( + const itemsWithTranslation = useDescribeTxs(items, currentAddress, query.isPlaceholderData); + + const content = itemsWithTranslation ? ( <> - + { tx.translation ? + : + + } diff --git a/ui/txs/TxsTableItem.tsx b/ui/txs/TxsTableItem.tsx index b1a4121cf5..216003bfeb 100644 --- a/ui/txs/TxsTableItem.tsx +++ b/ui/txs/TxsTableItem.tsx @@ -21,6 +21,7 @@ import TxFeeStability from 'ui/shared/tx/TxFeeStability'; import TxWatchListTags from 'ui/shared/tx/TxWatchListTags'; import TxAdditionalInfo from 'ui/txs/TxAdditionalInfo'; +import TxTranslationType from './TxTranslationType'; import TxType from './TxType'; type Props = { @@ -62,7 +63,10 @@ const TxsTableItem = ({ tx, showBlockInfo, currentAddress, enableTimeIncrement, - + { tx.translation ? + : + + } diff --git a/ui/txs/noves/useDescribeTxs.tsx b/ui/txs/noves/useDescribeTxs.tsx new file mode 100644 index 0000000000..d9081492cd --- /dev/null +++ b/ui/txs/noves/useDescribeTxs.tsx @@ -0,0 +1,89 @@ +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 | 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; + }); + + 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; +} diff --git a/ui/txs/noves/utils.ts b/ui/txs/noves/utils.ts new file mode 100644 index 0000000000..c5a986b66a --- /dev/null +++ b/ui/txs/noves/utils.ts @@ -0,0 +1,11 @@ +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; +}