From 54c2aaca6041d9e83de42040cd88af8ab131525a Mon Sep 17 00:00:00 2001 From: Leandro Date: Wed, 28 Jun 2023 08:14:28 -0700 Subject: [PATCH 1/2] feat(batch-graph): add new batch graph visualization (#534) * Hackaton/tenderly data loading (#530) * chore: add getTransactionTrace and getTransactionContracts tenderly api fns * chore: add hook to fetch unfiltered tx data * Hackaton/nodes and edges (#531) * refactor: rename `traceToTransfersAndTrades` * chore: export all types from tenderly from index * feat: add getContractTrades * feat: add getNotesAndEdges * fix: use buyToken address as a key for the buyToken address * Hackaton/update graph (#532) * refactor: a tiny bit of clean up on useTxBatchTrades * chore: update graph WIP Messy commit, lots to describe and update still * fix: edge colors now match user/amm interactions * chore: pass down transfers to Node objs * chore: use different node types for dex vs token nodes * chore: use a different icon for token nodes * chore: capture eth sell transfers * refactor: remove repeated variable * chore: add map of wrapped native token addresses * chore: add link to hyper nodes * chore: show address and link when token has no symbol * chore: actually, always show link for all nodes * chore: improve edge labels * chore: add tooltip to edges * chore: replace native token node with wrapped * ore: add node type hyper * chore: add dynamic stylesheet for token images * chore: add token images to graph * filter contract trades which only route tokens (#533) --------- Co-authored-by: Felix Henneke * chore: add circle and concentric layouts (#535) * Hackathon/integrate both visualizations (#537) * refactor: rename const to all caps * chore: wire up everything -- still WIP * chore: more a ton of refactorings, still not working * fix: enum comparison of same type was failing because one was a string the other a number * chore: update layout once visualization changes * fix: use proper type * chore: remove debug statements * refactor: remove unused hooks * refactor: move buildContractBasedSettlement to TransactionBatchGraph * refactor: move buildTokenBasedSettlement into settlementBuilder * refactor: move node builder fns into nodesBuilder file * refactor: move types to types file * refactor: move hooks to hooks file * chore: cache tenderly fetched data * refactor: rename contract->transfer token->trades * Merge edges for hypernodes + node tooltips (#536) * added tooltip for nodes showing balance of token * show dangeling edges * merge transfers for hyper nodes * use order information to filter for non-user trades * clean up address comments - made isInternal in Transfer optional - better sign for 0 in tool tip - tooltip can be undefined. does still render stuff though. * refactor: remove empty folder/file * fix: do not create tooltip if it doesn't exist * fix: adjust tooltip header width * refactor: simplify styles * fix: improve grid layout for trade based view * fix: always use bezier style edges for trade based view * refactor: simplify styles further * chore: make concentric layout further spread out * refactor: sort imports * chore: load tokens which are not part of any order * chore: start simple graphs in grid layout * fix: reset zoom when visualization change and keep same layout * fix: change filter logic from !== 1 to > 1 * fix: add dangling nodes back * fix: add padding on top of graph to not overlap buttons --------- Co-authored-by: Felix Henneke --- src/api/tenderly/index.ts | 1 + src/api/tenderly/tenderlyApi.ts | 35 +- src/api/tenderly/types.ts | 2 +- .../TransactionsTableWidget/index.tsx | 9 +- .../elementsBuilder.tsx | 12 +- .../TransanctionBatchGraph/hooks.ts | 208 ++++++- .../TransanctionBatchGraph/index.tsx | 110 +++- .../TransanctionBatchGraph/layouts.ts | 34 +- .../TransanctionBatchGraph/nodesBuilder.ts | 506 ++++++++++++++++++ .../settlementBuilder.tsx | 163 ++++++ .../TransanctionBatchGraph/styled.ts | 44 +- .../TransanctionBatchGraph/types.ts | 89 ++- .../TransanctionBatchGraph/utils.ts | 185 +------ src/apps/explorer/styled.ts | 2 +- src/const.ts | 9 +- src/hooks/useTransactionData.ts | 107 ++++ src/hooks/useTxBatchTrades.tsx | 145 ----- 17 files changed, 1249 insertions(+), 412 deletions(-) create mode 100644 src/apps/explorer/components/TransanctionBatchGraph/nodesBuilder.ts create mode 100644 src/apps/explorer/components/TransanctionBatchGraph/settlementBuilder.tsx create mode 100644 src/hooks/useTransactionData.ts delete mode 100644 src/hooks/useTxBatchTrades.tsx diff --git a/src/api/tenderly/index.ts b/src/api/tenderly/index.ts index fc2851312..f5c46be92 100644 --- a/src/api/tenderly/index.ts +++ b/src/api/tenderly/index.ts @@ -1,3 +1,4 @@ export * from './tenderlyApi' export type { PublicTrade as Trade, Transfer, Account } from './types' +export * from './types' diff --git a/src/api/tenderly/tenderlyApi.ts b/src/api/tenderly/tenderlyApi.ts index 41cfb74b6..6779b1717 100644 --- a/src/api/tenderly/tenderlyApi.ts +++ b/src/api/tenderly/tenderlyApi.ts @@ -1,16 +1,16 @@ -import { TENDERLY_API_URL, ETH_NULL_ADDRESS, APP_NAME } from 'const' +import { APP_NAME, NATIVE_TOKEN_ADDRESS_LOWERCASE, TENDERLY_API_URL } from 'const' import { Network } from 'types' import { fetchQuery } from 'api/baseApi' import { Account, Contract, - Trace, - PublicTrade as Trade, - Transfer, - TypeOfTrace, IndexTradeInput, IndexTransferInput, + PublicTrade as Trade, + Trace, + Transfer, TxTradesAndTransfers, + TypeOfTrace, } from './types' import { abbreviateString } from 'utils' import { SPECIAL_ADDRESSES } from 'apps/explorer/const' @@ -58,13 +58,21 @@ function _fetchTradesAccounts(networkId: Network, txHash: string): Promise>({ get: () => _get(networkId, queryString) }, queryString) } +export async function getTransactionTrace(networkId: Network, txHash: string): Promise { + return _fetchTrace(networkId, txHash) +} + +export async function getTransactionContracts(networkId: Network, txHash: string): Promise { + return _fetchTradesAccounts(networkId, txHash) +} + export async function getTradesAndTransfers(networkId: Network, txHash: string): Promise { const trace = await _fetchTrace(networkId, txHash) - return traceToTransfersTrades(trace) + return traceToTransfersAndTrades(trace) } -export function traceToTransfersTrades(trace: Trace): TxTradesAndTransfers { +export function traceToTransfersAndTrades(trace: Trace): TxTradesAndTransfers { const transfers: Array = [] const trades: Array = [] @@ -90,15 +98,24 @@ export function traceToTransfersTrades(trace: Trace): TxTradesAndTransfers { feeAmount: log.inputs[IndexTradeInput.feeAmount].value, orderUid: log.inputs[IndexTradeInput.orderUid].value, } - if (trade.buyToken === ETH_NULL_ADDRESS) { + if (trade.buyToken === NATIVE_TOKEN_ADDRESS_LOWERCASE) { //ETH transfers are not captured by ERC20 events, so we need to manually add them to the Transfer list transfers.push({ - token: ETH_NULL_ADDRESS, + token: NATIVE_TOKEN_ADDRESS_LOWERCASE, from: log.raw.address, to: trade.owner, value: trade.buyAmount, isInternal: log.raw.address === trade.owner, }) + } else if (trade.sellToken === NATIVE_TOKEN_ADDRESS_LOWERCASE) { + //ETH transfers are not captured by ERC20 events, so we need to manually add them to the Transfer list + transfers.push({ + token: NATIVE_TOKEN_ADDRESS_LOWERCASE, + from: trade.owner, + to: log.raw.address, + value: trade.sellAmount, + isInternal: log.raw.address === trade.owner, + }) } trades.push(trade) } diff --git a/src/api/tenderly/types.ts b/src/api/tenderly/types.ts index f7a1b6633..de8058da1 100644 --- a/src/api/tenderly/types.ts +++ b/src/api/tenderly/types.ts @@ -35,7 +35,7 @@ export interface Transfer { to: string value: string token: string - isInternal: boolean + isInternal?: boolean } export interface Account { diff --git a/src/apps/explorer/components/TransactionsTableWidget/index.tsx b/src/apps/explorer/components/TransactionsTableWidget/index.tsx index d3a4dd671..8f680eb85 100644 --- a/src/apps/explorer/components/TransactionsTableWidget/index.tsx +++ b/src/apps/explorer/components/TransactionsTableWidget/index.tsx @@ -15,7 +15,6 @@ import { TitleAddress, FlexContainer, Title } from 'apps/explorer/pages/styled' import { BlockExplorerLink } from 'components/common/BlockExplorerLink' import { ConnectionStatus } from 'components/ConnectionStatus' import { Notification } from 'components/Notification' -import { useTxBatchTrades, GetTxBatchTradesResult } from 'hooks/useTxBatchTrades' import { TransactionBatchGraph } from 'apps/explorer/components/TransanctionBatchGraph' import CowLoading from 'components/common/CowLoading' @@ -37,7 +36,7 @@ function useQueryViewParams(): { tab: string } { return { tab: query.get('tab')?.toUpperCase() || DEFAULT_TAB } // if URL param empty will be used DEFAULT } -const tabItems = (txBatchTrades: GetTxBatchTradesResult, networkId: BlockchainNetwork): TabItemInterface[] => { +const tabItems = (orders: Order[] | undefined, networkId: BlockchainNetwork, txHash: string): TabItemInterface[] => { return [ { id: TabView.ORDERS, @@ -47,7 +46,7 @@ const tabItems = (txBatchTrades: GetTxBatchTradesResult, networkId: BlockchainNe { id: TabView.GRAPH, tab: , - content: , + content: , }, ] } @@ -61,7 +60,7 @@ export const TransactionsTableWidget: React.FC = ({ txHash }) => { const txHashParams = { networkId, txHash } const isZeroOrders = !!(orders && orders.length === 0) const notGpv2ExplorerData = useTxOrderExplorerLink(txHash, isZeroOrders) - const txBatchTrades = useTxBatchTrades(networkId, txHash, orders) + const history = useHistory() // Avoid redirecting until another network is searched again @@ -117,7 +116,7 @@ export const TransactionsTableWidget: React.FC = ({ txHash }) => { }} > onChangeTab(key)} /> diff --git a/src/apps/explorer/components/TransanctionBatchGraph/elementsBuilder.tsx b/src/apps/explorer/components/TransanctionBatchGraph/elementsBuilder.tsx index 45ff5df7b..ee17f8e46 100644 --- a/src/apps/explorer/components/TransanctionBatchGraph/elementsBuilder.tsx +++ b/src/apps/explorer/components/TransanctionBatchGraph/elementsBuilder.tsx @@ -26,15 +26,17 @@ export default class ElementsBuilder { this._countEdgeDirection.set(idDirection, count + 1) } - _createNodeElement = (node: Node, parent?: string, hideLabel?: boolean): ElementDefinition => { + _createNodeElement = (node: Node, parent?: string, hideLabel?: boolean, tooltip?: InfoTooltip): ElementDefinition => { this._increaseCountNodeType(node.type) return { group: 'nodes', data: { + address: node.id, id: `${node.type}:${node.id}`, label: !hideLabel ? node.entity.alias : '', type: node.type, parent: parent ? `${TypeNodeOnTx.NetworkNode}:${parent}` : undefined, + tooltip, href: node.entity.href, }, } @@ -45,9 +47,9 @@ export default class ElementsBuilder { return this } - node(node: Node, parent?: string): this { + node(node: Node, parent?: string, tooltip?: InfoTooltip): this { const GROUP_NODE_NAME = 'group' - this._nodes.push(this._createNodeElement(node, parent, node.id.includes(GROUP_NODE_NAME))) + this._nodes.push(this._createNodeElement(node, parent, node.id.includes(GROUP_NODE_NAME), tooltip)) return this } @@ -139,6 +141,10 @@ export function buildGridLayout( throw new Error('Center node is required') } + if (countTypes.get(TypeNodeOnTx.Token)) { + return { center, nodes } + } + const maxRows = Math.max(...countTypes.values()) const middleOfTotalRows = Math.floor(maxRows / 2) const traders = countTypes.get(TypeNodeOnTx.Trader) || 0 diff --git a/src/apps/explorer/components/TransanctionBatchGraph/hooks.ts b/src/apps/explorer/components/TransanctionBatchGraph/hooks.ts index 3a3f618e7..d4e1fcd05 100644 --- a/src/apps/explorer/components/TransanctionBatchGraph/hooks.ts +++ b/src/apps/explorer/components/TransanctionBatchGraph/hooks.ts @@ -1,20 +1,37 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import Cytoscape, { EdgeDataDefinition, ElementDefinition, NodeDataDefinition } from 'cytoscape' -import { CustomLayoutOptions, layouts } from 'apps/explorer/components/TransanctionBatchGraph/layouts' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import Cytoscape, { EdgeDataDefinition, ElementDefinition, NodeDataDefinition, Stylesheet } from 'cytoscape' +import { LAYOUTS } from 'apps/explorer/components/TransanctionBatchGraph/layouts' import useWindowSizes from 'hooks/useWindowSizes' import { HEIGHT_HEADER_FOOTER } from 'apps/explorer/const' +import { bindPopper, removePopper, updateLayout } from 'apps/explorer/components/TransanctionBatchGraph/utils' +import { Network } from 'types' +import { getImageUrl } from 'utils' +import UnknownToken from 'assets/img/question1.svg' +import { + buildContractViewNodes, + buildTokenViewNodes, + getTokenAddress, +} from 'apps/explorer/components/TransanctionBatchGraph/nodesBuilder' import { - bindPopper, - getNodes, + CustomLayoutOptions, + GetTxBatchTradesResult, PopperInstance, - removePopper, - updateLayout, -} from 'apps/explorer/components/TransanctionBatchGraph/utils' -import { GetTxBatchTradesResult as TxBatchData } from 'hooks/useTxBatchTrades' -import { Network } from 'types' + ViewType, +} from 'apps/explorer/components/TransanctionBatchGraph/types' +import { useQuery } from 'hooks/useQuery' +import { useHistory } from 'react-router-dom' +import { Order } from 'api/operator' +import { useTransactionData } from 'hooks/useTransactionData' +import { + BuildSettlementParams, + buildTradesBasedSettlement, + buildTransfersBasedSettlement, +} from 'apps/explorer/components/TransanctionBatchGraph/settlementBuilder' +import { traceToTransfersAndTrades } from 'api/tenderly' +import { useMultipleErc20 } from 'hooks/useErc20' export type UseCytoscapeParams = { - txBatchData: TxBatchData + txBatchData: GetTxBatchTradesResult networkId: Network | undefined } @@ -28,6 +45,7 @@ export type UseCytoscapeReturn = { layout: CustomLayoutOptions setLayout: (layout: CustomLayoutOptions) => void cyPopperRef: React.MutableRefObject + tokensStylesheets: Cytoscape.Stylesheet[] } export function useCytoscape(params: UseCytoscapeParams): UseCytoscapeReturn { @@ -40,10 +58,11 @@ export function useCytoscape(params: UseCytoscapeParams): UseCytoscapeReturn { const cytoscapeRef = useRef(null) const cyPopperRef = useRef(null) const [resetZoom, setResetZoom] = useState(null) - const [layout, setLayout] = useState(layouts.grid) + const [layout, setLayout] = useState(LAYOUTS.grid) const { innerHeight } = useWindowSizes() const heightSize = innerHeight && innerHeight - HEIGHT_HEADER_FOOTER const [failedToLoadGraph, setFailedToLoadGraph] = useState(false) + const [tokensStylesheets, setTokensStylesheets] = useState([]) const setCytoscape = useCallback( (ref: Cytoscape.Core) => { @@ -63,9 +82,13 @@ export function useCytoscape(params: UseCytoscapeParams): UseCytoscapeReturn { setFailedToLoadGraph(false) const cy = cytoscapeRef.current setElements([]) - if (error || isLoading || !networkId || !heightSize || !cy) return + if (error || isLoading || !networkId || !heightSize || !cy || !txSettlement) return - setElements(getNodes(txSettlement, networkId, heightSize, layout.name)) + const getNodesFn = txSettlement.contractTrades ? buildTokenViewNodes : buildContractViewNodes + const nodes = getNodesFn(txSettlement, networkId, heightSize, layout.name) + + setTokensStylesheets(getStylesheets(nodes)) + setElements(nodes) if (resetZoom) { updateLayout(cy, layout.name) } @@ -96,6 +119,12 @@ export function useCytoscape(params: UseCytoscapeParams): UseCytoscapeReturn { event.target.removeClass('hover') document.getElementById('tx-graph')?.classList.remove('hover') }) + cy.on('mouseover touchstart', 'node', (event): void => { + const target = event.target + const targetData: NodeDataDefinition | EdgeDataDefinition = target.data() + + bindPopper(event, targetData, cyPopperRef) + }) cy.on('mouseover', 'node', (event): void => { if (event.target.data('href')) { event.target.addClass('hover') @@ -129,5 +158,156 @@ export function useCytoscape(params: UseCytoscapeParams): UseCytoscapeReturn { setLayout, cyPopperRef, elements, + tokensStylesheets, } } + +function getStylesheets( + nodes: ElementDefinition[], + // networkId: SupportedChainId, +): Stylesheet[] { + const stylesheets: Stylesheet[] = [] + + nodes.forEach((node) => { + if (node.data.type === 'token') { + // Right now unknown token image will only be used when the address is undefined + // which is not likely + // A way to deal with this would be to first fetch the image and when it fails set the fallback image + const image = getImageUrl(node.data.address) || UnknownToken + + stylesheets.push({ + selector: `node[id="${node.data.id}"]`, + style: { + // It's in theory possible to pass multiple images as a fallback, but when that's done, + // the image sizes are broken, going over the image bounds + 'background-image': `url("${image}")`, + 'background-color': 'white', + 'background-fit': 'contain', + // These settings are not respected as far as I tried + // 'background-width': 20, + // 'background-height': 20, + }, + }) + } + }) + + return stylesheets +} + +const DEFAULT_VIEW_TYPE = ViewType.TRANSFERS +const DEFAULT_VIEW_NAME = ViewType[DEFAULT_VIEW_TYPE] + +const VISUALIZATION_PARAM_NAME = 'vis' + +function useQueryViewParams(): { visualization: string } { + const query = useQuery() + return { visualization: query.get(VISUALIZATION_PARAM_NAME)?.toUpperCase() || DEFAULT_VIEW_NAME } +} + +function useUpdateVisQuery(): (vis: string) => void { + const query = useQuery() + const history = useHistory() + + // TODO: this is causing one extra re-render as the query is being updated when history is updated + // TODO: make it not depend on query + return useCallback( + (vis: string) => { + query.set(VISUALIZATION_PARAM_NAME, vis) + history.replace({ search: query.toString() }) + }, + [history, query], + ) +} + +export function useTxBatchData( + networkId: Network | undefined, + orders: Order[] | undefined, + txHash: string, + visualization: ViewType, +): GetTxBatchTradesResult { + // Fetch data from tenderly + const txData = useTransactionData(networkId, txHash) + + // Parse data into trades and transfers + const { trades, transfers } = useMemo(() => { + if (!txData.trace) { + return { trades: [], transfers: [] } + } + + return traceToTransfersAndTrades(txData.trace) + }, [txData.trace]) + + // Extract tokens from orders + const orderTokens = useMemo( + () => + orders?.reduce((acc, order) => { + if (order.sellToken) acc[order.sellToken.address.toLowerCase()] = order.sellToken + if (order.buyToken) acc[order.buyToken.address.toLowerCase()] = order.buyToken + + return acc + }, {}) || {}, + [orders], + ) + + // Collect addresses of missing tokens which were not part of any order + const missingTokensAddresses = useMemo(() => { + const addressesSet = transfers.reduce((set, transfer) => { + const tokenAddress = getTokenAddress(transfer.token, networkId || 1) + + if (!orderTokens[tokenAddress]) { + set.add(tokenAddress) + } + + return set + }, new Set()) + + return Array.from(addressesSet) + }, [networkId, orderTokens, transfers]) + + // Load missing tokens data + const { isLoading: areTokensLoading, value: missingTokens } = useMultipleErc20({ + addresses: missingTokensAddresses, + networkId, + }) + + // Build settlement object + const txSettlement = useMemo(() => { + const params: BuildSettlementParams = { + networkId, + tokens: { ...orderTokens, ...missingTokens }, + txData, + orders, + trades, + transfers, + } + + return visualization === ViewType.TRADES + ? buildTradesBasedSettlement(params) + : buildTransfersBasedSettlement(params) + }, [networkId, orderTokens, missingTokens, txData, orders, trades, transfers, visualization]) + + return { txSettlement, error: txData.error, isLoading: txData.isLoading || areTokensLoading } +} + +type UseVisualizationReturn = { + visualization: ViewType + onChangeVisualization: (vis: ViewType) => void +} + +export function useVisualization(): UseVisualizationReturn { + const { visualization } = useQueryViewParams() + + const updateVisQuery = useUpdateVisQuery() + + const [visualizationViewSelected, setVisualizationViewSelected] = useState( + ViewType[visualization] || DEFAULT_VIEW_TYPE, + ) + + const onChangeVisualization = useCallback((viewName: ViewType) => setVisualizationViewSelected(viewName), []) + + useEffect(() => { + updateVisQuery(ViewType[visualizationViewSelected].toLowerCase()) + }, [updateVisQuery, visualizationViewSelected]) + + return { visualization: visualizationViewSelected, onChangeVisualization } +} diff --git a/src/apps/explorer/components/TransanctionBatchGraph/index.tsx b/src/apps/explorer/components/TransanctionBatchGraph/index.tsx index 6a1467685..8de797182 100644 --- a/src/apps/explorer/components/TransanctionBatchGraph/index.tsx +++ b/src/apps/explorer/components/TransanctionBatchGraph/index.tsx @@ -3,30 +3,31 @@ import popper from 'cytoscape-popper' import noOverlap from 'cytoscape-no-overlap' import fcose from 'cytoscape-fcose' import klay from 'cytoscape-klay' -import React from 'react' +import React, { useEffect, useMemo } from 'react' import CytoscapeComponent from 'react-cytoscapejs' import styled, { useTheme } from 'styled-components' import { - faRedo, + faDiceFive, + faDiceFour, faDiceOne, - faDiceTwo, faDiceThree, - faDiceFour, - faDiceFive, + faDiceTwo, + faRedo, IconDefinition, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' - -import { GetTxBatchTradesResult as TxBatchData } from 'hooks/useTxBatchTrades' import { Network } from 'types' -import { STYLESHEET, ResetButton, LayoutButton, DropdownWrapper, FloatingWrapper } from './styled' +import { DropdownWrapper, FloatingWrapper, LayoutButton, ResetButton, STYLESHEET } from './styled' import CowLoading from 'components/common/CowLoading' import { media } from 'theme/styles/media' import { EmptyItemWrapper } from 'components/common/StyledUserDetailsTable' -import { layouts, LayoutNames } from './layouts' +import { LAYOUTS } from './layouts' import { DropdownOption, DropdownPosition } from 'apps/explorer/components/common/Dropdown' import { removePopper } from 'apps/explorer/components/TransanctionBatchGraph/utils' -import { useCytoscape } from 'apps/explorer/components/TransanctionBatchGraph/hooks' +import { useCytoscape, useTxBatchData, useVisualization } from 'apps/explorer/components/TransanctionBatchGraph/hooks' +import { Order } from 'api/operator' +import { usePrevious } from 'hooks/usePrevious' +import { LayoutNames, ViewType } from 'apps/explorer/components/TransanctionBatchGraph/types' Cytoscape.use(popper) Cytoscape.use(noOverlap) @@ -37,6 +38,8 @@ const WrapperCytoscape = styled(CytoscapeComponent)` background-color: ${({ theme }): string => theme.bg1}; font-weight: ${({ theme }): string => theme.fontMedium}; border-radius: 0.6rem; + + padding-top: 3rem; ${media.mediumDown} { border: 0.1rem solid ${({ theme }): string => theme.borderPrimary}; margin: 1.6rem 0; @@ -44,33 +47,43 @@ const WrapperCytoscape = styled(CytoscapeComponent)` ` const iconDice = [faDiceOne, faDiceTwo, faDiceThree, faDiceFour, faDiceFive] -interface GraphBatchTxParams { - txBatchData: TxBatchData - networkId: Network | undefined -} - function DropdownButtonContent({ - layout, + label, icon, open, }: { - layout: string + label: string icon: IconDefinition open?: boolean }): JSX.Element { return ( <> - Layout: {layout} + {label} ) } +const ViewTypeNames: Record = { + [ViewType.TRANSFERS]: 'Transfer based', + [ViewType.TRADES]: 'Trade based', +} + +interface GraphBatchTxParams { + orders: Order[] | undefined + txHash: string + networkId: Network | undefined +} + export function TransactionBatchGraph(params: GraphBatchTxParams): JSX.Element { - const { - txBatchData: { isLoading }, - } = params + const { orders, networkId, txHash } = params + const { visualization, onChangeVisualization } = useVisualization() + + const txBatchData = useTxBatchData(networkId, orders, txHash, visualization) + + const { isLoading } = txBatchData + const { elements, failedToLoadGraph, @@ -81,11 +94,24 @@ export function TransactionBatchGraph(params: GraphBatchTxParams): JSX.Element { resetZoom, setCytoscape, cyPopperRef, - } = useCytoscape(params) + tokensStylesheets, + } = useCytoscape({ networkId, txBatchData }) + + const previousVisualization = usePrevious(visualization) + + const visualizationChanged = previousVisualization !== visualization + + useEffect(() => { + if (visualizationChanged) setResetZoom(true) + }, [setResetZoom, visualizationChanged]) const theme = useTheme() const currentLayoutIndex = Object.keys(LayoutNames).findIndex((nameLayout) => nameLayout === layout.name) + const stylesheet = useMemo(() => { + return STYLESHEET(theme).concat(tokensStylesheets) + }, [tokensStylesheets, theme]) + if (isLoading) { return ( @@ -108,7 +134,7 @@ export function TransactionBatchGraph(params: GraphBatchTxParams): JSX.Element { elements={elements} layout={layout} style={{ width: '100%', height: heightSize }} - stylesheet={STYLESHEET(theme)} + stylesheet={stylesheet} cy={setCytoscape} wheelSensitivity={0.2} className="tx-graph" @@ -124,20 +150,52 @@ export function TransactionBatchGraph(params: GraphBatchTxParams): JSX.Element { + } dropdownButtonContentOpened={ - + } callback={(): void => removePopper(cyPopperRef)} items={Object.values(LayoutNames).map((layoutName) => ( - setLayout(layouts[layoutName.toLowerCase()])}> + setLayout(LAYOUTS[layoutName.toLowerCase()])}> {layoutName} ))} dropdownPosition={DropdownPosition.center} /> + + + } + dropdownButtonContentOpened={ + + } + callback={(): void => removePopper(cyPopperRef)} + items={Object.keys(ViewTypeNames).map((viewType) => ( + onChangeVisualization(Number(viewType))}> + {ViewTypeNames[viewType]} + + ))} + dropdownPosition={DropdownPosition.center} + /> + ) diff --git a/src/apps/explorer/components/TransanctionBatchGraph/layouts.ts b/src/apps/explorer/components/TransanctionBatchGraph/layouts.ts index 1cd6d8429..b6cf14b81 100644 --- a/src/apps/explorer/components/TransanctionBatchGraph/layouts.ts +++ b/src/apps/explorer/components/TransanctionBatchGraph/layouts.ts @@ -1,19 +1,23 @@ -import { LayoutOptions, NodeSingular } from 'cytoscape' +import { NodeSingular } from 'cytoscape' +import { CustomLayoutOptions, CytoscapeLayouts } from './types' -export type CytoscapeLayouts = 'grid' | 'klay' | 'fcose' - -export type CustomLayoutOptions = LayoutOptions & { - [key: string]: unknown -} - -const defaultValues = { +const DEFAULT_VALUES = { padding: 10, // padding used on fit animate: true, fit: true, // whether to fit the viewport to the graph } -export const layouts: Record = { +export const LAYOUTS: Record = { + circle: { + ...DEFAULT_VALUES, + name: 'circle', + }, + concentric: { + ...DEFAULT_VALUES, + name: 'concentric', + spacingFactor: 4, + }, grid: { - ...defaultValues, + ...DEFAULT_VALUES, name: 'grid', position: (node: NodeSingular): { row: number; col: number } => ({ row: node.data('row'), col: node.data('col') }), avoidOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space @@ -22,7 +26,7 @@ export const layouts: Record = { condense: false, }, klay: { - ...defaultValues, + ...DEFAULT_VALUES, name: 'klay', klay: { addUnnecessaryBendpoints: true, // Adds bend points even if an edge does not change direction. @@ -35,7 +39,7 @@ export const layouts: Record = { }, }, fcose: { - ...defaultValues, + ...DEFAULT_VALUES, name: 'fcose', quality: 'proof', randomize: true, @@ -95,9 +99,3 @@ export const layouts: Record = { relativePlacementConstraint: undefined, }, } - -export enum LayoutNames { - grid = 'Grid', - klay = 'KLay', - fcose = 'FCoSE', -} diff --git a/src/apps/explorer/components/TransanctionBatchGraph/nodesBuilder.ts b/src/apps/explorer/components/TransanctionBatchGraph/nodesBuilder.ts new file mode 100644 index 000000000..0e7638e71 --- /dev/null +++ b/src/apps/explorer/components/TransanctionBatchGraph/nodesBuilder.ts @@ -0,0 +1,506 @@ +import { Network } from 'types' +import { Order } from 'api/operator' +import { ElementDefinition } from 'cytoscape' +import { networkOptions } from 'components/NetworkSelector' +import ElementsBuilder, { buildGridLayout } from 'apps/explorer/components/TransanctionBatchGraph/elementsBuilder' +import { + BuildNodesFn, + ContractTrade, + NodesAndEdges, + Settlement, + TokenEdge, + TokenNode, + TypeEdgeOnTx, + TypeNodeOnTx, +} from 'apps/explorer/components/TransanctionBatchGraph/types' +import { abbreviateString, FormatAmountPrecision, formattingAmountPrecision } from 'utils' +import { getExplorerUrl } from 'utils/getExplorerUrl' +import { SPECIAL_ADDRESSES, TOKEN_SYMBOL_UNKNOWN } from 'apps/explorer/const' +import BigNumber from 'bignumber.js' +import { Account, ALIAS_TRADER_NAME, Trade, Transfer } from 'api/tenderly' +import { OrderKind, SupportedChainId } from '@cowprotocol/cow-sdk' +import { APP_NAME, NATIVE_TOKEN_ADDRESS_LOWERCASE, WRAPPED_NATIVE_ADDRESS } from 'const' +import { SingleErc20State } from 'state/erc20' + +const PROTOCOL_NAME = APP_NAME +const INTERNAL_NODE_NAME = `${APP_NAME} Buffer` + +export const buildContractViewNodes: BuildNodesFn = function getNodes( + txSettlement: Settlement, + networkId: Network, + heightSize: number, + layout: string, +): ElementDefinition[] { + if (!txSettlement.accounts) return [] + + const networkName = networkOptions.find((network) => network.id === networkId)?.name + const networkNode = { alias: `${networkName} Liquidity` || '' } + const builder = new ElementsBuilder(heightSize) + builder.node({ type: TypeNodeOnTx.NetworkNode, entity: networkNode, id: networkNode.alias }) + + const groupNodes: Map = new Map() + + for (const key in txSettlement.accounts) { + const account = txSettlement.accounts[key] + let parentNodeName = getNetworkParentNode(account, networkNode.alias) + + const receiverNode = { alias: `${abbreviateString(account.owner || key, 4, 4)}-group` } + + if (account.owner && account.owner !== key) { + if (!groupNodes.has(receiverNode.alias)) { + builder.node({ type: TypeNodeOnTx.NetworkNode, entity: receiverNode, id: receiverNode.alias }) + groupNodes.set(receiverNode.alias, account.owner || key) + } + parentNodeName = receiverNode.alias + } + + if (getTypeNode(account) === TypeNodeOnTx.CowProtocol) { + builder.center({ type: TypeNodeOnTx.CowProtocol, entity: account, id: key }, parentNodeName) + } else { + const receivers = Object.keys(txSettlement.accounts).reduce( + (acc, key) => (txSettlement.accounts?.[key].owner ? [...acc, txSettlement.accounts?.[key].owner] : acc), + [], + ) + + if (receivers.includes(key) && account.owner !== key) { + if (!groupNodes.has(receiverNode.alias)) { + builder.node({ type: TypeNodeOnTx.NetworkNode, entity: receiverNode, id: receiverNode.alias }) + groupNodes.set(receiverNode.alias, account.owner || key) + } + parentNodeName = receiverNode.alias + } + + builder.node( + { + id: key, + type: getTypeNode(account), + entity: showTraderAddress(account, key), + }, + parentNodeName, + ) + } + } + + let internalNodeCreated = false + + txSettlement.transfers.forEach((transfer) => { + // Custom from id when internal transfer to avoid re-using existing node + const fromId = transfer.isInternal ? INTERNAL_NODE_NAME : transfer.from + + // If transfer is internal and a node has not been created yet, create one + if (transfer.isInternal && !internalNodeCreated) { + // Set flag to prevent creating more + internalNodeCreated = true + + const account = { alias: fromId, href: getExplorerUrl(networkId, 'address', transfer.from) } + builder.node( + { + type: TypeNodeOnTx.Special, + entity: account, + id: fromId, + }, + // Put it inside the parent node + getInternalParentNode(groupNodes, transfer), + ) + } + + const kind = getKindEdge(transfer) + const token = txSettlement.tokens[transfer.token] + const tokenSymbol = token?.symbol || TOKEN_SYMBOL_UNKNOWN + const tokenAmount = token?.decimals + ? formattingAmountPrecision(new BigNumber(transfer.value), token, FormatAmountPrecision.highPrecision) + : '-' + + const source = builder.getById(fromId) + const target = builder.getById(transfer.to) + builder.edge( + { type: source?.data.type, id: fromId }, + { type: target?.data.type, id: transfer.to }, + `${tokenSymbol}`, + kind, + { + from: fromId, + // Do not display `to` field on tooltip when internal transfer as it's redundant + ...(transfer.isInternal + ? undefined + : { + to: transfer.to, + }), + amount: `${tokenAmount} ${tokenSymbol}`, + }, + ) + }) + + return builder.build( + layout === 'grid' + ? buildGridLayout(builder._countNodeTypes as Map, builder._center, builder._nodes) + : undefined, + ) +} + +function getTypeNode(account: Account & { owner?: string }): TypeNodeOnTx { + if (account.address && SPECIAL_ADDRESSES[account.address]) { + return TypeNodeOnTx.Special + } else if (account.alias === ALIAS_TRADER_NAME || account.owner) { + return TypeNodeOnTx.Trader + } else if (account.alias === PROTOCOL_NAME) { + return TypeNodeOnTx.CowProtocol + } + + return TypeNodeOnTx.Dex +} + +function getKindEdge(transfer: Transfer & { kind?: OrderKind }): TypeEdgeOnTx { + if (transfer.kind === OrderKind.SELL) { + return TypeEdgeOnTx.sellEdge + } else if (transfer.kind === OrderKind.BUY) { + return TypeEdgeOnTx.buyEdge + } + + return TypeEdgeOnTx.noKind +} + +function showTraderAddress(account: Account, address: string): Account { + const alias = account.alias === ALIAS_TRADER_NAME ? abbreviateString(address, 4, 4) : account.alias + + return { ...account, alias } +} + +function getNetworkParentNode(account: Account, networkName: string): string | undefined { + return account.alias !== ALIAS_TRADER_NAME ? networkName : undefined +} + +function getInternalParentNode(groupNodes: Map, transfer: Transfer): string | undefined { + for (const [key, value] of groupNodes) { + if (value === transfer.from) { + return key + } + } + return undefined +} + +const ADDRESSES_TO_IGNORE = new Set() +// CoW Protocol settlement contract +ADDRESSES_TO_IGNORE.add('0x9008d19f58aabd9ed0d60971565aa8510560ab41') +// ETH Flow contract +ADDRESSES_TO_IGNORE.add('0x40a50cf069e992aa4536211b23f286ef88752187') + +export function getContractTrades( + trades: Trade[], + transfers: Transfer[], + orders: Order[] | undefined, +): ContractTrade[] { + const userAddresses = new Set() + const contractAddresses = new Set() + + // Build a list of addresses that are involved in trades + if (orders) { + orders.forEach((order) => { + userAddresses.add(order.owner) + userAddresses.add(order.receiver) + }) + } else { + trades.forEach((trade) => userAddresses.add(trade.owner)) + } + + // Build list of contract addresses based on trades, which are not traders + // nor part of the ignored set (CoW Protocol itself, special contracts etc) + transfers.forEach((transfer) => { + ;[transfer.from, transfer.to].forEach((address) => { + if (!userAddresses.has(address) && !ADDRESSES_TO_IGNORE.has(address)) { + contractAddresses.add(address) + } + }) + }) + + // Get contract trades + return Array.from(contractAddresses).map((address) => { + const sellTransfers: Transfer[] = [] + const buyTransfers: Transfer[] = [] + + transfers.forEach((transfer) => { + if (transfer.from === address) { + sellTransfers.push(transfer) + } else if (transfer.to === address) { + buyTransfers.push(transfer) + } + }) + + return { address, sellTransfers, buyTransfers } + }) +} + +function mergeContractTrade(contractTrade: ContractTrade): ContractTrade { + const mergedSellTransfers: Transfer[] = [] + const mergedBuyTransfers: Transfer[] = [] + const token_balances: { [key: string]: bigint } = {} + + contractTrade.sellTransfers.forEach((transfer) => { + token_balances[transfer.token] = token_balances[transfer.token] + ? token_balances[transfer.token] - BigInt(transfer.value) + : -BigInt(transfer.value) + }) + contractTrade.buyTransfers.forEach((transfer) => { + token_balances[transfer.token] = token_balances[transfer.token] + ? token_balances[transfer.token] + BigInt(transfer.value) + : BigInt(transfer.value) + }) + + Object.entries(token_balances).forEach(([token, amount]) => { + if (amount < 0) { + mergedSellTransfers.push({ + from: '', // field should not be used later on + to: contractTrade.address, + value: (-amount).toString(), + token: token, + }) + } else if (amount > 0) { + mergedBuyTransfers.push({ + from: contractTrade.address, + to: '', + value: amount.toString(), + token: token, + }) + } + }) + + return { address: contractTrade.address, sellTransfers: mergedSellTransfers, buyTransfers: mergedBuyTransfers } +} + +function isRoutingTrade(contractTrade: ContractTrade): boolean { + return contractTrade.sellTransfers.length === 0 && contractTrade.buyTransfers.length === 0 +} + +export function getNotesAndEdges( + userTrades: Trade[], + contractTrades: ContractTrade[], + networkId: SupportedChainId, +): NodesAndEdges { + const nodes: Record = {} + const edges: TokenEdge[] = [] + + userTrades.forEach((trade) => { + const sellToken = getTokenAddress(trade.sellToken, networkId) + nodes[sellToken] = { address: sellToken } + const buyToken = getTokenAddress(trade.buyToken, networkId) + nodes[buyToken] = { address: buyToken } + + // one edge for each user trade + edges.push({ from: sellToken, to: buyToken, address: trade.owner, trade }) + }) + + contractTrades + .map(mergeContractTrade) + .filter((trade) => !isRoutingTrade(trade)) + .forEach((trade) => { + // add all sellTokens from contract trades to nodes + trade.sellTransfers.forEach(({ token }) => { + const tokenAddress = getTokenAddress(token, networkId) + nodes[tokenAddress] = { address: tokenAddress } + }) + // add all buyTokens from contract trades to nodes + trade.buyTransfers.forEach(({ token }) => { + const tokenAddress = getTokenAddress(token, networkId) + nodes[tokenAddress] = { address: tokenAddress } + }) + + if (trade.sellTransfers.length === 1 && trade.buyTransfers.length === 1) { + // no need to add a new node + // normal edge for normal contract interaction + const sellTransfer = trade.sellTransfers[0] + const buyTransfer = trade.buyTransfers[0] + edges.push({ + from: getTokenAddress(sellTransfer.token, networkId), + to: getTokenAddress(buyTransfer.token, networkId), + address: trade.address, + fromTransfer: sellTransfer, + toTransfer: buyTransfer, + }) + } else { + // if there is more than one sellToken or buyToken, the contract becomes a node + const nodeExists = nodes[trade.address] + if (!nodeExists) { + nodes[trade.address] = { address: trade.address, isHyperNode: true } + } + + // one edge for each sellToken + trade.sellTransfers.forEach((transfer) => + edges.push({ + from: getTokenAddress(transfer.token, networkId), + to: trade.address, + address: trade.address, + fromTransfer: transfer, + ...(nodeExists ? undefined : { hyperNode: 'to' }), + }), + ) + // one edge for each buyToken + trade.buyTransfers.forEach((transfer) => + edges.push({ + from: trade.address, + to: getTokenAddress(transfer.token, networkId), + address: trade.address, + toTransfer: transfer, + ...(nodeExists ? undefined : { hyperNode: 'from' }), + }), + ) + } + }) + + return { + nodes: Object.values(nodes), + edges, + } +} + +export function getTokenAddress(address: string, networkId: SupportedChainId): string { + if (address.toLowerCase() === NATIVE_TOKEN_ADDRESS_LOWERCASE) { + return WRAPPED_NATIVE_ADDRESS[networkId].toLowerCase() + } + return address.toLowerCase() +} + +export const buildTokenViewNodes: BuildNodesFn = function getNodesAlternative( + txSettlement: Settlement, + networkId: Network, + heightSize: number, + layout: string, +): ElementDefinition[] { + const networkName = networkOptions.find((network) => network.id === networkId)?.name + const networkNode = { alias: `${networkName} Liquidity` || '' } + const builder = new ElementsBuilder(heightSize) + + builder.center({ type: TypeNodeOnTx.NetworkNode, entity: networkNode, id: networkNode.alias }) + + const { trades, contractTrades, accounts, contracts, tokens } = txSettlement + + const contractsMap = + contracts?.reduce((acc, contract) => { + acc[contract.address] = contract.contract_name + return acc + }, {}) || {} + + const { nodes, edges } = getNotesAndEdges(trades, contractTrades || [], networkId) + + nodes.forEach((node) => { + const entity = accounts?.[node.address] || { + alias: abbreviateString(node.address, 6, 4), + address: node.address, + href: getExplorerUrl(networkId, 'contract', node.address), + } + const type = node.isHyperNode ? TypeNodeOnTx.Hyper : TypeNodeOnTx.Token + const tooltip = getNodeTooltip(node, edges, tokens) + builder.node({ entity, id: node.address, type }, networkNode.alias, tooltip) + }) + edges.forEach((edge) => { + const source = { + id: edge.from, + type: edge.hyperNode === 'from' ? TypeNodeOnTx.Hyper : TypeNodeOnTx.Token, + } + const target = { + id: edge.to, + type: edge.hyperNode === 'to' ? TypeNodeOnTx.Hyper : TypeNodeOnTx.Token, + } + const label = getLabel(edge, contractsMap) + const kind = edge.trade ? TypeEdgeOnTx.user : TypeEdgeOnTx.amm + const tooltip = getTooltip(edge, tokens) + builder.edge(source, target, label, kind, tooltip) + }) + + return builder.build( + layout === 'grid' + ? buildGridLayout(builder._countNodeTypes as Map, builder._center, builder._nodes) + : undefined, + ) +} + +function getLabel(edge: TokenEdge, contractsMap: Record): string { + if (edge.trade) { + return abbreviateString(edge.trade.orderUid, 6, 4) + } else if (edge.hyperNode) { + return '' + } else if (edge.toTransfer && edge.fromTransfer) { + return contractsMap[edge.address] || abbreviateString(edge.address, 6, 4) + } + return 'add transfer info' +} + +function getTooltip(edge: TokenEdge, tokens: Record): Record { + const tooltip = {} + + const fromToken = tokens[edge.from] + const toToken = tokens[edge.to] + + if (edge.trade) { + tooltip['order-id'] = edge.trade.orderUid + tooltip['sold'] = getTokenTooltipAmount(fromToken, edge.trade.sellAmount) + tooltip['bought'] = getTokenTooltipAmount(toToken, edge.trade.buyAmount) + } else if (edge.hyperNode) { + if (edge.fromTransfer) { + tooltip['sold'] = getTokenTooltipAmount(fromToken, edge.fromTransfer?.value) + } + if (edge.toTransfer) { + tooltip['bought'] = getTokenTooltipAmount(toToken, edge.toTransfer?.value) + } + } else { + tooltip['sold'] = getTokenTooltipAmount(fromToken, edge.fromTransfer?.value) + tooltip['bought'] = getTokenTooltipAmount(toToken, edge.toTransfer?.value) + } + + return tooltip +} + +function getNodeTooltip( + node: TokenNode, + edges: TokenEdge[], + tokens: Record, +): Record | undefined { + if (node.isHyperNode) { + return undefined + } + + const tooltip = {} + const address = node.address + const token = tokens[address] + + let amount = BigInt(0) + edges.forEach((edge) => { + if (edge.from === address) { + if (edge.fromTransfer) { + amount += BigInt(edge.fromTransfer.value) + } else if (edge.trade) { + amount += BigInt(edge.trade.sellAmount) + } + } + if (edge.to === address) { + if (edge.toTransfer) { + amount -= BigInt(edge.toTransfer.value) + } else if (edge.trade) { + amount -= BigInt(edge.trade.buyAmount) + } + } + }) + + tooltip['balance'] = getTokenTooltipAmount(token, amount.toString()) + + return tooltip +} + +function getTokenTooltipAmount(token: SingleErc20State, value: string | undefined): string { + let amount, amount_atoms, amount_atoms_abs, sign + if (token?.decimals && value) { + amount_atoms = BigInt(value) + sign = amount_atoms >= BigInt(0) ? BigInt(1) : BigInt(-1) + amount_atoms_abs = sign * amount_atoms + amount = formattingAmountPrecision( + new BigNumber(amount_atoms_abs.toString()), + token, + FormatAmountPrecision.highPrecision, + ) + } else { + amount = '-' + } + const tokenSymbol = token?.symbol || TOKEN_SYMBOL_UNKNOWN + const sign_char = sign && sign > 0 ? '' : '-' + + return `${sign_char}${amount} ${tokenSymbol}` +} diff --git a/src/apps/explorer/components/TransanctionBatchGraph/settlementBuilder.tsx b/src/apps/explorer/components/TransanctionBatchGraph/settlementBuilder.tsx new file mode 100644 index 000000000..f26474711 --- /dev/null +++ b/src/apps/explorer/components/TransanctionBatchGraph/settlementBuilder.tsx @@ -0,0 +1,163 @@ +import { accountAddressesInvolved, getAliasFromAddress, PublicTrade, Transfer } from 'api/tenderly' +import { SingleErc20State } from 'state/erc20' +import BigNumber from 'bignumber.js' +import { getExplorerUrl } from 'utils/getExplorerUrl' +import { TransactionData } from 'hooks/useTransactionData' +import { Network } from 'types' +import { Order } from 'api/operator' +import { getContractTrades, getTokenAddress } from './nodesBuilder' +import { abbreviateString } from 'utils' +import { Accounts, Dict, Settlement } from 'apps/explorer/components/TransanctionBatchGraph/types' + +/** + * Group transfers by token, from and to + */ +function groupTransfers(arr: Transfer[]): Transfer[] { + return [ + ...arr + .reduce((r, t) => { + const key = `${t.token}-${t.from}-${t.to}` + + const item = + r.get(key) || + Object.assign({}, t, { + value: new BigNumber(0), + }) + + item.value = BigNumber.sum(new BigNumber(item.value), new BigNumber(t.value)).toString() + + return r.set(key, item) + }, new Map()) + .values(), + ] +} + +export function buildTransfersBasedSettlement(params: BuildSettlementParams): Settlement | undefined { + const { networkId, orders, txData, tokens, trades, transfers } = params + const { trace, contracts } = txData + + if (!networkId || !orders || !trace || !contracts) { + return undefined + } + + const _accounts: Accounts = Object.fromEntries(accountAddressesInvolved(contracts, trades, transfers)) + const filteredOrders = orders.filter((order) => _accounts[order.owner]) + + const ownersAndReceivers = filteredOrders.reduce>((_set, { owner, receiver }) => { + _set.add(owner) + _set.add(receiver) + + return _set + }, new Set()) + + const groupedTransfers = groupTransfers(transfers) + const transfersWithKind: Transfer[] = groupedTransfers.filter( + (transfer) => !ownersAndReceivers.has(transfer.from) && !ownersAndReceivers.has(transfer.to), + ) + filteredOrders?.forEach((order) => { + const { owner, kind, receiver } = order + if (!ownersAndReceivers.has(owner)) return + transfersWithKind.push( + ...groupedTransfers.filter((t) => [t.from, t.to].includes(owner)).map((transfer) => ({ ...transfer, kind })), + ) + + transfersWithKind.push( + ...groupedTransfers.filter((t) => [t.from, t.to].includes(receiver)).map((transfer) => ({ ...transfer, kind })), + ) + ownersAndReceivers.delete(owner) + ownersAndReceivers.delete(receiver) + }) + + const accountsWithReceiver = _accounts + + filteredOrders.forEach((order) => { + if (!(order.receiver in _accounts)) { + accountsWithReceiver[order.receiver] = { + alias: getAliasFromAddress(order.receiver), + address: order.receiver, + } + } + accountsWithReceiver[order.receiver] = { + ...accountsWithReceiver[order.receiver], + owner: order.owner, + } + }) + Object.values(accountsWithReceiver).forEach((account) => { + if (account.address) account.href = getExplorerUrl(networkId, 'address', account.address) + }) + + const tokenAddresses = transfersWithKind.map((transfer: Transfer): string => transfer.token) + const accounts = accountsWithReceiver + + const filteredTokens = Object.keys(tokens).reduce((acc, token) => { + if (tokenAddresses.includes(token)) { + acc[token] = tokens[token] + } + return acc + }, {}) + + return { + transfers: transfersWithKind, + tokens: filteredTokens, + trades, + accounts, + } +} + +export type BuildSettlementParams = { + networkId: Network | undefined + tokens: Dict + orders?: Order[] | undefined + txData: TransactionData + trades: PublicTrade[] + transfers: Transfer[] +} + +export function buildTradesBasedSettlement(params: BuildSettlementParams): Settlement | undefined { + const { networkId, txData, tokens, orders, trades, transfers } = params + const { trace, contracts } = txData + + if (!networkId || !trace || !contracts) { + return undefined + } + + const contractTrades = getContractTrades(trades, transfers, orders) + + const addressesSet = transfers.reduce((set, transfer) => { + set.add(getTokenAddress(transfer.token, networkId || 1)) + return set + }, new Set()) + + const tokenAddresses = Array.from(addressesSet) + + const accounts = tokenAddresses.reduce((acc, address) => { + const symbol = tokens?.[address]?.symbol + + acc[address] = { + alias: symbol || abbreviateString(address, 6, 4), + address, + href: getExplorerUrl(networkId, 'token', address), + } + + return acc + }, {}) + + const filteredTokens = tokenAddresses.reduce((acc, address) => { + const token = tokens[address] + + if (token) { + acc[address] = token + } + + return acc + }, {}) + + return { + accounts, + trades, + contractTrades, + transfers, + tokens: filteredTokens, + contracts, + } +} diff --git a/src/apps/explorer/components/TransanctionBatchGraph/styled.ts b/src/apps/explorer/components/TransanctionBatchGraph/styled.ts index 27bf372ed..b0dd3354b 100644 --- a/src/apps/explorer/components/TransanctionBatchGraph/styled.ts +++ b/src/apps/explorer/components/TransanctionBatchGraph/styled.ts @@ -1,10 +1,12 @@ import { Stylesheet } from 'cytoscape' -import styled, { DefaultTheme, css } from 'styled-components' +import styled, { css, DefaultTheme } from 'styled-components' import TraderIcon from 'assets/img/Trader.svg' import SpecialIcon from 'assets/img/Trader-variant.svg' import CowProtocolIcon from 'assets/img/CoW-protocol.svg' import DexIcon from 'assets/img/Dex.svg' +import TokenIcon from 'assets/img/eth-network.svg' + import { MEDIA } from 'const' import { Dropdown } from 'apps/explorer/components/common/Dropdown' import { ArrowIconCSS } from 'components/icons/cssIcons' @@ -101,12 +103,6 @@ export function STYLESHEET(theme: DefaultTheme): Stylesheet[] { 'background-color': theme.bg2, }, }, - { - selector: 'node[label].hover', - style: { - color: theme.orange, - }, - }, { selector: 'node', style: { @@ -145,7 +141,7 @@ export function STYLESHEET(theme: DefaultTheme): Stylesheet[] { }, }, { - selector: 'edge[label].many-bidirectional', + selector: 'edge.many-bidirectional', style: { 'curve-style': 'bezier', 'font-size': '15px', @@ -153,21 +149,27 @@ export function STYLESHEET(theme: DefaultTheme): Stylesheet[] { }, }, { - selector: 'edge[label].sell', + selector: 'edge.sell,edge.amm', style: { 'line-color': theme.red1, 'target-arrow-color': theme.red1, }, }, { - selector: 'edge[label].buy', + selector: 'edge.buy,edge.user', style: { 'line-color': theme.green1, 'target-arrow-color': theme.green1, }, }, { - selector: 'edge[label].hover', + selector: 'edge.amm,edge.user', + style: { + 'curve-style': 'bezier', + }, + }, + { + selector: 'edge.hover', style: { width: 3, 'line-color': theme.orange1, @@ -201,6 +203,26 @@ export function STYLESHEET(theme: DefaultTheme): Stylesheet[] { 'text-margin-y': 8, }, }, + { + selector: 'node[type="token"]', + style: { + 'background-image': `url(${TokenIcon})`, + 'text-max-width': '5rem', + 'text-valign': 'bottom', + 'text-margin-y': 8, + }, + }, + { + selector: 'node[type="hyper"]', + style: { + 'background-color': theme.red1, + width: '10', + height: '10', + 'text-max-width': '5rem', + 'text-valign': 'bottom', + 'text-margin-y': 8, + }, + }, { selector: 'node[type="cowProtocol"]', style: { diff --git a/src/apps/explorer/components/TransanctionBatchGraph/types.ts b/src/apps/explorer/components/TransanctionBatchGraph/types.ts index c84e946e4..aa7355b86 100644 --- a/src/apps/explorer/components/TransanctionBatchGraph/types.ts +++ b/src/apps/explorer/components/TransanctionBatchGraph/types.ts @@ -1,4 +1,7 @@ -import { Account } from 'api/tenderly' +import { Account, Contract, Trade, Transfer } from 'api/tenderly' +import { ElementDefinition, LayoutOptions } from 'cytoscape' +import { Network } from 'types' +import { SingleErc20State } from 'state/erc20' export enum TypeNodeOnTx { NetworkNode = 'networkNode', @@ -6,12 +9,16 @@ export enum TypeNodeOnTx { Trader = 'trader', Dex = 'dex', Special = 'special', + Token = 'token', + Hyper = 'hyper', } export enum TypeEdgeOnTx { sellEdge = 'sell', buyEdge = 'buy', noKind = 'noKind', + user = 'user', + amm = 'amm', } export type InfoTooltip = Record @@ -23,3 +30,83 @@ export type Node = | NodeType | NodeType | NodeType + | NodeType + | NodeType + +export enum ViewType { + TRANSFERS, + TRADES, +} + +export type CytoscapeLayouts = 'grid' | 'klay' | 'fcose' | 'circle' | 'concentric' + +export type CustomLayoutOptions = LayoutOptions & { + [key: string]: unknown +} + +export enum LayoutNames { + grid = 'Grid', + klay = 'KLay', + fcose = 'FCoSE', + circle = 'Circle', + concentric = 'Concentric', +} + +export type BuildNodesFn = ( + txSettlement: Settlement, + networkId: Network, + heightSize: number, + layout: string, +) => ElementDefinition[] + +export type ContractTrade = { + address: string + sellTransfers: Transfer[] + buyTransfers: Transfer[] +} + +export type TokenNode = { + address: string + isHyperNode?: boolean +} + +export type TokenEdge = { + from: string + to: string + address: string + trade?: Trade + fromTransfer?: Transfer + toTransfer?: Transfer + hyperNode?: 'from' | 'to' +} + +export type NodesAndEdges = { + nodes: TokenNode[] + edges: TokenEdge[] +} + +export type Dict = Record + +export type AccountWithReceiver = Account & { owner?: string; uids?: string[] } +export type Accounts = Dict | undefined + +export interface Settlement { + tokens: Dict + accounts: Accounts + transfers: Array + trades: Array + // TODO: this is a big mix of types, refactor!!! + contractTrades?: Array + contracts?: Array +} + +export type GetTxBatchTradesResult = { + txSettlement: Settlement | undefined + error: string + isLoading: boolean +} + +export interface PopperInstance { + scheduleUpdate: () => void + destroy: () => void +} diff --git a/src/apps/explorer/components/TransanctionBatchGraph/utils.ts b/src/apps/explorer/components/TransanctionBatchGraph/utils.ts index 61e76b04e..8d3b391d8 100644 --- a/src/apps/explorer/components/TransanctionBatchGraph/utils.ts +++ b/src/apps/explorer/components/TransanctionBatchGraph/utils.ts @@ -1,26 +1,7 @@ -import Cytoscape, { ElementDefinition, EventObject } from 'cytoscape' +import Cytoscape, { EventObject } from 'cytoscape' import React from 'react' -import { layouts } from 'apps/explorer/components/TransanctionBatchGraph/layouts' -import { Account, ALIAS_TRADER_NAME, Transfer } from 'api/tenderly' -import { TypeEdgeOnTx, TypeNodeOnTx } from 'apps/explorer/components/TransanctionBatchGraph/types' -import { OrderKind } from '@cowprotocol/cow-sdk' -import { abbreviateString, FormatAmountPrecision, formattingAmountPrecision } from 'utils' -import { Settlement as TxSettlement } from 'hooks/useTxBatchTrades' -import { Network } from 'types' -import { networkOptions } from 'components/NetworkSelector' -import ElementsBuilder, { buildGridLayout } from 'apps/explorer/components/TransanctionBatchGraph/elementsBuilder' -import { SPECIAL_ADDRESSES, TOKEN_SYMBOL_UNKNOWN } from 'apps/explorer/const' -import BigNumber from 'bignumber.js' -import { APP_NAME } from 'const' -import { getExplorerUrl } from 'utils/getExplorerUrl' - -const PROTOCOL_NAME = APP_NAME -const INTERNAL_NODE_NAME = `${APP_NAME} Buffer` - -export interface PopperInstance { - scheduleUpdate: () => void - destroy: () => void -} +import { LAYOUTS } from 'apps/explorer/components/TransanctionBatchGraph/layouts' +import { PopperInstance } from 'apps/explorer/components/TransanctionBatchGraph/types' /** * This allows to bind a tooltip (popper.js) around to a cytoscape elements (node, edge) @@ -37,6 +18,10 @@ export function bindPopper( const existingTooltips: HTMLCollectionOf = document.getElementsByClassName(popperClassTarget) Array.from(existingTooltips).forEach((ele: { remove: () => void }): void => ele && ele.remove()) + if (!targetData.tooltip) { + return + } + const target = event.target popperRef.current = target.popper({ content: () => { @@ -98,163 +83,9 @@ export function bindPopper( } export const updateLayout = (cy: Cytoscape.Core, layoutName: string, noAnimation = false): void => { - cy.layout(noAnimation ? { ...layouts[layoutName], animate: false } : layouts[layoutName]).run() + cy.layout(noAnimation ? { ...LAYOUTS[layoutName], animate: false } : LAYOUTS[layoutName]).run() cy.fit() } export const removePopper = (popperInstance: React.MutableRefObject): void => popperInstance.current?.destroy() - -function getTypeNode(account: Account & { owner?: string }): TypeNodeOnTx { - if (account.address && SPECIAL_ADDRESSES[account.address]) { - return TypeNodeOnTx.Special - } else if (account.alias === ALIAS_TRADER_NAME || account.owner) { - return TypeNodeOnTx.Trader - } else if (account.alias === PROTOCOL_NAME) { - return TypeNodeOnTx.CowProtocol - } - - return TypeNodeOnTx.Dex -} - -function getKindEdge(transfer: Transfer & { kind?: OrderKind }): TypeEdgeOnTx { - if (transfer.kind === OrderKind.SELL) { - return TypeEdgeOnTx.sellEdge - } else if (transfer.kind === OrderKind.BUY) { - return TypeEdgeOnTx.buyEdge - } - - return TypeEdgeOnTx.noKind -} - -function showTraderAddress(account: Account, address: string): Account { - const alias = account.alias === ALIAS_TRADER_NAME ? abbreviateString(address, 4, 4) : account.alias - - return { ...account, alias } -} - -function getNetworkParentNode(account: Account, networkName: string): string | undefined { - return account.alias !== ALIAS_TRADER_NAME ? networkName : undefined -} - -function getInternalParentNode(groupNodes: Map, transfer: Transfer): string | undefined { - for (const [key, value] of groupNodes) { - if (value === transfer.from) { - return key - } - } - return undefined -} - -export function getNodes( - txSettlement: TxSettlement, - networkId: Network, - heightSize: number, - layout: string, -): ElementDefinition[] { - if (!txSettlement.accounts) return [] - - const networkName = networkOptions.find((network) => network.id === networkId)?.name - const networkNode = { alias: `${networkName} Liquidity` || '' } - const builder = new ElementsBuilder(heightSize) - builder.node({ type: TypeNodeOnTx.NetworkNode, entity: networkNode, id: networkNode.alias }) - - const groupNodes: Map = new Map() - - for (const key in txSettlement.accounts) { - const account = txSettlement.accounts[key] - let parentNodeName = getNetworkParentNode(account, networkNode.alias) - - const receiverNode = { alias: `${abbreviateString(account.owner || key, 4, 4)}-group` } - - if (account.owner && account.owner !== key) { - if (!groupNodes.has(receiverNode.alias)) { - builder.node({ type: TypeNodeOnTx.NetworkNode, entity: receiverNode, id: receiverNode.alias }) - groupNodes.set(receiverNode.alias, account.owner || key) - } - parentNodeName = receiverNode.alias - } - - if (getTypeNode(account) === TypeNodeOnTx.CowProtocol) { - builder.center({ type: TypeNodeOnTx.CowProtocol, entity: account, id: key }, parentNodeName) - } else { - const receivers = Object.keys(txSettlement.accounts).reduce( - (acc, key) => (txSettlement.accounts?.[key].owner ? [...acc, txSettlement.accounts?.[key].owner] : acc), - [], - ) - - if (receivers.includes(key) && account.owner !== key) { - if (!groupNodes.has(receiverNode.alias)) { - builder.node({ type: TypeNodeOnTx.NetworkNode, entity: receiverNode, id: receiverNode.alias }) - groupNodes.set(receiverNode.alias, account.owner || key) - } - parentNodeName = receiverNode.alias - } - - builder.node( - { - id: key, - type: getTypeNode(account), - entity: showTraderAddress(account, key), - }, - parentNodeName, - ) - } - } - - let internalNodeCreated = false - - txSettlement.transfers.forEach((transfer) => { - // Custom from id when internal transfer to avoid re-using existing node - const fromId = transfer.isInternal ? INTERNAL_NODE_NAME : transfer.from - - // If transfer is internal and a node has not been created yet, create one - if (transfer.isInternal && !internalNodeCreated) { - // Set flag to prevent creating more - internalNodeCreated = true - - const account = { alias: fromId, href: getExplorerUrl(networkId, 'address', transfer.from) } - builder.node( - { - type: TypeNodeOnTx.Special, - entity: account, - id: fromId, - }, - // Put it inside the parent node - getInternalParentNode(groupNodes, transfer), - ) - } - - const kind = getKindEdge(transfer) - const token = txSettlement.tokens[transfer.token] - const tokenSymbol = token?.symbol || TOKEN_SYMBOL_UNKNOWN - const tokenAmount = token?.decimals - ? formattingAmountPrecision(new BigNumber(transfer.value), token, FormatAmountPrecision.highPrecision) - : '-' - - const source = builder.getById(fromId) - const target = builder.getById(transfer.to) - builder.edge( - { type: source?.data.type, id: fromId }, - { type: target?.data.type, id: transfer.to }, - `${tokenSymbol}`, - kind, - { - from: fromId, - // Do not display `to` field on tooltip when internal transfer as it's redundant - ...(transfer.isInternal - ? undefined - : { - to: transfer.to, - }), - amount: `${tokenAmount} ${tokenSymbol}`, - }, - ) - }) - - return builder.build( - layout === 'grid' - ? buildGridLayout(builder._countNodeTypes as Map, builder._center, builder._nodes) - : undefined, - ) -} diff --git a/src/apps/explorer/styled.ts b/src/apps/explorer/styled.ts index bc532f912..3e0ab0f63 100644 --- a/src/apps/explorer/styled.ts +++ b/src/apps/explorer/styled.ts @@ -50,7 +50,7 @@ export const GlobalStyle = createGlobalStyle` .target-popper tr > td:first-child { font-weight: bold; text-transform: uppercase; - width: 6rem; + width: 6.5rem; } ` diff --git a/src/const.ts b/src/const.ts index fe45524d9..d32acbb7a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,6 +1,7 @@ import BigNumber from 'bignumber.js' import BN from 'bn.js' import { TokenErc20, UNLIMITED_ORDER_AMOUNT, BATCH_TIME } from '@gnosis.pm/dex-js' +import { SupportedChainId } from '@cowprotocol/cow-sdk' export { UNLIMITED_ORDER_AMOUNT, FEE_DENOMINATOR, @@ -44,7 +45,6 @@ export const APP_NAME = 'CoW Protocol' export const ETHER_PNG = 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png' -export const ETH_NULL_ADDRESS = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' export const UNLIMITED_ORDER_AMOUNT_BIGNUMBER = new BigNumber(UNLIMITED_ORDER_AMOUNT.toString()) @@ -151,6 +151,13 @@ export const WETH_ADDRESS_GOERLI = '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6' export const WXDAI_ADDRESS_XDAI = '0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d' export const WETH_ADDRESS_XDAI = '0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1' export const NATIVE_TOKEN_ADDRESS = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' +export const NATIVE_TOKEN_ADDRESS_LOWERCASE = NATIVE_TOKEN_ADDRESS.toLowerCase() + +export const WRAPPED_NATIVE_ADDRESS: Record = { + [SupportedChainId.MAINNET]: WETH_ADDRESS_MAINNET, + [SupportedChainId.GOERLI]: WETH_ADDRESS_GOERLI, + [SupportedChainId.GNOSIS_CHAIN]: WXDAI_ADDRESS_XDAI, +} export const ORDER_BOOK_HOPS_MAX = 30 diff --git a/src/hooks/useTransactionData.ts b/src/hooks/useTransactionData.ts new file mode 100644 index 000000000..765f25cda --- /dev/null +++ b/src/hooks/useTransactionData.ts @@ -0,0 +1,107 @@ +import { Network } from 'types' +import { Contract, Trace } from 'api/tenderly/types' +import { useCallback, useEffect, useState } from 'react' +import { getTransactionContracts, getTransactionTrace } from 'api/tenderly' + +type LoadingData = { + data: T + isLoading: boolean + error: string +} + +const TRACE_CACHE = new Map() + +function getCachedData(key: string, cache: Map): T | undefined { + return cache.get(key) +} + +function useTransactionTrace(network: Network | undefined, txHash: string): LoadingData { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [trace, setTrace] = useState() + + const fetchTrace = useCallback(async (network: Network, _txHash: string): Promise => { + setIsLoading(true) + setError('') + try { + const cacheKey = `${network}-${_txHash}` + const cachedData = getCachedData(cacheKey, TRACE_CACHE) + + if (cachedData) { + setTrace(cachedData) + } else { + const trace = await getTransactionTrace(network, _txHash) + + setTrace(trace) + TRACE_CACHE.set(cacheKey, trace) + } + } catch (e) { + setError(e.message) + } + setIsLoading(false) + }, []) + + useEffect(() => { + if (txHash && network) { + fetchTrace(network, txHash) + } + }, [network, txHash, fetchTrace]) + + return { data: trace, isLoading, error } +} + +const CONTRACTS_CACHE = new Map() + +function useTransactionContracts(network: Network | undefined, txHash: string): LoadingData { + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + const [contracts, setContracts] = useState([]) + + const fetchContracts = useCallback(async (network: Network, _txHash: string): Promise => { + setIsLoading(true) + setError('') + try { + const cacheKey = `${network}-${_txHash}` + const cachedData = getCachedData(cacheKey, CONTRACTS_CACHE) + + if (cachedData) { + setContracts(cachedData) + } else { + const contracts = await getTransactionContracts(network, _txHash) + + setContracts(contracts) + CONTRACTS_CACHE.set(cacheKey, contracts) + } + } catch (e) { + setError(e.message) + } + setIsLoading(false) + }, []) + + useEffect(() => { + if (txHash && network) { + fetchContracts(network, txHash) + } + }, [network, txHash, fetchContracts]) + + return { data: contracts, isLoading, error } +} + +export type TransactionData = { + trace: Trace | undefined + contracts: Contract[] + isLoading: boolean + error: string +} + +export function useTransactionData(network: Network | undefined, txHash: string): TransactionData { + const traceData = useTransactionTrace(network, txHash) + const contractsData = useTransactionContracts(network, txHash) + + return { + trace: traceData.data, + contracts: contractsData.data, + isLoading: traceData.isLoading || contractsData.isLoading, + error: traceData.error || contractsData.error, + } +} diff --git a/src/hooks/useTxBatchTrades.tsx b/src/hooks/useTxBatchTrades.tsx deleted file mode 100644 index dc8bb23c1..000000000 --- a/src/hooks/useTxBatchTrades.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { useState, useCallback, useEffect } from 'react' - -import { Network } from 'types' -import { getTradesAccount, getTradesAndTransfers, Trade, Transfer, Account, getAliasFromAddress } from 'api/tenderly' -import { useMultipleErc20 } from './useErc20' -import { SingleErc20State } from 'state/erc20' -import { Order } from 'api/operator' -import BigNumber from 'bignumber.js' -import { usePrevious } from './usePrevious' -import { getExplorerUrl } from 'utils/getExplorerUrl' - -interface TxBatchTrades { - trades: Trade[] - transfers: Transfer[] -} - -type Dict = Record - -type AccountWithReceiver = Account & { owner?: string; uids?: string[] } -type Accounts = Dict | undefined - -export interface Settlement { - tokens: Dict - accounts: Accounts - transfers: Array - trades: Array -} - -export type GetTxBatchTradesResult = { - txSettlement: Settlement - error: string - isLoading: boolean -} - -const getGroupedByTransfers = (arr: Transfer[]): Transfer[] => { - return [ - ...arr - .reduce((r, t) => { - const key = `${t.token}-${t.from}-${t.to}` - - const item = - r.get(key) || - Object.assign({}, t, { - value: new BigNumber(0), - }) - - item.value = BigNumber.sum(item.value, new BigNumber(t.value)) - - return r.set(key, item) - }, new Map()) - .values(), - ] -} - -export function useTxBatchTrades( - networkId: Network | undefined, - txHash: string, - orders: Order[] | undefined, -): GetTxBatchTradesResult { - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState('') - const [txBatchTrades, setTxBatchTrades] = useState({ trades: [], transfers: [] }) - const [accounts, setAccounts] = useState() - const txOrders = usePrevious( - JSON.stringify(orders?.map((o) => ({ owner: o.owner, kind: o.kind, receiver: o.receiver }))), - ) // We need to do a deep comparison here to avoid useEffect to be called twice (Orders array is populated partially from different places) - const [erc20Addresses, setErc20Addresses] = useState([]) - const { value: valueErc20s, isLoading: areErc20Loading } = useMultipleErc20({ networkId, addresses: erc20Addresses }) - - const _fetchTxTrades = useCallback(async (network: Network, _txHash: string, _orders: Order[]): Promise => { - setIsLoading(true) - setError('') - try { - const { transfers, trades } = await getTradesAndTransfers(network, _txHash) - const _accounts: Accounts = Object.fromEntries(await getTradesAccount(network, _txHash, trades, transfers)) - const filteredOrders = _orders?.filter((order) => _accounts[order.owner]) - const orderOwnersReceivers = [ - ...(filteredOrders?.map((order) => order.owner) || []), - ...(filteredOrders?.map((order) => order.receiver) || []), - ] - const groupedByTransfers = getGroupedByTransfers(transfers) - const transfersWithKind: Transfer[] = groupedByTransfers.filter( - (transfer) => !orderOwnersReceivers.includes(transfer.from) && !orderOwnersReceivers.includes(transfer.to), - ) - filteredOrders?.forEach((order) => { - const { owner, kind, receiver } = order - if (!orderOwnersReceivers.includes(owner)) return - transfersWithKind.push( - ...groupedByTransfers - .filter((t) => [t.from, t.to].includes(owner)) - .map((transfer) => ({ ...transfer, kind })), - ) - - transfersWithKind.push( - ...groupedByTransfers - .filter((t) => [t.from, t.to].includes(receiver)) - .map((transfer) => ({ ...transfer, kind })), - ) - orderOwnersReceivers.splice(orderOwnersReceivers.indexOf(owner), 1) - orderOwnersReceivers.splice(orderOwnersReceivers.indexOf(receiver), 1) - }) - - const accountsWithReceiver = _accounts - filteredOrders?.forEach((order) => { - if (!(order.receiver in _accounts)) { - accountsWithReceiver[order.receiver] = { - alias: getAliasFromAddress(order.receiver), - address: order.receiver, - } - } - accountsWithReceiver[order.receiver] = { - ...accountsWithReceiver[order.receiver], - owner: order.owner, - } - }) - Object.values(accountsWithReceiver).forEach((account) => { - if (account.address) account.href = getExplorerUrl(network, 'address', account.address) - }) - - setErc20Addresses(transfersWithKind.map((transfer: Transfer): string => transfer.token)) - setTxBatchTrades({ trades, transfers: transfersWithKind }) - setAccounts(accountsWithReceiver) - } catch (e) { - const msg = `Failed to fetch tx batch trades` - console.error(msg, e) - setError(msg) - } finally { - setIsLoading(false) - } - }, []) - - useEffect(() => { - if (!networkId || !txOrders) { - return - } - - _fetchTxTrades(networkId, txHash, JSON.parse(txOrders)) - }, [_fetchTxTrades, networkId, txHash, txOrders]) - - return { - txSettlement: { ...txBatchTrades, tokens: valueErc20s, accounts }, - error, - isLoading: isLoading || areErc20Loading, - } -} From 318911f728988f200c46fd527fbead20c7cef880 Mon Sep 17 00:00:00 2001 From: Alfetopito Date: Wed, 28 Jun 2023 16:18:34 +0100 Subject: [PATCH 2/2] chore: bump version to 2.24.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ea24cb709..b54880fff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cowprotocol/explorer", - "version": "2.23.0", + "version": "2.24.0", "description": "", "main": "src/index.js", "sideEffects": false,