diff --git a/package.json b/package.json index 64889f6926..1d91b2450e 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "@safe-global/safe-core-sdk": "^3.3.5", "@safe-global/safe-core-sdk-utils": "^1.7.4", "@safe-global/safe-ethers-lib": "^1.9.4", - "@safe-global/safe-gateway-typescript-sdk": "3.21.0-alpha.3", + "@safe-global/safe-gateway-typescript-sdk": "3.21.1", "@safe-global/safe-modules-deployments": "^1.2.0", "@sentry/react": "^7.91.0", "@spindl-xyz/attribution-lite": "^1.4.0", diff --git a/public/images/common/safe-swap-dark.svg b/public/images/common/safe-swap-dark.svg new file mode 100644 index 0000000000..29e5b284df --- /dev/null +++ b/public/images/common/safe-swap-dark.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/common/safe-swap.svg b/public/images/common/safe-swap.svg new file mode 100644 index 0000000000..9aebc9f8f9 --- /dev/null +++ b/public/images/common/safe-swap.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/components/common/NamedAddressInfo/index.test.tsx b/src/components/common/NamedAddressInfo/index.test.tsx new file mode 100644 index 0000000000..9581303651 --- /dev/null +++ b/src/components/common/NamedAddressInfo/index.test.tsx @@ -0,0 +1,67 @@ +import { render, waitFor } from '@/tests/test-utils' +import NamedAddressInfo from '.' +import { faker } from '@faker-js/faker' +import { getContract, type ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' + +const mockChainInfo = { + chainId: '4', + shortName: 'tst', + blockExplorerUriTemplate: { + address: 'https://test.scan.eth/{address}', + api: 'https://test.scan.eth/', + txHash: 'https://test.scan.eth/{txHash}', + }, +} as ChainInfo + +jest.mock('@safe-global/safe-gateway-typescript-sdk', () => ({ + ...jest.requireActual('@safe-global/safe-gateway-typescript-sdk'), + getContract: jest.fn(), + __esModule: true, +})) + +describe('NamedAddressInfo', () => { + const getContractMock = getContract as jest.Mock + it('should not fetch contract info if name / logo is given', async () => { + const result = render( + , + { + initialReduxState: { + chains: { + loading: false, + data: [mockChainInfo], + }, + }, + }, + ) + + expect(result.getByText('TestAddressName')).toBeVisible() + expect(getContractMock).not.toHaveBeenCalled() + }) + + it('should fetch contract info if name / logo is not given', async () => { + const address = faker.finance.ethereumAddress() + getContractMock.mockResolvedValue({ + displayName: 'Resolved Test Name', + name: 'ResolvedTestName', + logoUri: 'https://img-resolved.test.safe.global', + }) + const result = render(, { + initialReduxState: { + chains: { + loading: false, + data: [mockChainInfo], + }, + }, + }) + + await waitFor(() => { + expect(result.getByText('Resolved Test Name')).toBeVisible() + }) + + expect(getContractMock).toHaveBeenCalledWith('4', address) + }) +}) diff --git a/src/components/common/NamedAddressInfo/index.tsx b/src/components/common/NamedAddressInfo/index.tsx new file mode 100644 index 0000000000..7ebd1852e7 --- /dev/null +++ b/src/components/common/NamedAddressInfo/index.tsx @@ -0,0 +1,24 @@ +import useAsync from '@/hooks/useAsync' +import useChainId from '@/hooks/useChainId' +import { getContract } from '@safe-global/safe-gateway-typescript-sdk' +import EthHashInfo from '../EthHashInfo' +import type { EthHashInfoProps } from '../EthHashInfo/SrcEthHashInfo' + +const NamedAddressInfo = ({ address, name, customAvatar, ...props }: EthHashInfoProps) => { + const chainId = useChainId() + const [contract] = useAsync( + () => (!name && !customAvatar ? getContract(chainId, address) : undefined), + [address, chainId, name, customAvatar], + ) + + return ( + + ) +} + +export default NamedAddressInfo diff --git a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx index d04b43ca1f..3aa6ea378c 100644 --- a/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx +++ b/src/components/transactions/TxDetails/TxData/DecodedData/SingleTxDecoded/index.tsx @@ -13,7 +13,7 @@ import accordionCss from '@/styles/accordion.module.css' import CodeIcon from '@mui/icons-material/Code' import { DelegateCallWarning } from '@/components/transactions/Warning' import { InfoDetails } from '@/components/transactions/InfoDetails' -import EthHashInfo from '@/components/common/EthHashInfo' +import NamedAddressInfo from '@/components/common/NamedAddressInfo' type SingleTxDecodedProps = { tx: InternalTransaction @@ -74,7 +74,7 @@ export const SingleTxDecoded = ({ {isDelegateCall && } {!isSpendingLimitMethod && ( - { return ( <> - )} - {expiredSwap && ( + {isQueue && expiredSwap && ( This order has expired. Reject this transaction and try again. diff --git a/src/components/transactions/TxSummary/index.tsx b/src/components/transactions/TxSummary/index.tsx index 5f16e017da..4784382c8a 100644 --- a/src/components/transactions/TxSummary/index.tsx +++ b/src/components/transactions/TxSummary/index.tsx @@ -84,7 +84,7 @@ const TxSummary = ({ item, isGrouped }: TxSummaryProps): ReactElement => { )} - {expiredSwap && ( + {isQueue && expiredSwap && ( diff --git a/src/components/tx-flow/flows/ConfirmTx/index.tsx b/src/components/tx-flow/flows/ConfirmTx/index.tsx index 5df1140521..00cbf2cfe2 100644 --- a/src/components/tx-flow/flows/ConfirmTx/index.tsx +++ b/src/components/tx-flow/flows/ConfirmTx/index.tsx @@ -1,11 +1,14 @@ +import { isSwapTxInfo } from '@/utils/transaction-guards' import type { TransactionSummary } from '@safe-global/safe-gateway-typescript-sdk' import TxLayout from '@/components/tx-flow/common/TxLayout' import ConfirmProposedTx from './ConfirmProposedTx' import { useTransactionType } from '@/hooks/useTransactionType' import TxInfo from '@/components/transactions/TxInfo' +import SwapIcon from '@/public/images/common/swap.svg' const ConfirmTxFlow = ({ txSummary }: { txSummary: TransactionSummary }) => { const { text } = useTransactionType(txSummary) + const isSwapOrder = isSwapTxInfo(txSummary.txInfo) return ( { subtitle={ <> {text}  - + {!isSwapOrder && } } + icon={isSwapOrder && SwapIcon} step={0} txSummary={txSummary} > diff --git a/src/components/tx-flow/flows/SignMessage/index.tsx b/src/components/tx-flow/flows/SignMessage/index.tsx index b19c85d950..6c54f8e290 100644 --- a/src/components/tx-flow/flows/SignMessage/index.tsx +++ b/src/components/tx-flow/flows/SignMessage/index.tsx @@ -1,5 +1,8 @@ import TxLayout from '@/components/tx-flow/common/TxLayout' import SignMessage, { type ConfirmProps, type ProposeProps } from '@/components/tx-flow/flows/SignMessage/SignMessage' +import { getSwapTitle, SWAP_TITLE } from '@/features/swap' +import { selectSwapParams } from '@/features/swap/store/swapParamsSlice' +import { useAppSelector } from '@/store' import { Box, Typography } from '@mui/material' import SafeAppIconCard from '@/components/safe-apps/SafeAppIconCard' @@ -7,13 +10,18 @@ const APP_LOGO_FALLBACK_IMAGE = '/images/apps/apps-icon.svg' const APP_NAME_FALLBACK = 'Sign message' export const AppTitle = ({ name, logoUri }: { name?: string | null; logoUri?: string | null }) => { + const swapParams = useAppSelector(selectSwapParams) + const appName = name || APP_NAME_FALLBACK const appLogo = logoUri || APP_LOGO_FALLBACK_IMAGE + + const title = name === SWAP_TITLE ? getSwapTitle(swapParams.tradeType) : appName + return ( - - - {appName} + + + {title} ) diff --git a/src/features/swap/components/SwapOrderConfirmationView/index.tsx b/src/features/swap/components/SwapOrderConfirmationView/index.tsx index ba42fd120c..43d4702e83 100644 --- a/src/features/swap/components/SwapOrderConfirmationView/index.tsx +++ b/src/features/swap/components/SwapOrderConfirmationView/index.tsx @@ -18,8 +18,8 @@ import type { CowSwapConfirmationView } from '@safe-global/safe-gateway-typescri import SwapTokens from '@/features/swap/components/SwapTokens' import AlertIcon from '@/public/images/common/alert.svg' import EthHashInfo from '@/components/common/EthHashInfo' -import CowLogo from '@/public/images/swaps/cow-logo.png' import css from './styles.module.css' +import NamedAddress from '@/components/common/NamedAddressInfo' type SwapOrderProps = { order: CowSwapConfirmationView @@ -109,15 +109,7 @@ export const SwapOrderConfirmationView = ({ order, settlementContract }: SwapOrd , - + , receiver && owner !== receiver ? ( <> diff --git a/src/features/swap/hooks/useIsExpiredSwap.ts b/src/features/swap/hooks/useIsExpiredSwap.ts index ba2f675e01..f686525d5f 100644 --- a/src/features/swap/hooks/useIsExpiredSwap.ts +++ b/src/features/swap/hooks/useIsExpiredSwap.ts @@ -1,7 +1,7 @@ import { useState } from 'react' import type { TransactionInfo } from '@safe-global/safe-gateway-typescript-sdk' import useInterval from '@/hooks/useInterval' -import { isExpiredSwap as isSwapInfoExpired, isSwapTxInfo } from '@/utils/transaction-guards' +import { isSwapTxInfo } from '@/utils/transaction-guards' const INTERVAL_IN_MS = 10_000 @@ -16,7 +16,7 @@ const useIsExpiredSwap = (txInfo: TransactionInfo) => { const isExpiredSwap = () => { if (!isSwapTxInfo(txInfo)) return - setIsExpired(Date.now() > txInfo.validUntil * 1000 && isSwapInfoExpired(txInfo)) + setIsExpired(Date.now() > txInfo.validUntil * 1000) } useInterval(isExpiredSwap, INTERVAL_IN_MS) diff --git a/src/features/swap/index.tsx b/src/features/swap/index.tsx index cbc6bc36f0..47b6880fb8 100644 --- a/src/features/swap/index.tsx +++ b/src/features/swap/index.tsx @@ -14,7 +14,6 @@ import { import { useCurrentChain, useHasFeature } from '@/hooks/useChains' import { useDarkMode } from '@/hooks/useDarkMode' import { useCustomAppCommunicator } from '@/hooks/safe-apps/useCustomAppCommunicator' -import { showNotification } from '@/store/notificationsSlice' import { useAppDispatch, useAppSelector } from '@/store' import css from './styles.module.css' @@ -24,8 +23,10 @@ import BlockedAddress from '@/components/common/BlockedAddress' import useSwapConsent from './useSwapConsent' import Disclaimer from '@/components/common/Disclaimer' import LegalDisclaimerContent from '@/components/common/LegalDisclaimerContent' -import { selectSwapParams, setSwapParams } from './store/swapParamsSlice' import { isBlockedAddress } from '@/services/ofac' +import { selectSwapParams, setSwapParams, type SwapState } from './store/swapParamsSlice' +import { setSwapOrder } from '@/store/swapOrderSlice' + const BASE_URL = typeof window !== 'undefined' && window.location.origin ? window.location.origin : '' @@ -36,18 +37,12 @@ type Params = { } } -const appData: SafeAppData = { - id: 1, - url: 'https://app.safe.global', - name: 'Safe Swap', - iconUrl: 'https://app.safe.global/icon.png', - description: 'Safe Apps', - chainIds: ['1', '100'], - accessControl: { type: SafeAppAccessPolicyTypes.NoRestrictions }, - tags: ['safe-apps'], - features: [SafeAppFeatures.BATCHED_TRANSACTIONS], - socialProfiles: [], +export const SWAP_TITLE = 'Safe Swap' + +export const getSwapTitle = (tradeType: SwapState['tradeType']) => { + return tradeType === 'limit' ? 'Limit order' : 'Swap order' } + const SwapWidget = ({ sell }: Params) => { const chainId = useChainId() const { palette } = useTheme() @@ -61,6 +56,7 @@ const SwapWidget = ({ sell }: Params) => { const wallet = useWallet() const { isConsentAccepted, onAccept } = useSwapConsent() + useEffect(() => { if (isBlockedAddress(safeAddress)) { setBlockedAddress(safeAddress) @@ -70,6 +66,23 @@ const SwapWidget = ({ sell }: Params) => { } }, [safeAddress, wallet?.address]) + const appData: SafeAppData = useMemo( + () => ({ + id: 1, + url: 'https://app.safe.global', + name: SWAP_TITLE, + iconUrl: darkMode ? './images/common/safe-swap-dark.svg' : './images/common/safe-swap.svg', + description: 'Safe Apps', + chainIds: ['1', '100'], + accessControl: { type: SafeAppAccessPolicyTypes.NoRestrictions }, + tags: ['safe-apps'], + features: [SafeAppFeatures.BATCHED_TRANSACTIONS], + socialProfiles: [], + }), + [darkMode], + ) + + const groupKey = 'swap-order-status' const listeners = useMemo(() => { return [ @@ -78,54 +91,45 @@ const SwapWidget = ({ sell }: Params) => { handler: (event) => { console.info('🍞 New toast message:', event) const { messageType } = event + switch (messageType) { case 'ORDER_CREATED': dispatch( - showNotification({ - title: 'Swap transaction created', - message: 'Waiting for confirmation from signers of your Safe', - groupKey, - variant: 'info', + setSwapOrder({ + orderUid: event.data.orderUid, + status: 'created', }), ) break case 'ORDER_PRESIGNED': dispatch( - showNotification({ - title: 'Swap transaction confirmed', - message: 'Waiting for swap execution by the CoW Protocol', - groupKey, - variant: 'info', + setSwapOrder({ + orderUid: event.data.orderUid, + status: 'open', }), ) break case 'ORDER_FULFILLED': dispatch( - showNotification({ - title: 'Swap executed', - message: 'Your swap has been successful', - groupKey, - variant: 'info', + setSwapOrder({ + orderUid: event.data.orderUid, + status: 'fulfilled', }), ) break case 'ORDER_EXPIRED': dispatch( - showNotification({ - title: 'Swap expired', - message: 'Your swap has reached the expiry time and has become invalid', - groupKey, - variant: 'warning', + setSwapOrder({ + orderUid: event.data.orderUid, + status: 'expired', }), ) break case 'ORDER_CANCELLED': dispatch( - showNotification({ - title: 'Swap cancelled', - message: 'Your swap has been cancelled', - groupKey, - variant: 'warning', + setSwapOrder({ + orderUid: event.data.orderUid, + status: 'cancelled', }), ) break diff --git a/src/features/swap/store/swapParamsSlice.ts b/src/features/swap/store/swapParamsSlice.ts index 7aff6a6022..e7a620b5a0 100644 --- a/src/features/swap/store/swapParamsSlice.ts +++ b/src/features/swap/store/swapParamsSlice.ts @@ -2,6 +2,7 @@ import type { RootState } from '@/store' import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' +// Using TradeType from the cow widget library results in lint errors enum TradeType { SWAP = 'swap', LIMIT = 'limit', @@ -19,7 +20,11 @@ export const swapParamsSlice = createSlice({ name: 'swapParams', initialState, reducers: { - setSwapParams: (_, action: PayloadAction) => action.payload, + setSwapParams: (_, action: PayloadAction) => { + return { + tradeType: action.payload.tradeType.toLowerCase() as TradeType, + } + }, }, }) diff --git a/src/hooks/useTransactionType.ts b/src/hooks/useTransactionType.ts index dc994f2ead..38fbde5c5d 100644 --- a/src/hooks/useTransactionType.ts +++ b/src/hooks/useTransactionType.ts @@ -1,3 +1,4 @@ +import { getOrderClass } from '@/features/swap/helpers/utils' import { useMemo } from 'react' import { type AddressEx, @@ -36,7 +37,6 @@ export const getTransactionType = (tx: TransactionSummary, addressBook: AddressB const toAddress = getTxTo(tx) const addressBookName = toAddress?.value ? addressBook[toAddress.value] : undefined - console.log('tx.txInfo.type:', tx.txInfo.type, TransactionInfoType.SWAP_ORDER) switch (tx.txInfo.type) { case TransactionInfoType.CREATION: { return { @@ -63,9 +63,11 @@ export const getTransactionType = (tx: TransactionSummary, addressBook: AddressB } } case TransactionInfoType.SWAP_ORDER: { + const orderClass = getOrderClass(tx.txInfo) + return { icon: '/images/transactions/cow-logo.png', - text: 'Swap order', + text: orderClass === 'limit' ? 'Limit order' : 'Swap order', } } case TransactionInfoType.CUSTOM: { diff --git a/src/store/__tests__/swapOrderSlice.test.ts b/src/store/__tests__/swapOrderSlice.test.ts new file mode 100644 index 0000000000..e88d0ead54 --- /dev/null +++ b/src/store/__tests__/swapOrderSlice.test.ts @@ -0,0 +1,533 @@ +import { listenerMiddlewareInstance } from '@/store' +import { txHistorySlice } from '@/store/txHistorySlice' +import { swapOrderListener, swapOrderStatusListener, setSwapOrder, deleteSwapOrder } from '@/store/swapOrderSlice' +import { + TransactionListItemType, + type TransactionListItem, + TransactionInfoType, +} from '@safe-global/safe-gateway-typescript-sdk' +import * as notificationsSlice from '@/store/notificationsSlice' + +describe('swapOrderSlice', () => { + describe('swapOrderListener', () => { + const listenerMiddleware = listenerMiddlewareInstance + const mockDispatch = jest.fn() + const startListeningMock = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + listenerMiddleware.startListening = startListeningMock + swapOrderListener(listenerMiddleware) + }) + + it('should not dispatch an event if the transaction is not a swapTx', () => { + const nonSwapTransaction = { + type: TransactionListItemType.TRANSACTION, + transaction: { + id: '0x123', + txInfo: { + type: TransactionInfoType.TRANSFER, + }, + }, + } as TransactionListItem + + const action = txHistorySlice.actions.set({ + loading: false, + data: { + results: [nonSwapTransaction], + }, + }) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { dispatch: mockDispatch }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should not dispatch an event if the swapOrder status did not change', () => { + const swapTransaction = { + type: TransactionListItemType.TRANSACTION, + transaction: { + id: '0x123', + txInfo: { + type: TransactionInfoType.SWAP_ORDER, + uid: 'order1', + status: 'open', + }, + }, + } as unknown as TransactionListItem + + const action = txHistorySlice.actions.set({ + loading: false, + data: { + results: [swapTransaction], + }, + }) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getOriginalState: () => ({ + swapOrders: { + order1: { + orderUid: 'order1', + status: 'open', + }, + }, + }), + }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('should dispatch setSwapOrder if the swapOrder status changed', () => { + const swapTransaction = { + type: TransactionListItemType.TRANSACTION, + transaction: { + id: '0x123', + txInfo: { + type: TransactionInfoType.SWAP_ORDER, + uid: 'order1', + status: 'fulfilled', + }, + }, + } as unknown as TransactionListItem + + const action = txHistorySlice.actions.set({ + loading: false, + data: { + results: [swapTransaction], + }, + }) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getOriginalState: () => ({ + swapOrders: { + order1: { + orderUid: 'order1', + status: 'open', + }, + }, + }), + }) + + expect(mockDispatch).toHaveBeenCalledWith( + setSwapOrder({ + orderUid: 'order1', + status: 'fulfilled', + txId: '0x123', + }), + ) + }) + + it('should not dispatch setSwapOrder if the old status is undefined and new status is fulfilled, expired, or cancelled', () => { + const swapTransaction = { + type: TransactionListItemType.TRANSACTION, + transaction: { + id: '0x123', + txInfo: { + type: TransactionInfoType.SWAP_ORDER, + uid: 'order1', + status: 'fulfilled', // Test with 'expired' and 'cancelled' as well + }, + }, + } as unknown as TransactionListItem + + const action = txHistorySlice.actions.set({ + loading: false, + data: { + results: [swapTransaction], + }, + }) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getOriginalState: () => ({ + swapOrders: {}, // Old status is undefined + }), + }) + + expect(mockDispatch).not.toHaveBeenCalled() + }) + }) + + describe('swapOrderStatusListener', () => { + const listenerMiddleware = listenerMiddlewareInstance + const mockDispatch = jest.fn() + const showNotificationSpy = jest.spyOn(notificationsSlice, 'showNotification') + const startListeningMock = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + listenerMiddleware.startListening = startListeningMock + swapOrderStatusListener(listenerMiddleware) + }) + + it('should dispatch a notification if the swapOrder status is created and threshold is 1', () => { + const swapOrder = { + orderUid: 'order1', + status: 'created' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: {}, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order created', + message: 'Waiting for the transaction to be executed', + groupKey: 'swap-order-status', + variant: 'info', + }) + }) + + it('should dispatch a notification if the swapOrder status is created and there is nothing about this swap in the state and threshold is more than 1', () => { + const swapOrder = { + orderUid: 'order1', + status: 'created' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 2, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: {}, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order created', + message: 'Waiting for confirmation from signers of your Safe', + groupKey: 'swap-order-status', + variant: 'info', + }) + }) + + it('should dispatch a notification if the swapOrder status is open and we have old status and threshold is 1', () => { + const swapOrder = { + orderUid: 'order1', + status: 'open' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: { + order1: { + orderUid: 'order1', + status: 'created', // Old status is not undefined + }, + }, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order transaction confirmed', + message: 'Waiting for order execution by the CoW Protocol', + groupKey: 'swap-order-status', + variant: 'info', + }) + }) + + it('should dispatch a notification if the swapOrder status is presignaturePending', () => { + const swapOrder = { + orderUid: 'order1', + status: 'presignaturePending' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: {}, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order waiting for signature', + message: 'Waiting for confirmation from signers of your Safe', + groupKey: 'swap-order-status', + variant: 'info', + }) + }) + + it('should dispatch a notification if the swapOrder status is open', () => { + const swapOrder = { + orderUid: 'order1', + status: 'open' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: {}, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order transaction confirmed', + message: 'Waiting for order execution by the CoW Protocol', + groupKey: 'swap-order-status', + variant: 'info', + }) + }) + + it('should not dispatch a notification if the swapOrder status is fulfilled and old status is undefined', () => { + const swapOrder = { + orderUid: 'order1', + status: 'fulfilled' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: {}, + }), + }) + + expect(showNotificationSpy).not.toHaveBeenCalled() + }) + + it('should dispatch a notification if the swapOrder status is fulfilled and old status is not undefined', () => { + const swapOrder = { + orderUid: 'order1', + status: 'fulfilled' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: { + order1: { + orderUid: 'order1', + status: 'open', + }, + }, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order executed', + message: 'Your order has been successful', + groupKey: 'swap-order-status', + variant: 'success', + }) + + expect(mockDispatch).toHaveBeenCalledWith(deleteSwapOrder('order1')) + }) + + it('should not dispatch a notification if the swapOrder status is expired and old status is undefined', () => { + const swapOrder = { + orderUid: 'order1', + status: 'expired' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: {}, + }), + }) + + expect(showNotificationSpy).not.toHaveBeenCalled() + expect(mockDispatch).toHaveBeenCalledWith(deleteSwapOrder('order1')) + }) + + it('should dispatch a notification if the swapOrder status is expired and old status is not undefined', () => { + const swapOrder = { + orderUid: 'order1', + status: 'expired' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: { + order1: { + orderUid: 'order1', + status: 'open', + }, + }, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order expired', + message: 'Your order has reached the expiry time and has become invalid', + groupKey: 'swap-order-status', + variant: 'warning', + }) + + expect(mockDispatch).toHaveBeenCalledWith(deleteSwapOrder('order1')) + }) + + it('should not dispatch a notification if the swapOrder status is cancelled and old status is undefined', () => { + const swapOrder = { + orderUid: 'order1', + status: 'cancelled' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: {}, + }), + }) + + expect(showNotificationSpy).not.toHaveBeenCalled() + expect(mockDispatch).toHaveBeenCalledWith(deleteSwapOrder('order1')) + }) + + it('should dispatch a notification if the swapOrder status is cancelled and old status is not undefined', () => { + const swapOrder = { + orderUid: 'order1', + status: 'cancelled' as const, + txId: '0x123', + } + + const action = setSwapOrder(swapOrder) + + const effect = startListeningMock.mock.calls[0][0].effect + effect(action, { + dispatch: mockDispatch, + getState: () => ({ + safeInfo: { + data: { + threshold: 1, + }, + }, + }), + getOriginalState: () => ({ + swapOrders: { + order1: { + orderUid: 'order1', + status: 'open', + }, + }, + }), + }) + + expect(showNotificationSpy).toHaveBeenCalledWith({ + title: 'Order cancelled', + message: 'Your order has been cancelled', + groupKey: 'swap-order-status', + variant: 'warning', + }) + expect(mockDispatch).toHaveBeenCalledWith(deleteSwapOrder('order1')) + }) + }) +}) diff --git a/src/store/index.ts b/src/store/index.ts index b9f8665a5c..de091020b3 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -11,7 +11,13 @@ import merge from 'lodash/merge' import { IS_PRODUCTION } from '@/config/constants' import { getPreloadedState, persistState } from './persistStore' import { broadcastState, listenToBroadcast } from './broadcast' -import { safeMessagesListener, txHistoryListener, txQueueListener } from './slices' +import { + safeMessagesListener, + swapOrderListener, + swapOrderStatusListener, + txHistoryListener, + txQueueListener, +} from './slices' import * as slices from './slices' import * as hydrate from './useHydrateStore' @@ -22,6 +28,7 @@ const rootReducer = combineReducers({ [slices.sessionSlice.name]: slices.sessionSlice.reducer, [slices.txHistorySlice.name]: slices.txHistorySlice.reducer, [slices.txQueueSlice.name]: slices.txQueueSlice.reducer, + [slices.swapOrderSlice.name]: slices.swapOrderSlice.reducer, [slices.addressBookSlice.name]: slices.addressBookSlice.reducer, [slices.notificationsSlice.name]: slices.notificationsSlice.reducer, [slices.pendingTxsSlice.name]: slices.pendingTxsSlice.reducer, @@ -50,6 +57,7 @@ const persistedSlices: (keyof PreloadedState)[] = [ slices.batchSlice.name, slices.undeployedSafesSlice.name, slices.swapParamsSlice.name, + slices.swapOrderSlice.name, ] export const getPersistedState = () => { @@ -63,7 +71,7 @@ const middleware = [ broadcastState(persistedSlices), listenerMiddlewareInstance.middleware, ] -const listeners = [safeMessagesListener, txHistoryListener, txQueueListener] +const listeners = [safeMessagesListener, txHistoryListener, txQueueListener, swapOrderListener, swapOrderStatusListener] export const _hydrationReducer: typeof rootReducer = (state, action) => { if (action.type === hydrate.HYDRATE_ACTION) { diff --git a/src/store/slices.ts b/src/store/slices.ts index 6110473145..b186894502 100644 --- a/src/store/slices.ts +++ b/src/store/slices.ts @@ -18,3 +18,4 @@ export * from './pendingSafeMessagesSlice' export * from './batchSlice' export * from '@/features/counterfactual/store/undeployedSafesSlice' export * from '@/features/swap/store/swapParamsSlice' +export * from './swapOrderSlice' diff --git a/src/store/swapOrderSlice.ts b/src/store/swapOrderSlice.ts new file mode 100644 index 0000000000..2fd4afcf2b --- /dev/null +++ b/src/store/swapOrderSlice.ts @@ -0,0 +1,206 @@ +import type { listenerMiddlewareInstance } from '@/store' +import { createSelector, createSlice } from '@reduxjs/toolkit' +import type { OrderStatuses } from '@safe-global/safe-gateway-typescript-sdk' +import type { RootState } from '@/store' +import { isSwapTxInfo, isTransactionListItem } from '@/utils/transaction-guards' +import { txHistorySlice } from '@/store/txHistorySlice' +import { showNotification } from '@/store/notificationsSlice' +import { selectSafeInfo } from '@/store/safeInfoSlice' + +type AllStatuses = OrderStatuses | 'created' +type Order = { + orderUid: string + status: AllStatuses + txId?: string +} + +type SwapOrderState = { + [orderUid: string]: Order +} + +const initialState: SwapOrderState = {} + +const slice = createSlice({ + name: 'swapOrders', + initialState, + reducers: { + setSwapOrder: (state, { payload }: { payload: Order }): SwapOrderState => { + return { + ...state, + [payload.orderUid]: { + ...state[payload.orderUid], + ...payload, + }, + } + }, + deleteSwapOrder: (state, { payload }: { payload: string }): SwapOrderState => { + const newState = { ...state } + delete newState[payload] + return newState + }, + }, +}) + +export const { setSwapOrder, deleteSwapOrder } = slice.actions +const selector = (state: RootState) => state[slice.name] +export const swapOrderSlice = slice +export const selectAllSwapOrderStatuses = selector + +export const selectSwapOrderStatus = createSelector( + [selectAllSwapOrderStatuses, (_, uid: string) => uid], + (allOrders, uid): undefined | AllStatuses => { + return allOrders ? allOrders[uid]?.status : undefined + }, +) + +const groupKey = 'swap-order-status' +/** + * Listen for changes in the swap order status and determines if a notification should be shown + * + * Some gotchas: + * If the status of an order is created, presignaturePending, open - we always display a notification. + * Here it doesn't matter if the order was started through the UI or the gateway returned that order on a new browser instance. + * + * For fulfilled, expired, cancelled - we only display a notification if the old status is not undefined. + * Why? Because if the status is undefined, it means that the order was just fetched from the gateway, and + * it was already processed and there is no need to show a notification. If the status is != undefined, it means + * that the user has started the swap through the UI (or has continued it from a previous state), and we should show a notification. + * + * @param listenerMiddleware + */ +export const swapOrderStatusListener = (listenerMiddleware: typeof listenerMiddlewareInstance) => { + listenerMiddleware.startListening({ + actionCreator: slice.actions.setSwapOrder, + effect: (action, listenerApi) => { + const { dispatch } = listenerApi + const swapOrder = action.payload + const oldStatus = selectSwapOrderStatus(listenerApi.getOriginalState(), swapOrder.orderUid) + const newStatus = swapOrder.status + + if (oldStatus === newStatus || newStatus === undefined) { + return + } + + switch (newStatus) { + case 'created': + const safeInfo = selectSafeInfo(listenerApi.getState()) + + dispatch( + showNotification({ + title: 'Order created', + message: + safeInfo.data?.threshold === 1 + ? 'Waiting for the transaction to be executed' + : 'Waiting for confirmation from signers of your Safe', + groupKey, + variant: 'info', + }), + ) + + break + case 'presignaturePending': + dispatch( + showNotification({ + title: 'Order waiting for signature', + message: 'Waiting for confirmation from signers of your Safe', + groupKey, + variant: 'info', + }), + ) + break + case 'open': + dispatch( + showNotification({ + title: 'Order transaction confirmed', + message: 'Waiting for order execution by the CoW Protocol', + groupKey, + variant: 'info', + }), + ) + break + case 'fulfilled': + dispatch(slice.actions.deleteSwapOrder(swapOrder.orderUid)) + if (oldStatus === undefined) { + return + } + dispatch( + showNotification({ + title: 'Order executed', + message: 'Your order has been successful', + groupKey, + variant: 'success', + }), + ) + break + case 'expired': + dispatch(slice.actions.deleteSwapOrder(swapOrder.orderUid)) + if (oldStatus === undefined) { + return + } + dispatch( + showNotification({ + title: 'Order expired', + message: 'Your order has reached the expiry time and has become invalid', + groupKey, + variant: 'warning', + }), + ) + break + case 'cancelled': + dispatch(slice.actions.deleteSwapOrder(swapOrder.orderUid)) + if (oldStatus === undefined) { + return + } + dispatch( + showNotification({ + title: 'Order cancelled', + message: 'Your order has been cancelled', + groupKey, + variant: 'warning', + }), + ) + break + } + }, + }) +} + +/** + * Listen for changes in the tx history, check if the transaction is a swap order and update the status of the order + * @param listenerMiddleware + */ +export const swapOrderListener = (listenerMiddleware: typeof listenerMiddlewareInstance) => { + listenerMiddleware.startListening({ + actionCreator: txHistorySlice.actions.set, + effect: (action, listenerApi) => { + if (!action.payload.data) { + return + } + + for (const result of action.payload.data.results) { + if (!isTransactionListItem(result)) { + continue + } + + if (isSwapTxInfo(result.transaction.txInfo)) { + const swapOrder = result.transaction.txInfo + const oldStatus = selectSwapOrderStatus(listenerApi.getOriginalState(), swapOrder.uid) + + const finalStatuses: AllStatuses[] = ['fulfilled', 'expired', 'cancelled'] + if (oldStatus === swapOrder.status || (oldStatus === undefined && finalStatuses.includes(swapOrder.status))) { + continue + } + + listenerApi.dispatch({ + type: slice.actions.setSwapOrder.type, + payload: { + orderUid: swapOrder.uid, + status: swapOrder.status, + txId: result.transaction.id, + }, + }) + } + } + }, + }) +} diff --git a/src/utils/transaction-guards.ts b/src/utils/transaction-guards.ts index 725327db4e..1751662a49 100644 --- a/src/utils/transaction-guards.ts +++ b/src/utils/transaction-guards.ts @@ -110,10 +110,6 @@ export const isSwapConfirmationViewOrder = ( return false } -export const isExpiredSwap = (value: TransactionInfo) => { - return isSwapTxInfo(value) && value.status === 'expired' -} - export const isCancelledSwap = (value: TransactionInfo) => { return isSwapTxInfo(value) && value.status === 'cancelled' } diff --git a/yarn.lock b/yarn.lock index 985b249033..9cd3a71f0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4336,10 +4336,10 @@ "@safe-global/safe-core-sdk-utils" "^1.7.4" ethers "5.7.2" -"@safe-global/safe-gateway-typescript-sdk@3.21.0-alpha.3": - version "3.21.0-alpha.3" - resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.21.0-alpha.3.tgz#21e0f01e9fbc6cff504fa9c7f957c27877b3f982" - integrity sha512-31xjB8VYUMsJ9akBd30YqHt4mreAwF5Kwbfa0LO0lE5L/DvfIwYiK/VB9uMse3LF/gVKKVOu9TIxKkaTsh9Y5w== +"@safe-global/safe-gateway-typescript-sdk@3.21.1": + version "3.21.1" + resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.21.1.tgz#984ec2d3d4211caf6a96786ab922b39909093538" + integrity sha512-7nakIjcRSs6781LkizYpIfXh1DYlkUDqyALciqz/BjFU/S97sVjZdL4cuKsG9NEarytE+f6p0Qbq2Bo1aocVUA== "@safe-global/safe-gateway-typescript-sdk@^3.5.3": version "3.19.0"