diff --git a/apps/cowswap-frontend/src/common/hooks/useSendSafeTransactions.ts b/apps/cowswap-frontend/src/common/hooks/useSendSafeTransactions.ts new file mode 100644 index 0000000000..a57bbfd254 --- /dev/null +++ b/apps/cowswap-frontend/src/common/hooks/useSendSafeTransactions.ts @@ -0,0 +1,93 @@ +import { isTruthy } from '@cowprotocol/common-utils' +import { useSafeAppsSdk, useWalletCapabilities } from '@cowprotocol/wallet' +import { useWalletProvider } from '@cowprotocol/wallet-provider' +import type { MetaTransactionData } from '@safe-global/safe-core-sdk-types' + +import { useAppKitAccount, useAppKitNetwork } from '@reown/appkit/react' + +type GetCallsResult = { + status: 'PENDING' | 'CONFIRMED' + receipts?: { + logs: { + address: `0x${string}` + data: `0x${string}` + topics: `0x${string}`[] + }[] + status: `0x${string}` // Hex 1 or 0 for success or failure, respectively + chainId: `0x${string}` + blockHash: `0x${string}` + blockNumber: `0x${string}` + gasUsed: `0x${string}` + transactionHash: `0x${string}` + }[] +} + +export function useSendSafeTransactions() { + const safeAppsSdk = useSafeAppsSdk() + const provider = useWalletProvider() + const { address: account } = useAppKitAccount() + const { chainId } = useAppKitNetwork() + const capabilities = useWalletCapabilities() + const isAtomicBatchSupported = !!capabilities?.atomicBatch?.supported + + return async function sendSafeTransaction(txs: MetaTransactionData[]): Promise { + if (isAtomicBatchSupported && provider && account && chainId) { + const chainIdHex = '0x' + (+chainId).toString(16) + + return provider + .send('wallet_sendCalls', [ + { version: '1.0', from: account, calls: txs.map((tx) => ({ ...tx, chainId: chainIdHex })) }, + ]) + .then((batchId) => { + return new Promise((resolve, reject) => { + let intervalId: NodeJS.Timer | null = null + let triesCount = 0 + + // TODO: store batchId into localStorage and monitor it in background + function checkStatus() { + if (!provider) return undefined + + return provider.send('wallet_getCallsStatus', [batchId]).then((response: GetCallsResult) => { + triesCount++ + + const safeTxHashes = response.receipts + ?.map((r) => { + const log = r.logs.find((l) => { + // ExecutionSuccess topic + return l.topics[0] === '0x442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e' + }) + + return log ? log.data.slice(0, 66) : undefined + }) + .filter(isTruthy) + + const safeTxHash = safeTxHashes?.[0] + + if (response.status === 'CONFIRMED' && safeTxHash) { + resolve(safeTxHash) + if (intervalId) clearInterval(intervalId) + } + + if (triesCount > 30) { + if (intervalId) clearInterval(intervalId) + reject(new Error('Cannot get batch transaction result')) + } + }) + } + + intervalId = setInterval(checkStatus, 1000) + + checkStatus() + }) + }) + } + + if (safeAppsSdk) { + const tx = await safeAppsSdk.txs.send({ txs }) + + return tx.safeTxHash + } else { + throw new Error('Safe Apps SDK not available') + } + } +} diff --git a/apps/cowswap-frontend/src/modules/twap/hooks/useCreateTwapOrder.tsx b/apps/cowswap-frontend/src/modules/twap/hooks/useCreateTwapOrder.tsx index 9d30a8e069..e9851bec35 100644 --- a/apps/cowswap-frontend/src/modules/twap/hooks/useCreateTwapOrder.tsx +++ b/apps/cowswap-frontend/src/modules/twap/hooks/useCreateTwapOrder.tsx @@ -3,7 +3,7 @@ import { useCallback } from 'react' import { OrderKind } from '@cowprotocol/cow-sdk' import { UiOrderType } from '@cowprotocol/types' -import { useSafeAppsSdk, useWalletInfo } from '@cowprotocol/wallet' +import { useWalletInfo } from '@cowprotocol/wallet' import { CurrencyAmount, Token } from '@uniswap/sdk-core' import { Nullish } from 'types' @@ -17,6 +17,7 @@ import { useTradeConfirmActions, useTradePriceImpact } from 'modules/trade' import { TradeFlowAnalyticsContext, tradeFlowAnalytics } from 'modules/trade/utils/tradeFlowAnalytics' import { useConfirmPriceImpactWithoutFee } from 'common/hooks/useConfirmPriceImpactWithoutFee' +import { useSendSafeTransactions } from 'common/hooks/useSendSafeTransactions' import { useExtensibleFallbackContext } from './useExtensibleFallbackContext' import { useTwapOrderCreationContext } from './useTwapOrderCreationContext' @@ -40,7 +41,7 @@ export function useCreateTwapOrder() { const { inputCurrencyAmount, outputCurrencyAmount } = useAdvancedOrdersDerivedState() const appDataInfo = useAppData() - const safeAppsSdk = useSafeAppsSdk() + const sendSafeTransactions = useSendSafeTransactions() const twapOrderCreationContext = useTwapOrderCreationContext(inputCurrencyAmount as Nullish>) const extensibleFallbackContext = useExtensibleFallbackContext() @@ -60,7 +61,6 @@ export function useCreateTwapOrder() { !outputCurrencyAmount || !twapOrderCreationContext || !extensibleFallbackContext || - !safeAppsSdk || !appDataInfo || !twapOrder ) @@ -101,7 +101,7 @@ export function useCreateTwapOrder() { // upload the app data here, as application might need it to decode the order info before it is being signed uploadAppData({ chainId, orderId, appData: appDataInfo }) const createOrderTxs = createTwapOrderTxs(twapOrder, paramsStruct, twapOrderCreationContext) - const { safeTxHash } = await safeAppsSdk.txs.send({ txs: [...fallbackSetupTxs, ...createOrderTxs] }) + const safeTxHash = await sendSafeTransactions([...fallbackSetupTxs, ...createOrderTxs]) const orderItem: TwapOrderItem = { order: twapOrderToStruct(twapOrder), @@ -150,7 +150,7 @@ export function useCreateTwapOrder() { outputCurrencyAmount, twapOrderCreationContext, extensibleFallbackContext, - safeAppsSdk, + sendSafeTransactions, appDataInfo, twapOrder, confirmPriceImpactWithoutFee, @@ -159,6 +159,6 @@ export function useCreateTwapOrder() { addTwapOrderToList, uploadAppData, updateAdvancedOrdersState, - ] + ], ) } diff --git a/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts b/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts index b67a185ddb..ab77b3803e 100644 --- a/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts +++ b/apps/cowswap-frontend/src/modules/twap/hooks/useTwapFormState.ts @@ -1,7 +1,6 @@ import { useAtomValue } from 'jotai' -import { useMemo } from 'react' -import { useIsSafeApp, useWalletInfo } from '@cowprotocol/wallet' +import { useIsSafeApp, useIsWalletConnect, useWalletCapabilities, useWalletInfo } from '@cowprotocol/wallet' import { useReceiveAmountInfo } from 'modules/trade' import { useUsdAmount } from 'modules/usdAmount' @@ -23,15 +22,18 @@ export function useTwapFormState(): TwapFormState | null { const verification = useFallbackHandlerVerification() const isSafeApp = useIsSafeApp() - - return useMemo(() => { - return getTwapFormState({ - isSafeApp, - verification, - twapOrder, - sellAmountPartFiat, - chainId, - partTime, - }) - }, [isSafeApp, verification, twapOrder, sellAmountPartFiat, chainId, partTime]) + const isWalletConnect = useIsWalletConnect() + const walletCapabilities = useWalletCapabilities() + + // TODO: fix the condition in order to check whether is it a Safe via WC + const isSafeWithBundlingTx = isSafeApp || Boolean(isWalletConnect && walletCapabilities?.atomicBatch?.supported) + + return getTwapFormState({ + isSafeWithBundlingTx, + verification, + twapOrder, + sellAmountPartFiat, + chainId, + partTime, + }) } diff --git a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts index f293ae0d22..b46654c09a 100644 --- a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts +++ b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.test.ts @@ -24,7 +24,7 @@ describe('getTwapFormState()', () => { describe('When sell fiat amount is under threshold', () => { it('And order has buy amount, then should return SELL_AMOUNT_TOO_SMALL', () => { const result = getTwapFormState({ - isSafeApp: true, + isSafeWithBundlingTx: true, verification: ExtensibleFallbackVerification.HAS_DOMAIN_VERIFIER, twapOrder: { ...twapOrder }, sellAmountPartFiat: CurrencyAmount.fromRawAmount(WETH_SEPOLIA, 10000000), @@ -37,7 +37,7 @@ describe('getTwapFormState()', () => { it('And order does NOT have buy amount, then should return null', () => { const result = getTwapFormState({ - isSafeApp: true, + isSafeWithBundlingTx: true, verification: ExtensibleFallbackVerification.HAS_DOMAIN_VERIFIER, twapOrder: { ...twapOrder, buyAmount: CurrencyAmount.fromRawAmount(COW_SEPOLIA, 0) }, sellAmountPartFiat: CurrencyAmount.fromRawAmount(WETH_SEPOLIA, 10000000), diff --git a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx index a6ff8e4e45..8ea5a45e52 100644 --- a/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx +++ b/apps/cowswap-frontend/src/modules/twap/pure/PrimaryActionButton/getTwapFormState.tsx @@ -11,7 +11,7 @@ import { isPartTimeIntervalTooShort } from '../../utils/isPartTimeIntervalTooSho import { isSellAmountTooSmall } from '../../utils/isSellAmountTooSmall' export interface TwapFormStateParams { - isSafeApp: boolean + isSafeWithBundlingTx: boolean verification: ExtensibleFallbackVerification | null twapOrder: TWAPOrder | null sellAmountPartFiat: Nullish> @@ -28,9 +28,9 @@ export enum TwapFormState { } export function getTwapFormState(props: TwapFormStateParams): TwapFormState | null { - const { twapOrder, isSafeApp, verification, sellAmountPartFiat, chainId, partTime } = props + const { twapOrder, isSafeWithBundlingTx, verification, sellAmountPartFiat, chainId, partTime } = props - if (!isSafeApp) return TwapFormState.NOT_SAFE + if (!isSafeWithBundlingTx) return TwapFormState.NOT_SAFE if (verification === null) return TwapFormState.LOADING_SAFE_INFO diff --git a/apps/cowswap-frontend/src/modules/twap/updaters/index.tsx b/apps/cowswap-frontend/src/modules/twap/updaters/index.tsx index e4306ee4f1..a94201c795 100644 --- a/apps/cowswap-frontend/src/modules/twap/updaters/index.tsx +++ b/apps/cowswap-frontend/src/modules/twap/updaters/index.tsx @@ -1,5 +1,5 @@ import { percentToBps } from '@cowprotocol/common-utils' -import { useIsSafeApp, useWalletInfo } from '@cowprotocol/wallet' +import { useIsSafeWallet, useWalletInfo } from '@cowprotocol/wallet' import { useComposableCowContract } from 'modules/advancedOrders/hooks/useComposableCowContract' import { AppDataUpdater } from 'modules/appData' @@ -16,11 +16,11 @@ import { useTwapSlippage } from '../hooks/useTwapSlippage' export function TwapUpdaters() { const { chainId, account } = useWalletInfo() - const isSafeApp = useIsSafeApp() + const isSafeWallet = useIsSafeWallet() const composableCowContract = useComposableCowContract() const twapOrderSlippage = useTwapSlippage() - const shouldLoadTwapOrders = !!(isSafeApp && chainId && account && composableCowContract) + const shouldLoadTwapOrders = !!(isSafeWallet && chainId && account && composableCowContract) return ( <> diff --git a/libs/wallet/src/api/hooks.ts b/libs/wallet/src/api/hooks.ts index 518c532c39..0b847b3e1c 100644 --- a/libs/wallet/src/api/hooks.ts +++ b/libs/wallet/src/api/hooks.ts @@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai' import { useWalletInfo as useReOwnWalletInfo } from '@reown/appkit/react' +import { useWalletCapabilities } from './hooks/useWalletCapabilities' import { gnosisSafeInfoAtom, walletDetailsAtom, walletDisplayedAddress, walletInfoAtom } from './state' import { GnosisSafeInfo, WalletDetails, WalletInfo } from './types' @@ -25,10 +26,12 @@ export function useGnosisSafeInfo(): GnosisSafeInfo | undefined { } export function useIsBundlingSupported(): boolean { + const capabilities = useWalletCapabilities() + // For now, bundling can only be performed while the App is loaded as a Safe App // Pending a custom RPC endpoint implementation on Safe side to allow // tx bundling via WalletConnect - return useIsSafeApp() + return useIsSafeApp() || !!capabilities?.atomicBatch?.supported } export function useIsAssetWatchingSupported(): boolean { diff --git a/libs/wallet/src/api/hooks/useWalletCapabilities.ts b/libs/wallet/src/api/hooks/useWalletCapabilities.ts new file mode 100644 index 0000000000..97c0eb6dc3 --- /dev/null +++ b/libs/wallet/src/api/hooks/useWalletCapabilities.ts @@ -0,0 +1,29 @@ +import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const' +import { useWalletProvider } from '@cowprotocol/wallet-provider' + +import { useAppKitAccount, useAppKitNetwork } from '@reown/appkit/react' +import useSWR from 'swr' + +export type WalletCapabilities = { + atomicBatch?: { supported: boolean } +} + +export function useWalletCapabilities(): WalletCapabilities | undefined { + const provider = useWalletProvider() + const { address: account } = useAppKitAccount() + const { chainId } = useAppKitNetwork() + + return useSWR( + provider && account && chainId ? [provider, account, chainId] : null, + ([provider, account, chainId]) => { + return provider + .send('wallet_getCapabilities', [account]) + .then((result: { [chainIdHex: string]: WalletCapabilities }) => { + const chainIdHex = '0x' + (+chainId).toString(16) + + return result[chainIdHex] + }) + }, + SWR_NO_REFRESH_OPTIONS, + ).data +} diff --git a/libs/wallet/src/index.ts b/libs/wallet/src/index.ts index ecf8d63e02..acfa502d87 100644 --- a/libs/wallet/src/index.ts +++ b/libs/wallet/src/index.ts @@ -7,6 +7,7 @@ export * from './assets' // Hooks export * from './api/hooks' export { useOpenWalletConnectionModal } from './api/hooks/useOpenWalletConnectionModal' +export { useWalletCapabilities } from './api/hooks/useWalletCapabilities' export * from './reown/hooks/useWalletMetadata' export * from './reown/hooks/useIsWalletConnect' export * from './reown/hooks/useSafeAppsSdk'