diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po index 9d35edccb8e..447c94978d2 100644 --- a/apps/cowswap-frontend/src/locales/en-US.po +++ b/apps/cowswap-frontend/src/locales/en-US.po @@ -686,7 +686,6 @@ msgid "Sell amount" msgstr "Sell amount" #: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderEstimatedExecutionPrice/OrderEstimatedExecutionPrice.pure.tsx -#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTable/Row/WarningEstimatedPrice/OrderRowWarningEstimatedPrice.pure.tsx #: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderStatusBox/getOrderStatusTitleAndColor.ts #: apps/cowswap-frontend/src/modules/ordersTable/state/params/ordersTableParams.atom.ts msgid "Unfillable" @@ -4400,7 +4399,6 @@ msgid "Limit Order" msgstr "Limit Order" #: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderEstimatedExecutionPrice/OrderEstimatedExecutionPrice.pure.tsx -#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTable/Row/WarningEstimatedPrice/OrderRowWarningEstimatedPrice.pure.tsx msgid "Insufficient allowance" msgstr "Insufficient allowance" @@ -8367,7 +8365,6 @@ msgid "View on Bridge Explorer ↗" msgstr "View on Bridge Explorer ↗" #: apps/cowswap-frontend/src/modules/ordersTable/pure/OrderEstimatedExecutionPrice/OrderEstimatedExecutionPrice.pure.tsx -#: apps/cowswap-frontend/src/modules/ordersTable/pure/OrdersTable/Row/WarningEstimatedPrice/OrderRowWarningEstimatedPrice.pure.tsx msgid "Insufficient balance" msgstr "Insufficient balance" diff --git a/apps/explorer/src/api/operator/types.ts b/apps/explorer/src/api/operator/types.ts index 403ca06da9f..a939d7b19cf 100644 --- a/apps/explorer/src/api/operator/types.ts +++ b/apps/explorer/src/api/operator/types.ts @@ -1,4 +1,5 @@ import { + AddressKey, CompetitionOrderStatus, EnrichedOrder, OrderKind, @@ -101,6 +102,10 @@ export type Order = Pick< executedFeeAmount: BigNumber executedFee: BigNumber | null totalFee: BigNumber + // Derived client-side from the trades. Undefined when unknown; `[]` means no fee was charged. + protocolFees?: ProtocolFee[] + // Native-token wei, from the orderbook. Undefined if unsettled, or settled before it was recorded. + gasCost?: BigNumber cancelled: boolean status: OrderStatus partiallyFilled: boolean @@ -114,8 +119,17 @@ export type Order = Pick< export type OrderCompetitionStatus = CompetitionOrderStatus -// Raw API response -export type RawOrder = EnrichedOrder +/** One fee policy's total across all of an order's fills. */ +export type ProtocolFee = { + amount: BigNumber + tokenAddress: AddressKey + type: ProtocolFeeType + // Index in a fill's `executedProtocolFees`; preserves the order the fees were applied in. + position: number +} + +// TODO: drop the `gasCost` intersection once `EnrichedOrder` in @cowprotocol/cow-sdk declares it. +export type RawOrder = EnrichedOrder & { gasCost?: string | null } export type RawOrderStatusFromAPI = (typeof RAW_ORDER_STATUS)[keyof typeof RAW_ORDER_STATUS] @@ -145,4 +159,11 @@ export type Trade = Pick setShowDecodedAppData((state) => !state)}> + setShowDecodedAppData((state) => !state)}> {showDecodedAppData ? '[-] Show less' : '[+] Show more'} - + )}
diff --git a/apps/explorer/src/components/AppDataRowContent/AppDataRowContent.styles.tsx b/apps/explorer/src/components/common/ShowMoreButton.tsx similarity index 100% rename from apps/explorer/src/components/AppDataRowContent/AppDataRowContent.styles.tsx rename to apps/explorer/src/components/common/ShowMoreButton.tsx diff --git a/apps/explorer/src/components/orders/DetailsTable/detailsTableTooltips.tsx b/apps/explorer/src/components/orders/DetailsTable/detailsTableTooltips.tsx index 67c1bd4dfce..cc42e7d0833 100644 --- a/apps/explorer/src/components/orders/DetailsTable/detailsTableTooltips.tsx +++ b/apps/explorer/src/components/orders/DetailsTable/detailsTableTooltips.tsx @@ -64,4 +64,6 @@ export const DetailsTableTooltips = { filled: 'Indicates what percentage amount this order has been filled and the amount sold/bought. Amount sold includes the fee.', fees: 'The amount of fees paid for this order. This will show a progressive number for orders with partial fills. Might take a few minutes to show the final value.', + feesBreakdown: + 'The costs and fees charged for this order, totaled per token, with a breakdown into the on-chain network costs and each fee applied. Might take a few minutes to show the final value.', } diff --git a/apps/explorer/src/components/orders/DetailsTable/items/CostAndFeesItem.tsx b/apps/explorer/src/components/orders/DetailsTable/items/CostAndFeesItem.tsx index c6585845396..5b2b1461d49 100644 --- a/apps/explorer/src/components/orders/DetailsTable/items/CostAndFeesItem.tsx +++ b/apps/explorer/src/components/orders/DetailsTable/items/CostAndFeesItem.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' import { GasFeeDisplay } from 'components/orders/GasFeeDisplay' +import { useFeeDisplayFeatureFlag } from 'hooks/useFeeDisplayFeatureFlag' import { Order } from '../../../../api/operator' import { DetailRow } from '../../../common/DetailRow' @@ -11,9 +12,19 @@ interface CostAndFeesItemProps { } export function CostAndFeesItem({ order }: CostAndFeesItemProps): ReactNode { + const isFeeDisplayEnabled = useFeeDisplayFeatureFlag() + // GasFeeDisplay falls back to the legacy fee without a usable gas cost and fee list, so the + // row's label, tooltip and layout have to fall back with it. + const showBreakdown = isFeeDisplayEnabled && Boolean(order.gasCost?.isGreaterThan(0)) && Boolean(order.protocolFees) + return ( - - + // The breakdown is a total plus an expandable table, so it stacks; the legacy fee stays inline. + + ) } diff --git a/apps/explorer/src/components/orders/GasFeeDisplay/GasFeeDisplay.stories.tsx b/apps/explorer/src/components/orders/GasFeeDisplay/GasFeeDisplay.stories.tsx deleted file mode 100644 index b6b08111f39..00000000000 --- a/apps/explorer/src/components/orders/GasFeeDisplay/GasFeeDisplay.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react' - -import { Story, Meta } from '@storybook/react/types-6-0' -import BigNumber from 'bignumber.js' -import { ZERO_BIG_NUMBER } from 'const' -import { GlobalStyles, ThemeToggler } from 'storybook/decorators' - -import { RICH_ORDER, WETH } from '../../../test/data' - -import { GasFeeDisplay, Props } from '.' - -export default { - title: 'orders/GasFeeDisplay', - component: GasFeeDisplay, - decorators: [GlobalStyles, ThemeToggler], - argTypes: { order: { control: null } }, -} as Meta - -const Template: Story = (args) => ( -
- -
-) - -const order = { - ...RICH_ORDER, - feeAmount: new BigNumber('200000'), - executedFeeAmount: ZERO_BIG_NUMBER, -} - -const defaultProps: Props = { order } - -export const NoFee = Template.bind({}) -NoFee.args = { ...defaultProps } - -export const PartialFee = Template.bind({}) -PartialFee.args = { ...defaultProps, order: { ...order, executedFeeAmount: new BigNumber('100000') } } - -export const FullFee = Template.bind({}) -FullFee.args = { ...defaultProps, order: { ...order, executedFeeAmount: order.feeAmount, fullyFilled: true } } - -export const TinyFee6DecimalsToken = Template.bind({}) -TinyFee6DecimalsToken.args = { ...defaultProps, order: { ...order, executedFeeAmount: new BigNumber('1') } } - -export const TinyFee18DecimalsToken = Template.bind({}) -TinyFee18DecimalsToken.args = { - ...defaultProps, - order: { ...order, executedFeeAmount: new BigNumber('1'), sellToken: WETH }, -} diff --git a/apps/explorer/src/components/orders/GasFeeDisplay/breakdown.ts b/apps/explorer/src/components/orders/GasFeeDisplay/breakdown.ts new file mode 100644 index 00000000000..92afd0b542b --- /dev/null +++ b/apps/explorer/src/components/orders/GasFeeDisplay/breakdown.ts @@ -0,0 +1,61 @@ +import { AddressKey, getAddressKey } from '@cowprotocol/cow-sdk' + +import { TokenErc20 } from '@gnosis.pm/dex-js' +import BigNumber from 'bignumber.js' +import { ZERO_BIG_NUMBER } from 'const' + +import { ProtocolFee, ProtocolFeeType } from 'api/operator' + +// The API says how each fee was calculated but not who charged it, so labels name the policy. +export const FEE_TYPE_LABELS: Record = { + [ProtocolFeeType.Surplus]: 'Surplus fee', + [ProtocolFeeType.Volume]: 'Volume fee', + [ProtocolFeeType.PriceImprovement]: 'Price improvement fee', + [ProtocolFeeType.Unknown]: 'Fee', +} + +export type LineItem = { label: string; tokenAddress: AddressKey; amount: BigNumber } + +/** + * One row per cost: network costs first, then the fees in the order they were applied. + * Repeated fee types get numbered so the rows stay distinguishable. + */ +export function buildLineItems(protocolFees: ProtocolFee[], gasCost: BigNumber, nativeKey: AddressKey): LineItem[] { + const labels = protocolFees.map((fee) => FEE_TYPE_LABELS[fee.type]) + const occurrences = new Map() + const numbered = new Map() + + for (const label of labels) occurrences.set(label, (occurrences.get(label) ?? 0) + 1) + + const feeItems = protocolFees.map(({ tokenAddress, amount }, index) => { + const label = labels[index] + const seen = (numbered.get(label) ?? 0) + 1 + numbered.set(label, seen) + + return { label: occurrences.get(label) === 1 ? label : `${label} (${seen})`, tokenAddress, amount } + }) + + return [{ label: 'Network costs', tokenAddress: nativeKey, amount: gasCost }, ...feeItems] +} + +/** Indexes whatever token metadata is available so line items can be rendered with decimals. */ +export function indexTokensByKey(tokens: Array): Map { + const map = new Map() + + for (const token of tokens) { + if (token) map.set(getAddressKey(token.address), token) + } + + return map +} + +/** One total per token; wrapped native deliberately stays separate from native. */ +export function sumByToken(lineItems: LineItem[]): Array<[AddressKey, BigNumber]> { + const byToken = new Map() + + for (const { tokenAddress, amount } of lineItems) { + byToken.set(tokenAddress, (byToken.get(tokenAddress) ?? ZERO_BIG_NUMBER).plus(amount)) + } + + return Array.from(byToken) +} diff --git a/apps/explorer/src/components/orders/GasFeeDisplay/index.tsx b/apps/explorer/src/components/orders/GasFeeDisplay/index.tsx index cf170c7c85d..fe0af39c701 100644 --- a/apps/explorer/src/components/orders/GasFeeDisplay/index.tsx +++ b/apps/explorer/src/components/orders/GasFeeDisplay/index.tsx @@ -1,60 +1,132 @@ -// TODO: Enable once API is ready -// import { NumbersBreakdown } from 'components/orders/NumbersBreakdown' +import { Fragment, ReactNode, useMemo } from 'react' -import React, { useMemo } from 'react' +import { shortenAddress } from '@cowprotocol/common-utils' +import { AddressKey, getAddressKey } from '@cowprotocol/cow-sdk' -import { ZERO_BIG_NUMBER } from 'const' +import { TokenErc20 } from '@gnosis.pm/dex-js' +import BigNumber from 'bignumber.js' +import { NumbersBreakdown } from 'components/orders/NumbersBreakdown' +import { TokenAmount } from 'components/token/TokenAmount' +import { NATIVE_TOKEN_ADDRESS, NATIVE_TOKEN_PER_NETWORK, ZERO_BIG_NUMBER } from 'const' +import { useMultipleErc20 } from 'hooks/useErc20' +import { useNetworkId } from 'state/network' import styled from 'styled-components/macro' -import { Order } from 'api/operator' +import { Order, ProtocolFee } from 'api/operator' import { formatTokenAmount } from 'utils/tokenFormatting' -const Wrapper = styled.div` +import { buildLineItems, indexTokensByKey, sumByToken } from './breakdown' + +const LegacyWrapper = styled.div` > span { margin: 0 0.5rem 0 0; } ` -export type Props = { order: Order } - -// TODO: Enable once API is ready -// const fetchFeeBreakdown = async (initialFee: string): Promise => { -// // TODO: Simulating API call to fetch fee breakdown data -// return new Promise((resolve) => { -// resolve({ -// networkCosts: 'TODO: Get network costs here', -// fee: 'TODO: Get fee here', -// total: initialFee, -// }) -// }) -// } - -// TODO: Enable once API is ready -// const renderFeeBreakdown = (data: any): React.ReactNode => { -// return ( -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -//
Network Costs:{data.networkCosts}
Fee:{data.fee}
Total Costs & Fees:{data.total}
-// ) -// } - -export function GasFeeDisplay(props: Props): React.ReactNode | null { - const { - order: { feeAmount, sellToken, sellTokenAddress, fullyFilled, totalFee }, - } = props +export type Props = { + order: Order + /** The caller gates this on `isExplorerFeeDisplayEnabled` plus a usable gas cost and fee list. */ + showBreakdown?: boolean +} + +export function GasFeeDisplay(props: Props): ReactNode { + const { order, showBreakdown = false } = props + + // Without both the gas cost and the fees, a total would silently omit a component. + if (!showBreakdown || !order.gasCost || !order.gasCost.isGreaterThan(0) || !order.protocolFees) { + return + } + + return +} + +function CostsAndFeesBreakdown({ + order, + gasCost, + protocolFees, +}: { + order: Order + gasCost: BigNumber + protocolFees: ProtocolFee[] +}): ReactNode { + const networkId = useNetworkId() ?? undefined + + const feeTokenAddresses = useMemo(() => protocolFees.map((fee) => fee.tokenAddress), [protocolFees]) + const { value: feeTokens, isLoading: areFeeTokensLoading } = useMultipleErc20({ + networkId, + addresses: feeTokenAddresses, + }) + + const nativeToken = networkId + ? NATIVE_TOKEN_PER_NETWORK[networkId as keyof typeof NATIVE_TOKEN_PER_NETWORK] + : undefined + const nativeKey = getAddressKey(nativeToken?.address ?? NATIVE_TOKEN_ADDRESS) + + const tokenByKey = useMemo( + () => indexTokensByKey([...Object.values(feeTokens), nativeToken, order.buyToken, order.sellToken]), + [feeTokens, nativeToken, order.buyToken, order.sellToken], + ) + + const lineItems = useMemo(() => buildLineItems(protocolFees, gasCost, nativeKey), [protocolFees, gasCost, nativeKey]) + + const totals = useMemo(() => sumByToken(lineItems), [lineItems]) + + // Amounts mean nothing without decimals; keep the legacy fee up until they load. + if (areFeeTokensLoading) return + + return ( + <> + + {totals.map(([tokenAddress, amount], index) => ( + + {index > 0 && ', '} + + + ))} + + {/* A lone network-costs row would just repeat the total. */} + {lineItems.length > 1 && ( + + + + {lineItems.map((item, index) => ( + + + + + ))} + +
{item.label}: + +
+
+ )} + + ) +} + +// No metadata means no decimals, so mark the figure unscaled rather than pass it off as an amount. +function FeeAmount({ + amount, + token, + tokenAddress, +}: { + amount: BigNumber + token?: TokenErc20 + tokenAddress: AddressKey +}): ReactNode { + if (!token) return `${amount.toString(10)} (raw) ${shortenAddress(tokenAddress)}` + + return +} + +// The combined executed fee in the sell token, shown whenever the breakdown can't be. +function LegacyFeeDisplay({ order }: { order: Order }): ReactNode { + const { feeAmount, sellToken, sellTokenAddress, fullyFilled, totalFee } = order const { executedFeeFormatted, totalFeeFormatted, quoteSymbol } = useMemo(() => { if (!sellToken) { @@ -71,32 +143,19 @@ export function GasFeeDisplay(props: Props): React.ReactNode | null { return { executedFeeFormatted, totalFeeFormatted, quoteSymbol } }, [totalFee, feeAmount, sellToken, sellTokenAddress]) - const noFee = useMemo(() => feeAmount.isZero() && totalFee.isZero(), [feeAmount, totalFee]) + const noFee = feeAmount.isZero() && totalFee.isZero() - const FeeElement = useMemo( - () => ( + return ( + {noFee ? '-' : `${executedFeeFormatted} ${quoteSymbol}`} {!fullyFilled && feeAmount.gt(ZERO_BIG_NUMBER) && ( - <> - - of {totalFeeFormatted} {quoteSymbol} - - + + {' '} + of {totalFeeFormatted} {quoteSymbol} + )} - ), - [noFee, executedFeeFormatted, quoteSymbol, fullyFilled, feeAmount, totalFeeFormatted], - ) - - return ( - - {FeeElement} - {/*TODO: Enable once API is ready*/} - {/* fetchFeeBreakdown(`${formattedExecutedFee} ${quoteSymbol}`)}*/} - {/* renderContent={renderFeeBreakdown}*/} - {/*/>*/} - + ) } diff --git a/apps/explorer/src/components/orders/NumbersBreakdown/index.tsx b/apps/explorer/src/components/orders/NumbersBreakdown/index.tsx new file mode 100644 index 00000000000..f7b042809fb --- /dev/null +++ b/apps/explorer/src/components/orders/NumbersBreakdown/index.tsx @@ -0,0 +1,52 @@ +import { PropsWithChildren, ReactNode } from 'react' + +import { Media } from '@cowprotocol/ui' + +import { ShowMoreButton } from 'components/common/ShowMoreButton' +import useSafeState from 'hooks/useSafeState' +import styled from 'styled-components/macro' + +const DetailsWrapper = styled.div` + display: flex; + margin: 0 0 1rem; + border-radius: 0.6rem; + line-height: 1.6; + width: max-content; + align-items: flex-start; + word-break: break-all; + overflow: auto; + border: 1px solid rgb(151 151 184 / 10%); + background: rgb(151 151 184 / 10%); + + ${Media.upToSmall()} { + width: 100%; + } + + table { + width: 100%; + border-collapse: collapse; + } + + td { + padding: 0.1rem 0.5rem; + } + + tr:not(:last-child) td { + border-bottom: 1px solid rgb(151 151 184 / 15%); + } +` + +export const NumbersBreakdown = ({ children }: PropsWithChildren): ReactNode => { + const [showDetails, setShowDetails] = useSafeState(false) + + const handleToggle = (): void => { + setShowDetails(!showDetails) + } + + return ( + <> + {showDetails ? '[-] Show less' : '[+] Show more'} + {showDetails && {children}} + + ) +} diff --git a/apps/explorer/src/components/orders/OrderDetails/index.tsx b/apps/explorer/src/components/orders/OrderDetails/index.tsx index f1e0920af94..ac58cebd91b 100644 --- a/apps/explorer/src/components/orders/OrderDetails/index.tsx +++ b/apps/explorer/src/components/orders/OrderDetails/index.tsx @@ -25,7 +25,7 @@ import { formatPercentage } from 'utils' import { useCrossChainOrder } from 'modules/bridge' -import { Order, ORDER_FINAL_FAILED_STATUSES, Trade } from 'api/operator' +import { Order, ORDER_FINAL_FAILED_STATUSES, ProtocolFee, Trade } from 'api/operator' import { FillsTableContext } from './context/FillsTableContext' import { TitleUid, StyledExplorerTabs, TabContent } from './styled' @@ -38,6 +38,8 @@ import { StatusLabel } from '../StatusLabel' type Props = { order: Order | null trades: Trade[] + // Derived from *all* trades, not the current fills page. Undefined while unknown. + protocolFees?: ProtocolFee[] isOrderLoading: boolean areTradesLoading: boolean errors: Errors @@ -70,6 +72,7 @@ const tabItems = ( _order: Order | null, crossChainOrderResponse: SWRResponse, trades: Trade[], + protocolFees: ProtocolFee[] | undefined, areTradesLoading: boolean, isOrderLoading: boolean, onChangeTab: (tab: TabView) => void, @@ -80,7 +83,7 @@ const tabItems = ( solvedBy?: OrderSolverInfo, isSolvedByLoading?: boolean, ): TabItemInterface[] => { - const order = getOrderWithTxHash(_order, trades, hasMultipleTrades) + const order = enrichOrderFromTrades(_order, trades, hasMultipleTrades, protocolFees) const areTokensLoaded = Boolean(order?.buyToken && order?.sellToken) const isLoadingForTheFirstTime = isOrderLoading && !areTokensLoaded const filledPercentage = order?.filledPercentage && formatPercentage(order.filledPercentage) @@ -149,15 +152,25 @@ const tabItems = ( } /** - * Get the order with txHash set if it has a single trade - * - * That is the case for any filled fill or kill or a partial fill that has a single trade + * Returns the order enriched from its trades: the fee breakdown, plus txHash and executionDate when + * there is a single trade (a fill or kill, or a partial fill with one trade so far). */ -function getOrderWithTxHash(order: Order | null, trades: Trade[], hasMultipleTrades: boolean): Order | null { - if (order && trades.length === 1 && !hasMultipleTrades) { - return { ...order, txHash: trades[0].txHash || undefined, executionDate: trades[0].executionTime || undefined } +function enrichOrderFromTrades( + order: Order | null, + trades: Trade[], + hasMultipleTrades: boolean, + protocolFees: ProtocolFee[] | undefined, +): Order | null { + if (!order) return order + + const enriched = { ...order, protocolFees } + + if (trades.length === 1 && !hasMultipleTrades) { + enriched.txHash = trades[0].txHash || undefined + enriched.executionDate = trades[0].executionTime || undefined } - return order + + return enriched } function hasMultipleTradesForOrder(trades: Trade[], tableState: TableState): boolean { @@ -173,6 +186,7 @@ export const OrderDetails: React.FC = (props) => { areTradesLoading, errors, trades, + protocolFees, tableState, setPageSize, setPageOffset, @@ -192,7 +206,7 @@ export const OrderDetails: React.FC = (props) => { const crossChainOrderResponse = useCrossChainOrder(order?.uid) const hasMultipleTrades = hasMultipleTradesForOrder(trades, tableState) const isMultiFill = order?.partiallyFillable && !order.txHash && hasMultipleTrades - const orderWithTxHash = getOrderWithTxHash(order, trades, hasMultipleTrades) + const orderWithTxHash = enrichOrderFromTrades(order, trades, hasMultipleTrades, protocolFees) const { solver: solvedBy, isLoading: isSolvedByLoading } = useOrderSolver( showSolverDetails && !isMultiFill ? orderWithTxHash : null, ) @@ -264,6 +278,7 @@ export const OrderDetails: React.FC = (props) => { order, crossChainOrderResponse, trades, + protocolFees, areTradesLoading, isOrderLoading, onChangeTab, diff --git a/apps/explorer/src/components/orders/OrderSurplusDisplay/index.tsx b/apps/explorer/src/components/orders/OrderSurplusDisplay/index.tsx index 6fc9a79f305..7dc1b29c369 100644 --- a/apps/explorer/src/components/orders/OrderSurplusDisplay/index.tsx +++ b/apps/explorer/src/components/orders/OrderSurplusDisplay/index.tsx @@ -11,45 +11,9 @@ import { BaseIconTooltipOnHover } from 'components/Tooltip' import styled, { css, FlattenSimpleInterpolation } from 'styled-components/macro' import { Order } from 'api/operator' -// TODO: Enable once API is ready -// import { NumbersBreakdown } from 'components/orders/NumbersBreakdown' const Wrapper = styled.div`` -// TODO: Enable once API is ready -// const fetchSurplusBreakdown = async (initialSurplus: React.ReactNode): Promise => { -// // TODO: Simulating API call to fetch surplus breakdown data -// return new Promise((resolve) => { -// resolve({ -// networkCosts: 'TODO: BIG NUMBER HERE ETH', -// fee: 'TODO: FEE NUMBER HERE', -// total: initialSurplus, -// }) -// }) -// } - -// TODO: Enable once API is ready -// const renderSurplusBreakdown = (data: any): React.ReactNode => { -// return ( -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -//
Protected slippage:{data.networkCosts}
Price improvement (user share):{data.fee}
Total surplus:{data.total}
-// ) -// } - export type Props = { order: Order; amountSmartFormatting?: boolean } & React.HTMLAttributes type OrderSurplus = { amount: BigNumber; percentage: BigNumber; surplusToken: TokenErc20 } @@ -58,16 +22,9 @@ export function OrderSurplusDisplay(props: Props): React.ReactNode | null { if (!surplus) return null - const SurplusElement = - return ( - {SurplusElement} - {/*TODO: Enable once API is ready*/} - {/* fetchSurplusBreakdown(SurplusElement)}*/} - {/* renderContent={renderSurplusBreakdown}*/} - {/*/>*/} + ) } diff --git a/apps/explorer/src/explorer/components/OrderWidget/index.tsx b/apps/explorer/src/explorer/components/OrderWidget/index.tsx index 54eabf3506a..4cd64962d60 100644 --- a/apps/explorer/src/explorer/components/OrderWidget/index.tsx +++ b/apps/explorer/src/explorer/components/OrderWidget/index.tsx @@ -5,8 +5,9 @@ import { useOrderAndErc20s } from '../../../hooks/useOperatorOrder' import { useOrderTrades } from '../../../hooks/useOperatorTrades' import { useSanitizeOrderIdAndUpdateUrl } from '../../../hooks/useSanitizeOrderIdAndUpdateUrl' import { RedirectToNetwork, useNetworkId } from '../../../state/network' +import { Errors } from '../../../types' import { ORDER_QUERY_INTERVAL } from '../../const' -import { useTable } from '../TokensTableWidget/useTable' +import { TableState, useTable } from '../TokensTableWidget/useTable' const RESULTS_PER_PAGE = 10 @@ -15,7 +16,7 @@ export const OrderWidget: React.FC = () => { const orderId = useSanitizeOrderIdAndUpdateUrl() const { - state: tableState, + state: baseTableState, setPageSize, setPageOffset, handleNextPage, @@ -25,22 +26,21 @@ export const OrderWidget: React.FC = () => { const { order, isLoading: isOrderLoading, - errors, + errors: orderErrors, errorOrderPresentInNetworkId, } = useOrderAndErc20s(orderId, ORDER_QUERY_INTERVAL) const { trades, + protocolFees, error, isLoading: areTradesLoading, hasNextPage, - } = useOrderTrades(order, tableState.pageOffset, tableState.pageSize) - - // eslint-disable-next-line react-hooks/immutability - tableState['hasNextPage'] = hasNextPage + } = useOrderTrades(order, baseTableState.pageOffset, baseTableState.pageSize) + const tableState: TableState = { ...baseTableState, hasNextPage } + const errors: Errors = { ...orderErrors } if (error) { - // eslint-disable-next-line react-hooks/immutability - errors['trades'] = error + errors.trades = error } if (errorOrderPresentInNetworkId && networkId !== errorOrderPresentInNetworkId) { @@ -51,6 +51,7 @@ export const OrderWidget: React.FC = () => { } = {} -export function useOrderTrades(order: Order | null, offset = 0, limit = 10): Result { - const [error, setError] = useState() - const [trades, setTrades] = useState([]) - const [rawTrades, setRawTrades] = useState(null) - const [tradesTimestamps, setTradesTimestamps] = useState({}) - const [hasNextPage, setHasNextPage] = useState(false) - - // Here we assume that we are already in the right network - // contrary to useOrder hook, where it searches all networks for a given orderId - const networkId = useNetworkId() - - const fetchTrades = useCallback( - async (controller: AbortController, _networkId: Network): Promise => { - if (!order) return - - const { uid: orderId } = order +type AllTradesResult = { + // Undefined while unknown (loading, failed, or no order). + rawTrades?: RawTrade[] + error?: UiError + isLoading: boolean +} - try { - const trades = await getTrades({ networkId: _networkId, orderId, offset, limit: limit + 1 }) +// Large enough that most orders need a single call. Exported so tests can serve a full page. +export const ALL_TRADES_PAGE_SIZE = 1000 +// Safety bound; reaching it means the paging is broken, not that the order has this many fills. +const MAX_TRADES_PAGES = 100 - if (controller.signal.aborted) return +const TRADES_ERROR = 'Failed to fetch trades' - setRawTrades(trades) - setError(undefined) - } catch (e) { - const msg = `Failed to fetch trades` - console.error(msg, e) +/** + * An order's fills: the requested page, enriched with timestamps, plus the fee breakdown over all + * of them. One fetch serves both, so the order details page reads the trades once. + */ +export function useOrderTrades(order: Order | null, offset = 0, limit = 10): Result { + const { rawTrades, error, isLoading } = useAllOrderTrades(order) + const [tradesTimestamps, setTradesTimestamps] = useState({}) - setRawTrades([]) - setError({ message: msg, type: 'error' }) - } - }, - [order, offset, limit], - ) + // Paging client-side: the API offsets PROD and BARN separately, so it cannot page the merged list. + const pageTrades = useMemo(() => rawTrades?.slice(offset, offset + limit) ?? [], [rawTrades, offset, limit]) - // Fetch blocks timestamps for trades + // Fetch blocks timestamps for the visible page only useEffect(() => { - if (!rawTrades) return + if (!pageTrades.length) return - fetchTradesTimestamps(rawTrades) - .then(setTradesTimestamps) + let cancelled = false + + fetchTradesTimestamps(pageTrades) + // Merged, not replaced: a timestamp belongs to its tx, so earlier pages stay correct. + .then((timestamps) => { + if (!cancelled) setTradesTimestamps((current) => ({ ...current, ...timestamps })) + }) .catch((error) => { console.error('Trades timestamps fetching error: ', error) - - setTradesTimestamps({}) }) - }, [rawTrades]) + + return (): void => { + cancelled = true + } + }, [pageTrades]) // Transform trades adding tokens and timestamps - useEffect(() => { - if (!order || !rawTrades) return + const trades = useMemo(() => { + if (!order) return [] const { buyToken, sellToken } = order - const trades = rawTrades.map((trade) => { + const trades = pageTrades.map((trade) => { const timestamp = trade.txHash ? tradesTimestamps[trade.txHash] : undefined return { ...transformTrade(trade, order, timestamp), buyToken, sellToken } }) // sort trades by execution time, newest first - trades.sort((a, b) => { + return trades.sort((a, b) => { if (a.executionTime && b.executionTime) { return b.executionTime > a.executionTime ? 1 : -1 } return 0 }) + }, [order, pageTrades, tradesTimestamps]) - const hasNext = trades.length > limit - setHasNextPage(hasNext) + const protocolFees = useMemo(() => rawTrades && getProtocolFees(rawTrades), [rawTrades]) - setTrades(hasNext ? trades.slice(0, limit) : trades) - }, [order, rawTrades, tradesTimestamps, limit]) + const hasNextPage = (rawTrades?.length ?? 0) > offset + limit + // SWR reports nothing pending without a key, but the caller is still waiting on the order itself. + const areTradesLoading = isLoading || (!rawTrades && !error) - const executedSellAmount = order?.executedSellAmount.toString() - const executedBuyAmount = order?.executedBuyAmount.toString() - - useEffect(() => { - if (!networkId || !order?.uid) { - return - } - - const controller = new AbortController() - - fetchTrades(controller, networkId) - return (): void => controller.abort() - // Depending on order UID to avoid re-fetching when obj changes but ID remains the same - // Depending on `executedBuy/SellAmount`s string to force a refetch when there are new trades - // using the string version because hooks are bad at detecting Object changes - }, [fetchTrades, networkId, order?.uid, executedSellAmount, executedBuyAmount]) - - const isLoading = rawTrades === null - - return useMemo(() => ({ trades, error, isLoading, hasNextPage }), [trades, error, isLoading, hasNextPage]) + return useMemo( + () => ({ trades, protocolFees, error, isLoading: areTradesLoading, hasNextPage }), + [trades, protocolFees, error, areTradesLoading, hasNextPage], + ) } -/** - * Fetches trades for given order - */ -// TODO: Break down this large function into smaller functions - async function fetchTradesTimestamps(rawTrades: RawTrade[]): Promise { const requests = rawTrades.map(({ txHash, blockNumber }) => { const cachedValue = tradesTimestampsCache[blockNumber] @@ -143,3 +130,62 @@ async function fetchTradesTimestamps(rawTrades: RawTrade[]): Promise { + const allTrades: RawTrade[] = [] + const seen = new Set() + + for (let page = 0; page < MAX_TRADES_PAGES; page++) { + const trades = await getTrades({ networkId, orderId, offset: allTrades.length, limit: ALL_TRADES_PAGE_SIZE }) + + // Already-collected records mean `offset` was ignored and an earlier page was re-served. + const newTrades = trades.filter((trade) => { + const key = `${trade.txHash}-${trade.logIndex}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + + allTrades.push(...newTrades) + + // Per the `/api/v2/trades` contract, a short page is the last one; the `newTrades` check covers + // an API that serves full pages forever. + if (trades.length < ALL_TRADES_PAGE_SIZE || newTrades.length === 0) return allTrades + } + + throw new Error(`Reached ${MAX_TRADES_PAGES} pages of trades for order ${orderId}; the API is not paging correctly`) +} + +/** Every fill of an order, as one SWR entry that {@link useOrderTrades} pages and aggregates. */ +function useAllOrderTrades(order: Order | null): AllTradesResult { + // Here we assume that we are already in the right network + // contrary to useOrder hook, where it searches all networks for a given orderId + const networkId = useNetworkId() + const orderUid = order?.uid + + // In the key so a new fill refetches. They change only when a fill lands, not on every poll. + const executedSellAmount = order?.executedSellAmount.toString() + const executedBuyAmount = order?.executedBuyAmount.toString() + + const { data, error, isLoading } = useSWR( + networkId && orderUid ? ['allOrderTrades', networkId, orderUid, executedSellAmount, executedBuyAmount] : null, + ([, network, uid]: [string, Network, string, ...unknown[]]) => getAllOrderTrades(network, uid), + { + ...SWR_NO_REFRESH_OPTIONS, + errorRetryCount: 0, + onError: (err: unknown) => { + console.error(`[useAllOrderTrades] ${TRADES_ERROR}`, normalizeError(err)) + }, + }, + ) + + return useMemo( + () => ({ + rawTrades: data, + error: error ? { message: TRADES_ERROR, type: 'error' } : undefined, + isLoading, + }), + [data, error, isLoading], + ) +} diff --git a/apps/explorer/src/test/components/costsAndFeesBreakdown.test.tsx b/apps/explorer/src/test/components/costsAndFeesBreakdown.test.tsx new file mode 100644 index 00000000000..be9560bae84 --- /dev/null +++ b/apps/explorer/src/test/components/costsAndFeesBreakdown.test.tsx @@ -0,0 +1,98 @@ +import { ReactNode } from 'react' + +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import BigNumber from 'bignumber.js' +import { SWRConfig } from 'swr' + +import { getTrades, Order, RawTrade } from 'api/operator' + +import { GasFeeDisplay } from '../../components/orders/GasFeeDisplay' +import { useOrderTrades } from '../../hooks/useOperatorTrades' +import { RICH_ORDER, USDT, WETH } from '../data' + +jest.mock('state/network', () => ({ + useNetworkId: jest.fn(() => 1), +})) + +// Token-metadata boundary; the order's own buy/sell tokens (USDT/WETH) resolve without it. +jest.mock('hooks/useErc20', () => ({ + useMultipleErc20: jest.fn(() => ({ isLoading: false, error: {}, value: {} })), +})) + +// Only the HTTP call is mocked; the real types/enums keep getProtocolFees and the labels running. +jest.mock('api/operator', () => ({ + ...jest.requireActual('api/operator/types'), + getTrades: jest.fn(), +})) + +// Chain boundary: the hook reads block timestamps for the fills it pages, which this view ignores. +jest.mock('../../explorer/api', () => ({ + web3: { eth: { getBlock: jest.fn().mockResolvedValue({ timestamp: '1715000000' }) } }, +})) + +const mockedGetTrades = jest.mocked(getTrades) + +type Policy = NonNullable[number]['policy'] +const VOLUME_POLICY: Policy = { volume: { factor: 0.002 } } +const PRICE_IMPROVEMENT_POLICY: Policy = { + priceImprovement: { factor: 0.5, maxVolumeFactor: 0.01, quote: { sellAmount: '1', buyAmount: '1', fee: '0' } }, +} + +// Each fill charges the same three policies; the zero-amount one must be dropped. +function fill(index: number): RawTrade { + return { + txHash: `0xfill${index}`, + logIndex: index, + executedProtocolFees: [ + { amount: '10000000000000000', token: WETH.address, policy: VOLUME_POLICY }, + { amount: '400000', token: USDT.address, policy: PRICE_IMPROVEMENT_POLICY }, + { amount: '0', token: USDT.address, policy: VOLUME_POLICY }, + ], + } as RawTrade +} + +// The real chain the app uses: derive the fees from every trade, attach them to the order, render. +function Harness({ order }: { order: Order }): ReactNode { + const { protocolFees } = useOrderTrades(order) + return +} + +// Fees are cached by order, so tests need a fresh cache. +function renderHarness(order: Order): ReturnType { + return render( + new Map() }}> + + , + ) +} + +describe('costs & fees breakdown (integration)', () => { + beforeEach(() => mockedGetTrades.mockReset()) + + it('derives and renders the breakdown from an order’s trades, end to end', async () => { + const fills = [fill(0), fill(1), fill(2)] + mockedGetTrades.mockImplementation(async ({ offset = 0 }) => fills.slice(offset)) + + const order = { ...RICH_ORDER, gasCost: new BigNumber('2500000000000000') } // 0.0025 native + const { container } = renderHarness(order) + + await waitFor(() => expect(screen.queryByText('[+] Show more')).not.toBeNull()) + + // One call covers the order, and the fees below aggregate all three of its fills. + expect(mockedGetTrades).toHaveBeenCalledTimes(1) + expect(mockedGetTrades).toHaveBeenCalledWith(expect.objectContaining({ orderId: order.uid, offset: 0 })) + + // Each token keeps its own total; the native gas cost is not folded into the WETH fee. + const headline = container.textContent || '' + expect(headline).toContain('ETH') + expect(headline).toContain('WETH') + expect(headline).toContain('USDT') + + fireEvent.click(screen.getByText('[+] Show more')) + expect(screen.getByText('Network costs:')).not.toBeNull() + expect(screen.getByText('Volume fee:')).not.toBeNull() + expect(screen.getByText('Price improvement fee:')).not.toBeNull() + // The zero-amount fee (position 2) is dropped, so the volume fee is not numbered. + expect(screen.queryByText(/Volume fee \(/)).toBeNull() + }) +}) diff --git a/apps/explorer/src/test/components/costsAndFeesFeatureFlag.test.tsx b/apps/explorer/src/test/components/costsAndFeesFeatureFlag.test.tsx new file mode 100644 index 00000000000..c44d988eace --- /dev/null +++ b/apps/explorer/src/test/components/costsAndFeesFeatureFlag.test.tsx @@ -0,0 +1,105 @@ +import { ReactNode } from 'react' + +import { render, screen } from '@testing-library/react' +import BigNumber from 'bignumber.js' + +import { CostAndFeesItem } from '../../components/orders/DetailsTable/items/CostAndFeesItem' +import { RICH_ORDER } from '../data' + +jest.mock('launchdarkly-react-client-sdk', () => ({ + useFlags: jest.fn(), +})) + +jest.mock('state/network', () => ({ + useNetworkId: jest.fn(() => 1), +})) + +jest.mock('hooks/useErc20', () => ({ + useMultipleErc20: jest.fn(() => ({ isLoading: false, error: {}, value: {} })), +})) + +// Stand-in so the assertions are about what the flag decides, not the tooltip machinery. +jest.mock('../../components/common/DetailRow', () => ({ + DetailRow: ({ + label, + tooltipText, + stack, + children, + }: { + label: string + tooltipText?: ReactNode + stack?: boolean + children: ReactNode + }): ReactNode => ( +
+ {label} + {tooltipText} + {String(Boolean(stack))} + {children} +
+ ), +})) + +const { useFlags } = jest.requireMock('launchdarkly-react-client-sdk') as { useFlags: jest.Mock } + +// Has everything the breakdown needs, so the flag is the only thing deciding what renders. +const ORDER_WITH_BREAKDOWN = { + ...RICH_ORDER, + gasCost: new BigNumber('2500000000000000'), + protocolFees: [], +} + +describe('costs & fees feature flag', () => { + beforeEach(() => useFlags.mockReset()) + + it('renders the pre-feature row when the flag is off, even for an order that could show a breakdown', () => { + useFlags.mockReturnValue({}) + + render() + + expect(screen.getByText('Costs & Fees')).not.toBeNull() + expect(screen.getByTestId('stack').textContent).toBe('false') + expect(screen.getByTestId('tooltip').textContent).toContain('The amount of fees paid for this order') + expect(screen.queryByText('Network costs:')).toBeNull() + expect(screen.queryByText('[+] Show more')).toBeNull() + }) + + it('renders the breakdown row when the flag is on', () => { + useFlags.mockReturnValue({ isExplorerFeeDisplayEnabled: true }) + + render() + + expect(screen.getByText('Costs and fees')).not.toBeNull() + expect(screen.getByTestId('stack').textContent).toBe('true') + expect(screen.getByTestId('tooltip').textContent).toContain('totaled per token') + }) + + it('falls back to the pre-feature row when the flag is on but the fees are unknown', () => { + useFlags.mockReturnValue({ isExplorerFeeDisplayEnabled: true }) + + render() + + expect(screen.getByText('Costs & Fees')).not.toBeNull() + expect(screen.getByTestId('stack').textContent).toBe('false') + expect(screen.queryByText('Network costs:')).toBeNull() + expect(screen.queryByText('[+] Show more')).toBeNull() + }) + + it('falls back to the pre-feature row when the flag is on but no gas cost was recorded', () => { + useFlags.mockReturnValue({ isExplorerFeeDisplayEnabled: true }) + + render() + + expect(screen.getByText('Costs & Fees')).not.toBeNull() + expect(screen.getByTestId('stack').textContent).toBe('false') + }) + + it('falls back to the pre-feature row when the flag is on but the gas cost is zero', () => { + useFlags.mockReturnValue({ isExplorerFeeDisplayEnabled: true }) + + render() + + expect(screen.getByText('Costs & Fees')).not.toBeNull() + expect(screen.getByTestId('stack').textContent).toBe('false') + }) +}) diff --git a/apps/explorer/src/test/components/costsAndFeesLineItems.test.ts b/apps/explorer/src/test/components/costsAndFeesLineItems.test.ts new file mode 100644 index 00000000000..85af8254932 --- /dev/null +++ b/apps/explorer/src/test/components/costsAndFeesLineItems.test.ts @@ -0,0 +1,99 @@ +import { getAddressKey } from '@cowprotocol/cow-sdk' + +import { TokenErc20 } from '@gnosis.pm/dex-js' +import BigNumber from 'bignumber.js' + +import { ProtocolFee, ProtocolFeeType } from 'api/operator' + +import { buildLineItems, indexTokensByKey, sumByToken } from '../../components/orders/GasFeeDisplay/breakdown' +import { TUSD, USDT, WETH } from '../data' + +const NATIVE_KEY = getAddressKey('0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee') +const GAS_COST = new BigNumber('2500000000000000') + +function fee(type: ProtocolFeeType, token: string, amount: string, position = 0): ProtocolFee { + return { type, tokenAddress: getAddressKey(token), amount: new BigNumber(amount), position } +} + +describe('buildLineItems', () => { + it('puts the network costs first, in the native token', () => { + const items = buildLineItems([fee(ProtocolFeeType.Volume, USDT.address, '400000')], GAS_COST, NATIVE_KEY) + + expect(items[0]).toEqual({ label: 'Network costs', tokenAddress: NATIVE_KEY, amount: GAS_COST }) + }) + + it('keeps the fees in the order they were applied', () => { + const items = buildLineItems( + [fee(ProtocolFeeType.Surplus, WETH.address, '1'), fee(ProtocolFeeType.PriceImprovement, USDT.address, '2', 1)], + GAS_COST, + NATIVE_KEY, + ) + + expect(items.map((item) => item.label)).toEqual(['Network costs', 'Surplus fee', 'Price improvement fee']) + }) + + it('numbers repeated fee types so the rows stay distinguishable', () => { + const items = buildLineItems( + [ + fee(ProtocolFeeType.Volume, WETH.address, '1'), + fee(ProtocolFeeType.Surplus, WETH.address, '2', 1), + fee(ProtocolFeeType.Volume, USDT.address, '3', 2), + ], + GAS_COST, + NATIVE_KEY, + ) + + // Only the repeated type is numbered; the single surplus fee keeps its plain label. + expect(items.map((item) => item.label)).toEqual([ + 'Network costs', + 'Volume fee (1)', + 'Surplus fee', + 'Volume fee (2)', + ]) + }) + + it('labels a fee of an unrecognised policy generically', () => { + const items = buildLineItems([fee(ProtocolFeeType.Unknown, USDT.address, '1')], GAS_COST, NATIVE_KEY) + + expect(items[1].label).toBe('Fee') + }) +}) + +describe('sumByToken', () => { + it('adds up the amounts of each token, keeping first-seen order', () => { + const items = buildLineItems( + [fee(ProtocolFeeType.Volume, USDT.address, '400000'), fee(ProtocolFeeType.Surplus, USDT.address, '600000', 1)], + GAS_COST, + NATIVE_KEY, + ) + + expect(sumByToken(items)).toEqual([ + [NATIVE_KEY, GAS_COST], + [getAddressKey(USDT.address), new BigNumber('1000000')], + ]) + }) + + it('keeps wrapped native separate from native', () => { + const items = buildLineItems([fee(ProtocolFeeType.Volume, WETH.address, '10')], GAS_COST, NATIVE_KEY) + + expect(sumByToken(items)).toHaveLength(2) + }) +}) + +describe('indexTokensByKey', () => { + it('indexes by address key and skips the tokens that failed to load', () => { + const map = indexTokensByKey([WETH as TokenErc20, undefined, null, USDT as TokenErc20]) + + expect(map.get(getAddressKey(WETH.address))).toBe(WETH) + expect(map.get(getAddressKey(USDT.address))).toBe(USDT) + expect(map.get(getAddressKey(TUSD.address))).toBeUndefined() + expect(map.size).toBe(2) + }) + + it('lets a later entry win, so order metadata overrides a partial fee-token lookup', () => { + const partial = { ...WETH, symbol: undefined } as unknown as TokenErc20 + const map = indexTokensByKey([partial, WETH as TokenErc20]) + + expect(map.get(getAddressKey(WETH.address))).toBe(WETH) + }) +}) diff --git a/apps/explorer/src/test/data/operator.ts b/apps/explorer/src/test/data/operator.ts index 3aa79043b93..07c0f9ddfe7 100644 --- a/apps/explorer/src/test/data/operator.ts +++ b/apps/explorer/src/test/data/operator.ts @@ -4,7 +4,9 @@ import BigNumber from 'bignumber.js' import { USDT, WETH } from './erc20s' -import { Order, RawOrder, RawTrade, OrderStatus as OrderStatusInner } from '../../api/operator' +// Not from `api/operator`: its index requires `operatorMock`, which requires this file — under jest +// that cycle evaluates this module before the index has re-exported the enums. +import { Order, OrderStatus as OrderStatusInner, RawOrder, RawTrade } from '../../api/operator/types' import { ZERO_BIG_NUMBER } from '../../const' export const RAW_ORDER = { @@ -50,6 +52,8 @@ export const RICH_ORDER: Order = { executedFeeAmount: new BigNumber(RAW_ORDER.executedFeeAmount), executedFee: ZERO_BIG_NUMBER, totalFee: ZERO_BIG_NUMBER, + // Overrides the raw `string | null` carried over from the RAW_ORDER spread. + gasCost: undefined, cancelled: RAW_ORDER.invalidated, status: OrderStatusInner.Open, partiallyFilled: false, diff --git a/apps/explorer/src/test/hooks/useOperatorTrades.test.tsx b/apps/explorer/src/test/hooks/useOperatorTrades.test.tsx index 2c24bc377c8..ca3d29f9175 100644 --- a/apps/explorer/src/test/hooks/useOperatorTrades.test.tsx +++ b/apps/explorer/src/test/hooks/useOperatorTrades.test.tsx @@ -1,11 +1,14 @@ +import { ReactNode } from 'react' + import { renderHook, waitFor } from '@testing-library/react' import BigNumber from 'bignumber.js' import { useNetworkId } from 'state/network' +import { SWRConfig } from 'swr' import { transformTrade } from 'utils' import { getTrades, Order, RawTrade, Trade } from 'api/operator' -import { useOrderTrades } from '../../hooks/useOperatorTrades' +import { ALL_TRADES_PAGE_SIZE, useOrderTrades } from '../../hooks/useOperatorTrades' jest.mock('state/network', () => ({ useNetworkId: jest.fn(), @@ -15,8 +18,10 @@ jest.mock('api/operator', () => ({ getTrades: jest.fn(), })) +// getProtocolFees has its own unit test; a pass-through keeps these assertions about the paging. jest.mock('utils', () => ({ transformTrade: jest.fn(), + getProtocolFees: jest.fn((trades) => trades), })) jest.mock('../../explorer/api', () => ({ @@ -31,105 +36,78 @@ const mockedUseNetworkId = jest.mocked(useNetworkId) const mockedGetTrades = jest.mocked(getTrades) const mockedTransformTrade = jest.mocked(transformTrade) -const ZERO = new BigNumber(0) const ONE = new BigNumber(1) const TWO = new BigNumber(2) +// Trades are deduped by where they settled, so distinct fills need distinct txHash/logIndex. +function createFill(index: number): RawTrade { + return { txHash: `0xfill${index}`, blockNumber: 42, logIndex: index } as RawTrade +} + +function createFullPage(): RawTrade[] { + return Array.from({ length: ALL_TRADES_PAGE_SIZE }, (_, index) => createFill(index)) +} + +// Only the fields the hook reads: its SWR key, and the tokens it attaches to each trade. function createMockOrder(overrides: Partial = {}): Order { return { uid: '0xorder', - owner: '0x1234', - receiver: '0x5678', - kind: 'sell', - partiallyFillable: false, - signature: '0x', - class: 'limit', - appData: '0x', - fullAppData: null, - executedFeeToken: null, - creationDate: new Date(), - expirationDate: new Date(), - buyTokenAddress: '0xbuy', buyToken: null, - sellTokenAddress: '0xsell', sellToken: null, - buyAmount: ONE, - sellAmount: ONE, executedBuyAmount: ONE, executedSellAmount: ONE, - feeAmount: ZERO, - executedFeeAmount: ZERO, - executedFee: null, - totalFee: ZERO, - cancelled: false, - status: 'filled', - partiallyFilled: false, - fullyFilled: true, - filledAmount: ONE, - filledPercentage: new BigNumber(100), - surplusAmount: ZERO, - surplusPercentage: ZERO, ...overrides, } as Order } -function createRawTrade(overrides: Partial = {}): RawTrade { - return { - txHash: '0xtrade', - blockNumber: 42, - ...overrides, - } as RawTrade +// Trades are cached by order, so without a fresh cache one test's trades satisfy another's key. +function FreshSwrCache({ children }: { children: ReactNode }): ReactNode { + return new Map() }}>{children} } -function createTransformedTrade(overrides: Partial = {}): Trade { - return { - txHash: '0xtrade', - blockNumber: 42, - logIndex: 0, - owner: '0x1234', - orderId: '0xorder', - buyAmount: ONE, - sellAmount: ONE, - sellAmountBeforeFees: ONE, - buyTokenAddress: '0xbuy', - sellTokenAddress: '0xsell', - executionTime: new Date('2024-01-01T00:00:00Z'), - ...overrides, - } as Trade +// Serves `fills` as a single short page, which ends the paging. +function serveFills(fills: RawTrade[]): void { + mockedGetTrades.mockImplementation(async ({ offset = 0 }) => fills.slice(offset)) } -describe('useOrderTrades', () => { - beforeEach(() => { - mockedUseNetworkId.mockReset() - mockedGetTrades.mockReset() - mockedTransformTrade.mockReset() +beforeEach(() => { + mockedUseNetworkId.mockReset() + mockedGetTrades.mockReset() + mockedTransformTrade.mockReset() - mockedUseNetworkId.mockReturnValue(1) - mockedTransformTrade.mockImplementation(() => createTransformedTrade()) - }) + mockedUseNetworkId.mockReturnValue(1) + mockedTransformTrade.mockImplementation( + (trade) => ({ txHash: trade.txHash, executionTime: new Date('2024-01-01T00:00:00Z') }) as Trade, + ) +}) - it('surfaces error and returns no trades when getTrades fails', async () => { - mockedGetTrades.mockRejectedValueOnce(new Error('barn/prod unavailable')) - const order = createMockOrder() +describe('useOrderTrades fills page', () => { + it('surfaces the error and reports nothing when getTrades fails', async () => { + mockedGetTrades.mockRejectedValue(new Error('barn/prod unavailable')) - const { result } = renderHook(() => useOrderTrades(order, 0, 10)) + const { result } = renderHook(() => useOrderTrades(createMockOrder(), 0, 10), { wrapper: FreshSwrCache }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.error?.message).toBe('Failed to fetch trades') expect(result.current.trades).toEqual([]) + // Undefined, not [] — the caller must not read this as "the order charged no fees". + expect(result.current.protocolFees).toBeUndefined() }) it('clears error and returns trades after a successful refetch', async () => { - mockedGetTrades.mockRejectedValueOnce(new Error('temporary outage')).mockResolvedValueOnce([createRawTrade()]) + mockedGetTrades.mockRejectedValueOnce(new Error('temporary outage')) const initialOrder = createMockOrder() const { result, rerender } = renderHook(({ order }) => useOrderTrades(order, 0, 10), { initialProps: { order: initialOrder as Order | null }, + wrapper: FreshSwrCache, }) await waitFor(() => expect(result.current.isLoading).toBe(false)) expect(result.current.error?.message).toBe('Failed to fetch trades') + serveFills([createFill(0)]) + const refreshedOrder = createMockOrder({ uid: initialOrder.uid, executedBuyAmount: TWO, @@ -143,4 +121,88 @@ describe('useOrderTrades', () => { expect(result.current.trades).toHaveLength(1) }) }) + + it('pages client-side, so walking the fills does not refetch them', async () => { + serveFills([createFill(0), createFill(1), createFill(2)]) + const order = createMockOrder() + + const { result, rerender } = renderHook(({ offset }) => useOrderTrades(order, offset, 2), { + initialProps: { offset: 0 }, + wrapper: FreshSwrCache, + }) + + await waitFor(() => expect(result.current.trades).toHaveLength(2)) + expect(result.current.hasNextPage).toBe(true) + const callsAfterFirstPage = mockedGetTrades.mock.calls.length + + rerender({ offset: 2 }) + + await waitFor(() => expect(result.current.trades).toHaveLength(1)) + expect(result.current.hasNextPage).toBe(false) + expect(result.current.trades[0].txHash).toBe('0xfill2') + expect(mockedGetTrades).toHaveBeenCalledTimes(callsAfterFirstPage) + }) +}) + +describe('useOrderTrades protocol fees', () => { + it('does not fetch anything when given no order', () => { + const { result } = renderHook(() => useOrderTrades(null, 0, 10), { wrapper: FreshSwrCache }) + + expect(mockedGetTrades).not.toHaveBeenCalled() + expect(result.current.protocolFees).toBeUndefined() + }) + + it('aggregates every fill, not only the ones on the current page', async () => { + serveFills([createFill(0), createFill(1), createFill(2)]) + + const { result } = renderHook(() => useOrderTrades(createMockOrder(), 0, 2), { wrapper: FreshSwrCache }) + + await waitFor(() => expect(result.current.protocolFees).toHaveLength(3)) + expect(result.current.trades).toHaveLength(2) + + // The API documents a short page as the last one, so a second call would be wasted. + expect(mockedGetTrades).toHaveBeenCalledTimes(1) + }) + + it('keeps paging while the API fills every page', async () => { + const fills = [...createFullPage(), createFill(ALL_TRADES_PAGE_SIZE)] + mockedGetTrades.mockImplementation(async ({ offset = 0 }) => fills.slice(offset, offset + ALL_TRADES_PAGE_SIZE)) + + const { result } = renderHook(() => useOrderTrades(createMockOrder(), 0, 10), { wrapper: FreshSwrCache }) + + // Stopping at the first full page would drop the last fill. + await waitFor(() => expect(result.current.protocolFees).toHaveLength(fills.length)) + expect(mockedGetTrades).toHaveBeenLastCalledWith(expect.objectContaining({ offset: ALL_TRADES_PAGE_SIZE })) + }) + + it('stops instead of double-counting when the API ignores the offset', async () => { + // Always the same full page: only the dedupe can end this, since no page is ever short. + mockedGetTrades.mockResolvedValue(createFullPage()) + + const { result } = renderHook(() => useOrderTrades(createMockOrder(), 0, 10), { wrapper: FreshSwrCache }) + + await waitFor(() => expect(result.current.protocolFees).toHaveLength(ALL_TRADES_PAGE_SIZE)) + expect(mockedGetTrades).toHaveBeenCalledTimes(2) + }) + + it('does not report one order’s fees while another order is loading', async () => { + serveFills([createFill(0), createFill(1)]) + + const { result, rerender } = renderHook(({ order }) => useOrderTrades(order, 0, 10), { + initialProps: { order: createMockOrder({ uid: '0xfirst' }) as Order | null }, + wrapper: FreshSwrCache, + }) + + await waitFor(() => expect(result.current.protocolFees).toHaveLength(2)) + + // Hold the second order's only page open: its fees are unknown, not the first order's. + let resolveSecond: (trades: RawTrade[]) => void = () => undefined + mockedGetTrades.mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))) + rerender({ order: createMockOrder({ uid: '0xsecond' }) }) + + expect(result.current.protocolFees).toBeUndefined() + + resolveSecond([createFill(9)]) + await waitFor(() => expect(result.current.protocolFees).toHaveLength(1)) + }) }) diff --git a/apps/explorer/src/test/utils/operator/protocolFees.test.ts b/apps/explorer/src/test/utils/operator/protocolFees.test.ts new file mode 100644 index 00000000000..7305245d5c7 --- /dev/null +++ b/apps/explorer/src/test/utils/operator/protocolFees.test.ts @@ -0,0 +1,102 @@ +import { getAddressKey } from '@cowprotocol/cow-sdk' + +import { getProtocolFees } from 'utils' + +import { ProtocolFeeType, RawTrade } from '../../../api/operator/types' +import { USDT, WETH } from '../../data' + +type ExecutedProtocolFees = NonNullable +type Policy = ExecutedProtocolFees[number]['policy'] + +const VOLUME: Policy = { volume: { factor: 0.002 } } +const SURPLUS: Policy = { surplus: { factor: 0.5, maxVolumeFactor: 0.01 } } +const PRICE_IMPROVEMENT: Policy = { + priceImprovement: { factor: 0.5, maxVolumeFactor: 0.01, quote: { sellAmount: '1', buyAmount: '1', fee: '0' } }, +} + +function fill(executedProtocolFees: ExecutedProtocolFees): Pick { + return { executedProtocolFees } +} + +describe('getProtocolFees', () => { + it('returns nothing when there are no trades or no fees', () => { + expect(getProtocolFees([])).toEqual([]) + expect(getProtocolFees([fill([])])).toEqual([]) + expect(getProtocolFees([{} as RawTrade])).toEqual([]) + }) + + it('sums a fee across the fills it was charged on', () => { + const fees = getProtocolFees([ + fill([{ amount: '100', token: USDT.address, policy: VOLUME }]), + fill([{ amount: '250', token: USDT.address, policy: VOLUME }]), + fill([{ amount: '50', token: USDT.address, policy: VOLUME }]), + ]) + + expect(fees).toHaveLength(1) + expect(fees[0].amount.toString(10)).toBe('400') + expect(fees[0].tokenAddress).toBe(getAddressKey(USDT.address)) + expect(fees[0].type).toBe(ProtocolFeeType.Volume) + }) + + it('keeps fees at different positions apart and ordered, even when fills carry different fee counts', () => { + const fees = getProtocolFees([ + fill([ + { amount: '100', token: WETH.address, policy: VOLUME }, + { amount: '7', token: USDT.address, policy: PRICE_IMPROVEMENT }, + ]), + fill([{ amount: '100', token: WETH.address, policy: VOLUME }]), + ]) + + expect(fees.map((fee) => [fee.position, fee.type, fee.amount.toString(10)])).toEqual([ + [0, ProtocolFeeType.Volume, '200'], + [1, ProtocolFeeType.PriceImprovement, '7'], + ]) + }) + + // Keying only by position would add the WETH amount to the USDT one and format it as whichever came first. + it('does not merge fees charged in different tokens at the same position', () => { + const fees = getProtocolFees([ + fill([{ amount: '1000000000000000000', token: WETH.address, policy: VOLUME }]), + fill([{ amount: '5000000', token: USDT.address, policy: VOLUME }]), + ]) + + expect(fees).toHaveLength(2) + expect(fees.map((fee) => [fee.tokenAddress, fee.amount.toString(10)])).toEqual([ + [getAddressKey(WETH.address), '1000000000000000000'], + [getAddressKey(USDT.address), '5000000'], + ]) + }) + + it('does not merge fees charged under different policies at the same position', () => { + const fees = getProtocolFees([ + fill([{ amount: '100', token: USDT.address, policy: VOLUME }]), + fill([{ amount: '400', token: USDT.address, policy: SURPLUS }]), + ]) + + expect(fees.map((fee) => [fee.type, fee.amount.toString(10)])).toEqual([ + [ProtocolFeeType.Volume, '100'], + [ProtocolFeeType.Surplus, '400'], + ]) + }) + + it('classifies a missing or unrecognised policy as unknown', () => { + const fees = getProtocolFees([fill([{ amount: '100', token: USDT.address } as ExecutedProtocolFees[number]])]) + + expect(fees[0].type).toBe(ProtocolFeeType.Unknown) + }) + + it('skips entries with no amount or no token, and policies that charged nothing', () => { + const fees = getProtocolFees([ + fill([ + { amount: '0', token: USDT.address, policy: VOLUME }, + { amount: '', token: USDT.address, policy: SURPLUS }, + { amount: '100', token: '', policy: SURPLUS }, + { amount: '5', token: WETH.address, policy: SURPLUS }, + ]), + ]) + + expect(fees).toHaveLength(1) + expect(fees[0].amount.toString(10)).toBe('5') + expect(fees[0].tokenAddress).toBe(getAddressKey(WETH.address)) + }) +}) diff --git a/apps/explorer/src/utils/operator.ts b/apps/explorer/src/utils/operator.ts index 8388d43df44..7581fc08b7e 100644 --- a/apps/explorer/src/utils/operator.ts +++ b/apps/explorer/src/utils/operator.ts @@ -1,12 +1,21 @@ import { isSellOrder } from '@cowprotocol/common-utils' -import { Trade as TradeMetaData } from '@cowprotocol/cow-sdk' +import { FeePolicy, getAddressKey, Trade as TradeMetaData } from '@cowprotocol/cow-sdk' import { calculatePrice, invertPrice, TokenErc20 } from '@gnosis.pm/dex-js' import BigNumber from 'bignumber.js' import { ZERO_BIG_NUMBER } from 'const' import { formatSmartMaxPrecision, formattingAmountPrecision } from 'utils' -import { Order, OrderStatus, RAW_ORDER_STATUS, RawOrder, Trade } from 'api/operator/types' +import { + Order, + OrderStatus, + ProtocolFee, + ProtocolFeeType, + RAW_ORDER_STATUS, + RawOrder, + RawTrade, + Trade, +} from 'api/operator/types' import { getOrderBridgeProviderId } from './getOrderBridgeProviderId' @@ -346,6 +355,38 @@ export function getOrderSurplus(order: RawOrder): Surplus { } } +/** + * Aggregates the fees charged across an order's fills into one total per (position, type, token). + * Position alone is not a safe key: across fills it can carry a different token or policy, and + * summing those would mix tokens. + */ +export function getProtocolFees(trades: Array>): ProtocolFee[] { + const feesByPolicy = new Map() + + for (const { executedProtocolFees } of trades) { + if (!executedProtocolFees) continue + + executedProtocolFees.forEach(({ amount, token, policy }, position) => { + if (!amount || !token) return + + const type = getProtocolFeeType(policy) + const tokenAddress = getAddressKey(token) + const key = `${position}-${type}-${tokenAddress}` + + const existing = feesByPolicy.get(key) + if (existing) { + existing.amount = existing.amount.plus(amount) + } else { + feesByPolicy.set(key, { amount: new BigNumber(amount), tokenAddress, type, position }) + } + }) + } + + return Array.from(feesByPolicy.values()) + .sort((a, b) => a.position - b.position) + .filter((fee) => fee.amount.isGreaterThan(0)) +} + export function getTradeSurplus(rawTrade: TradeMetaData, order: Order): Surplus { const params: PartialFillSurplusParams = { sellAmount: order.sellAmount, @@ -380,6 +421,7 @@ export function transformOrder(rawOrder: RawOrder): Order { executedFeeAmount, executedFee, totalFee, + gasCost, invalidated, ...rest } = rawOrder @@ -407,6 +449,7 @@ export function transformOrder(rawOrder: RawOrder): Order { executedFeeAmount: new BigNumber(executedFeeAmount), executedFee: executedFee ? new BigNumber(executedFee) : null, totalFee: new BigNumber(totalFee), + gasCost: gasCost ? new BigNumber(gasCost) : undefined, cancelled: invalidated, status, partiallyFilled, @@ -441,6 +484,15 @@ export function transformTrade(rawTrade: TradeMetaData, order: Order, executionT } } +function getProtocolFeeType(policy: FeePolicy | undefined): ProtocolFeeType { + if (policy) { + if ('surplus' in policy) return ProtocolFeeType.Surplus + if ('volume' in policy) return ProtocolFeeType.Volume + if ('priceImprovement' in policy) return ProtocolFeeType.PriceImprovement + } + return ProtocolFeeType.Unknown +} + function getReceiverAddress({ owner, receiver }: RawOrder): string { return !receiver || isZeroAddress(receiver) ? owner : receiver }