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"