From 7130d874383f3139882e5f85d167552e600669c0 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Mon, 6 May 2024 13:57:59 +0200 Subject: [PATCH 1/4] feat: fetch contract info for contract interactions (#3652) * feat: fetch contract info for contract interactions --- package.json | 2 +- .../common/NamedAddressInfo/index.test.tsx | 67 +++++++++++++++++++ .../common/NamedAddressInfo/index.tsx | 24 +++++++ .../DecodedData/SingleTxDecoded/index.tsx | 4 +- .../TxDetails/TxData/DecodedData/index.tsx | 4 +- .../SwapOrderConfirmationView/index.tsx | 12 +--- yarn.lock | 8 +-- 7 files changed, 102 insertions(+), 19 deletions(-) create mode 100644 src/components/common/NamedAddressInfo/index.test.tsx create mode 100644 src/components/common/NamedAddressInfo/index.tsx 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/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 ( <> - , - + , receiver && owner !== receiver ? ( <> 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" From 250d4d489f3645aa3fc15eb9ba70a4f283d96213 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 6 May 2024 13:59:05 +0200 Subject: [PATCH 2/4] fix: Hide Expired status in tx history (#3659) --- src/components/transactions/TxDetails/index.tsx | 2 +- src/components/transactions/TxSummary/index.tsx | 2 +- src/features/swap/hooks/useIsExpiredSwap.ts | 4 ++-- src/utils/transaction-guards.ts | 4 ---- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/components/transactions/TxDetails/index.tsx b/src/components/transactions/TxDetails/index.tsx index f8cd5d510e..b1448f0870 100644 --- a/src/components/transactions/TxDetails/index.tsx +++ b/src/components/transactions/TxDetails/index.tsx @@ -137,7 +137,7 @@ const TxDetailsBlock = ({ txSummary, txDetails }: TxDetailsProps): ReactElement )} - {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/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/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' } From 510808f1c2e550f1c9f329df5db79945072b7f8d Mon Sep 17 00:00:00 2001 From: Daniel Dimitrov Date: Mon, 6 May 2024 14:38:05 +0200 Subject: [PATCH 3/4] Feat cow swap better notifications (#3651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: better notifications Original issue: we had a listener for the different order statuses on the swap widget. That meant that if the user would leave the widget page he wouldn’t receive any notifications about the order. What is even worse - the user would be on the transaction page, would see the order updating and later when navigating to the widget he would now see a notification for that order. Solution: I’ve changed the way we deal with events. Instead of directly dispatching a notification whenever we receive a new event from the widget, we now store those order updates in the redux state. We update those orders based on our txHistory and on the widget updates we receive. The middleware that listens on those updates has access to the state prior to the state update and can decide if a notification needs to be shown to the user or not. fix: rename swap to order You can have swap or limit orders. Unfortunately it is a bit complicated to know whether the current order is a swap or limit order, so we are going to use the generic order. fix: type in notification title feat: delete swap order We are persisting the order slice and if the users are making a lot of orders it can grow quite a lot. Since right now we are mainly interested in the state in order to show correct notifications, we can optimise and delete the order if the state is fulfilled, cancelled or expired. Those are final states - the next time we see them from the txHistory we can ignore them, if our redux state doesn’t have a created, open or presignaturePending state. If the previous state is undefined - this means that we are not interested in those final states and can ignore them. . fix: failing swap slice test feat: change notification variant for success swap fix: state was not always cleaned up * fix: test --- src/features/swap/index.tsx | 44 +- src/store/__tests__/swapOrderSlice.test.ts | 533 +++++++++++++++++++++ src/store/index.ts | 12 +- src/store/slices.ts | 1 + src/store/swapOrderSlice.ts | 206 ++++++++ 5 files changed, 767 insertions(+), 29 deletions(-) create mode 100644 src/store/__tests__/swapOrderSlice.test.ts create mode 100644 src/store/swapOrderSlice.ts diff --git a/src/features/swap/index.tsx b/src/features/swap/index.tsx index 4189747fae..0ed5714b14 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' @@ -26,6 +25,7 @@ import useSwapConsent from './useSwapConsent' import Disclaimer from '@/components/common/Disclaimer' import LegalDisclaimerContent from '@/components/common/LegalDisclaimerContent' import { selectSwapParams, setSwapParams } from './store/swapParamsSlice' +import { setSwapOrder } from '@/store/swapOrderSlice' const BASE_URL = typeof window !== 'undefined' && window.location.origin ? window.location.origin : '' @@ -61,7 +61,6 @@ const SwapWidget = ({ sell }: Params) => { const wallet = useWallet() const { isConsentAccepted, onAccept } = useSwapConsent() - const groupKey = 'swap-order-status' const listeners = useMemo(() => { return [ { @@ -69,54 +68,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/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, + }, + }) + } + } + }, + }) +} From ec2e4e22e1e5043e8e0e6e72fa3664bf9b3a2aad Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Mon, 6 May 2024 14:46:02 +0200 Subject: [PATCH 4/4] feat: Use different titles depending on the type of swap (#3649) * feat: Use different titles depending on the type of swap * fix: Handle swap title in AppTitle component * fix: Remove dependency, toLowerCase in reducer * fix: Swap icon --- public/images/common/safe-swap-dark.svg | 16 +++++++++ public/images/common/safe-swap.svg | 16 +++++++++ .../tx-flow/flows/ConfirmTx/index.tsx | 6 +++- .../tx-flow/flows/SignMessage/index.tsx | 14 ++++++-- src/features/swap/index.tsx | 35 ++++++++++++------- src/features/swap/store/swapParamsSlice.ts | 7 +++- src/hooks/useTransactionType.ts | 6 ++-- 7 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 public/images/common/safe-swap-dark.svg create mode 100644 public/images/common/safe-swap.svg 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/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/index.tsx b/src/features/swap/index.tsx index 0ed5714b14..f65dc3f976 100644 --- a/src/features/swap/index.tsx +++ b/src/features/swap/index.tsx @@ -24,7 +24,7 @@ import { isBlockedAddress } from '@/services/ofac' import useSwapConsent from './useSwapConsent' import Disclaimer from '@/components/common/Disclaimer' import LegalDisclaimerContent from '@/components/common/LegalDisclaimerContent' -import { selectSwapParams, setSwapParams } from './store/swapParamsSlice' +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 +36,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 +55,23 @@ const SwapWidget = ({ sell }: Params) => { const wallet = useWallet() const { isConsentAccepted, onAccept } = useSwapConsent() + 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 [ { 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: {