From f080ffdb098612e729f3a3f829410ce78697979f Mon Sep 17 00:00:00 2001 From: Alexandr Kazachenko Date: Wed, 8 Jan 2025 22:52:28 +0500 Subject: [PATCH] feat(swap): partial approve (#5256) * feat(swap): add settings option for partial approve * feat(swap): add sell amount to regular approve tx * feat(swap): add sell amount to permit value * chore: fix build * fix: cache permit taking amount into account * feat(swap): take partial approves into account for sc wallets * fix: ignore account agnostic permit in hooks details * fix: take permit amount into account when caching * fix: skip partial permits in widgets besides swap * chore: fix permit hook description * chore: fix conditions * fix: disable partial approve for Hooks store * fix: support partial approve it classic eth flow * fix: do not use infinite approvals in swap when partial approve mode * chore: fix circular dependency * chore: add a dot * chore: fix tooltips * chore: adjust approve tooltip * fix: display hook details only in Hooks store confirm modal * feat(partial-approvals): partial approve v2 (#5269) * refactor: expose UndelinedLinkStyledButton component * chore: removed unused React import * refactor: export useOpenSettingsTab hook * feat: expose needsApproval flag for all token types, not just permittable * feat: add PartialApprovalBanner * feat: update settings name Partial Approve to Minimal approve and tooltip * fix: fix typo and padding on settings link * fix: reworked minimal approvals title and tooltip * fix: lint issues * fix: cosmos build * fix: fix grammar * fix: do not show approval banner when selling native * chore: move partial approval banner after TWAP warning * feat: simplify text * feat: remove isApprovalNeeded prop * feat: remove banner from swap warnings * feat: add banner to top of Swap widget * chore: remove settings from cosmos --------- Co-authored-by: Leandro --- .../containers/OrderHooksDetails/index.tsx | 41 +++++++++- .../TradeApprove/TradeApproveButton.tsx | 5 +- .../TradeApprove/useTradeApproveCallback.ts | 9 ++- .../src/common/hooks/useApproveCallback.ts | 15 ++-- .../src/common/pure/ApproveButton/index.tsx | 1 - .../common/pure/OrderProgressBarV2/index.tsx | 15 +++- .../common/pure/OrderProgressBarV2/styled.ts | 12 +-- .../src/common/utils/parsePermitData.ts | 15 ++++ .../src/legacy/state/user/hooks.tsx | 19 +++++ .../src/legacy/state/user/reducer.ts | 7 +- .../src/lib/hooks/useApproval.ts | 74 +------------------ .../operations/bundle/buildApproveTx.ts | 5 +- .../permit/hooks/useGeneratePermitHook.ts | 10 ++- .../permit/hooks/useGetCachedPermit.ts | 5 +- .../modules/permit/state/permitCacheAtom.ts | 31 ++++++-- .../src/modules/permit/types.ts | 4 +- .../src/modules/permit/utils/handlePermit.ts | 3 +- .../ConfirmSwapModalSetup/index.tsx | 8 +- .../modules/swap/containers/EthFlow/index.tsx | 8 +- .../swap/containers/SwapWidget/index.tsx | 10 ++- .../swap/hooks/useHandleSwapOrEthFlow.ts | 8 +- .../swap/hooks/useSwapButtonContext.ts | 4 + .../modules/swap/hooks/useSwapFlowContext.ts | 6 +- .../EthFlow/EthFlowModalContent/configs.ts | 4 +- .../swap/pure/SwapButtons/index.cosmos.tsx | 1 + .../modules/swap/pure/SwapButtons/index.tsx | 3 +- .../pure/banners/PartialApprovalBanner.tsx | 31 ++++++++ .../trade/pure/TradeConfirmation/index.tsx | 18 ++++- .../tradeFlow/hooks/useTradeFlowContext.ts | 8 +- .../safeBundleFlow/safeBundleApprovalFlow.ts | 11 ++- .../safeBundleFlow/safeBundleEthFlow.ts | 10 ++- .../tradeFlow/services/swapFlow/index.ts | 1 + .../tradeFlow/types/TradeFlowContext.ts | 1 + .../containers/SettingsTab/index.tsx | 41 +++++++++- .../pure/Row/RowSlippageContent/index.tsx | 7 +- .../state/settingsTabState.ts | 8 +- .../cowswap-frontend/src/pages/Swap/index.tsx | 5 +- .../utils/orderUtils/getOrderPermitAmount.ts | 7 +- libs/hook-dapp-lib/src/hookDappsRegistry.json | 2 +- .../src/lib/generatePermitHook.ts | 14 +++- libs/permit-utils/src/types.ts | 1 + libs/ui/src/containers/InlineBanner/index.tsx | 2 +- libs/ui/src/pure/LinkStyledButton/index.tsx | 9 +++ 43 files changed, 350 insertions(+), 149 deletions(-) create mode 100644 apps/cowswap-frontend/src/common/utils/parsePermitData.ts create mode 100644 apps/cowswap-frontend/src/modules/swap/pure/banners/PartialApprovalBanner.tsx diff --git a/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/index.tsx b/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/index.tsx index 3c8a623983..67e607b666 100644 --- a/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/index.tsx +++ b/apps/cowswap-frontend/src/common/containers/OrderHooksDetails/index.tsx @@ -3,6 +3,8 @@ import { ReactElement, useEffect, useMemo, useState } from 'react' import { latest } from '@cowprotocol/app-data' import { CowHookDetails, HookToDappMatch, matchHooksToDappsRegistry } from '@cowprotocol/hook-dapp-lib' import { InfoTooltip } from '@cowprotocol/ui' +import { useWalletInfo } from '@cowprotocol/wallet' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { ChevronDown, ChevronUp } from 'react-feather' @@ -14,15 +16,27 @@ import { HookItem } from './HookItem' import * as styledEl from './styled' import { CircleCount } from './styled' +import { parsePermitData } from '../../utils/parsePermitData' + interface OrderHooksDetailsProps { appData: string | AppDataInfo children: (content: ReactElement) => ReactElement margin?: string isTradeConfirmation?: boolean + slippageAdjustedSellAmount?: CurrencyAmount + isPartialApprove?: boolean } -export function OrderHooksDetails({ appData, children, margin, isTradeConfirmation }: OrderHooksDetailsProps) { +export function OrderHooksDetails({ + appData, + children, + margin, + isTradeConfirmation, + slippageAdjustedSellAmount, + isPartialApprove, +}: OrderHooksDetailsProps) { const [isOpen, setOpen] = useState(false) + const { account } = useWalletInfo() const appDataDoc = useMemo(() => { return typeof appData === 'string' ? decodeAppData(appData) : appData.doc }, [appData]) @@ -41,9 +55,32 @@ export function OrderHooksDetails({ appData, children, margin, isTradeConfirmati const metadata = appDataDoc.metadata as latest.Metadata + /** + * AppData might include a hook with account agnostic permit which is used to fetch a quote. + * This hook should be ignored. + * Moreover, any hook with a permit which has owner !== current account will be excluded. + * We also remove the permit from appData before order signing (see filterPermitSignerPermit). + */ + const preHooks = account + ? metadata.hooks?.pre?.filter((hook) => { + try { + const permitHookData = parsePermitData(hook.callData) + const isOwnerMatched = permitHookData.owner.toLowerCase() === account.toLowerCase() + + // If the hook is a partial approve, we need to check if the value is equal to the slippageAdjustedSellAmount + // Because there might be a hook with an "infinite" permit from other widget + return isPartialApprove && slippageAdjustedSellAmount + ? isOwnerMatched && permitHookData.value.eq(slippageAdjustedSellAmount.quotient.toString()) + : isOwnerMatched + } catch { + return true + } + }) + : metadata.hooks?.pre + const hasSomeFailedSimulation = isTradeConfirmation && Object.values(data || {}).some((hook) => !hook.status) - const preHooksToDapp = matchHooksToDappsRegistry(metadata.hooks?.pre || [], preCustomHookDapps) + const preHooksToDapp = matchHooksToDappsRegistry(preHooks || [], preCustomHookDapps) const postHooksToDapp = matchHooksToDappsRegistry(metadata.hooks?.post || [], postCustomHookDapps) if (!preHooksToDapp.length && !postHooksToDapp.length) return null diff --git a/apps/cowswap-frontend/src/common/containers/TradeApprove/TradeApproveButton.tsx b/apps/cowswap-frontend/src/common/containers/TradeApprove/TradeApproveButton.tsx index 68e68ef2b3..3e31105e1d 100644 --- a/apps/cowswap-frontend/src/common/containers/TradeApprove/TradeApproveButton.tsx +++ b/apps/cowswap-frontend/src/common/containers/TradeApprove/TradeApproveButton.tsx @@ -14,15 +14,16 @@ export interface TradeApproveButtonProps { amountToApprove: CurrencyAmount children?: React.ReactNode isDisabled?: boolean + isPartialApprove?: boolean } export function TradeApproveButton(props: TradeApproveButtonProps) { - const { amountToApprove, children, isDisabled } = props + const { amountToApprove, children, isDisabled, isPartialApprove } = props const currency = amountToApprove.currency const { state: approvalState } = useApproveState(amountToApprove) - const tradeApproveCallback = useTradeApproveCallback(amountToApprove) + const tradeApproveCallback = useTradeApproveCallback(amountToApprove, isPartialApprove) const shouldZeroApprove = useShouldZeroApprove(amountToApprove) const zeroApprove = useZeroApprove(amountToApprove.currency) diff --git a/apps/cowswap-frontend/src/common/containers/TradeApprove/useTradeApproveCallback.ts b/apps/cowswap-frontend/src/common/containers/TradeApprove/useTradeApproveCallback.ts index 3e5136344c..552a32d42b 100644 --- a/apps/cowswap-frontend/src/common/containers/TradeApprove/useTradeApproveCallback.ts +++ b/apps/cowswap-frontend/src/common/containers/TradeApprove/useTradeApproveCallback.ts @@ -19,13 +19,16 @@ export interface TradeApproveCallback { (params?: TradeApproveCallbackParams): Promise } -export function useTradeApproveCallback(amountToApprove?: CurrencyAmount): TradeApproveCallback { +export function useTradeApproveCallback( + amountToApprove?: CurrencyAmount, + isPartialApprove?: boolean, +): TradeApproveCallback { const updateTradeApproveState = useUpdateTradeApproveState() const spender = useTradeSpenderAddress() const currency = amountToApprove?.currency const symbol = currency?.symbol - const approveCallback = useApproveCallback(amountToApprove, spender) + const approveCallback = useApproveCallback(amountToApprove, spender, isPartialApprove) return useCallback( async ({ useModals = true }: TradeApproveCallbackParams = { useModals: true }) => { @@ -58,6 +61,6 @@ export function useTradeApproveCallback(amountToApprove?: CurrencyAmount, + isPartialApprove?: boolean, ): Promise<{ approveAmount: BigNumber | string gasLimit: BigNumber }> { + const approveAmount = + isPartialApprove && amountToApprove ? BigNumber.from(amountToApprove.quotient.toString()) : MaxUint256 + try { return { - approveAmount: MaxUint256, - gasLimit: await tokenContract.estimateGas.approve(spender, MaxUint256), + approveAmount, + gasLimit: await tokenContract.estimateGas.approve(spender, approveAmount), } } catch { // Fallback: Attempt to set an approval for the maximum wallet balance (instead of the MaxUint256). @@ -45,7 +49,7 @@ export async function estimateApprove( ) return { - approveAmount: MaxUint256, + approveAmount, gasLimit: GAS_LIMIT_DEFAULT, } } @@ -55,6 +59,7 @@ export async function estimateApprove( export function useApproveCallback( amountToApprove?: CurrencyAmount, spender?: string, + isPartialApprove?: boolean, ): (summary?: string) => Promise { const { chainId } = useWalletInfo() const currency = amountToApprove?.currency @@ -68,7 +73,7 @@ export function useApproveCallback( return } - const estimation = await estimateApprove(tokenContract, spender, amountToApprove) + const estimation = await estimateApprove(tokenContract, spender, amountToApprove, isPartialApprove) return tokenContract .approve(spender, estimation.approveAmount, { gasLimit: calculateGasMargin(estimation.gasLimit), @@ -81,5 +86,5 @@ export function useApproveCallback( }) return response }) - }, [chainId, token, tokenContract, amountToApprove, spender, addTransaction]) + }, [chainId, token, tokenContract, amountToApprove, spender, addTransaction, isPartialApprove]) } diff --git a/apps/cowswap-frontend/src/common/pure/ApproveButton/index.tsx b/apps/cowswap-frontend/src/common/pure/ApproveButton/index.tsx index 13273df82e..68bda945b0 100644 --- a/apps/cowswap-frontend/src/common/pure/ApproveButton/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/ApproveButton/index.tsx @@ -50,7 +50,6 @@ export function ApproveButton(props: ApproveButtonProps) { content={ You must give the CoW Protocol smart contracts permission to use your . - If you approve the default amount, you will only have to do this once per token. } > diff --git a/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/index.tsx b/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/index.tsx index 81d1eaafc2..605eb3faf3 100644 --- a/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/index.tsx @@ -22,7 +22,16 @@ import { ExplorerDataType, getExplorerLink, getRandomInt, isSellOrder, shortenAd import { OrderKind, SupportedChainId } from '@cowprotocol/cow-sdk' import { TokenLogo } from '@cowprotocol/tokens' import { Command } from '@cowprotocol/types' -import { Confetti, ExternalLink, InfoTooltip, ProductLogo, ProductVariant, TokenAmount, UI } from '@cowprotocol/ui' +import { + Confetti, + ExternalLink, + InfoTooltip, + ProductLogo, + ProductVariant, + TokenAmount, + UI, + UnderlinedLinkStyledButton, +} from '@cowprotocol/ui' import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { AnimatePresence, motion } from 'framer-motion' @@ -1123,14 +1132,14 @@ function ExpiredStep(props: OrderProgressBarV2Props) {

The good news

Unlike on other exchanges, you won't be charged for this! Feel free to{' '} - { props.navigateToNewOrder?.() trackNewOrderClick() }} > place a new order - {' '} + {' '} without worry.

diff --git a/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/styled.ts b/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/styled.ts index 198fe338ff..cd1a6a3de0 100644 --- a/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/styled.ts +++ b/apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/styled.ts @@ -1,6 +1,6 @@ import IMAGE_STAR_SHINE from '@cowprotocol/assets/cow-swap/star-shine.svg' import { SingleLetterLogoWrapper } from '@cowprotocol/tokens' -import { ButtonPrimary, Font, LinkStyledButton, Media, UI } from '@cowprotocol/ui' +import { ButtonPrimary, Font, Media, UI } from '@cowprotocol/ui' import styled, { css, keyframes } from 'styled-components/macro' @@ -66,6 +66,7 @@ export const StepsContainer = styled.div<{ $height: number; $minHeight?: string; padding: 0; // implement a gradient to hide the bottom of the steps container using white to opacity white using pseudo element + &::after { content: ${({ bottomGradient }) => (bottomGradient ? '""' : 'none')}; position: absolute; @@ -143,15 +144,6 @@ export const CancelButton = styled(CancelButtonOriginal)` } ` -export const Button = styled(LinkStyledButton)` - font-size: 14px; - text-decoration: underline; - - &:hover { - text-decoration: none; - } -` - export const ProgressImageWrapper = styled.div<{ bgColor?: string; padding?: string; height?: string; gap?: string }>` width: 100%; height: ${({ height }) => height || '246px'}; diff --git a/apps/cowswap-frontend/src/common/utils/parsePermitData.ts b/apps/cowswap-frontend/src/common/utils/parsePermitData.ts new file mode 100644 index 0000000000..6ba16f7c1a --- /dev/null +++ b/apps/cowswap-frontend/src/common/utils/parsePermitData.ts @@ -0,0 +1,15 @@ +import { Erc20__factory } from '@cowprotocol/abis' +import type { BigNumber } from '@ethersproject/bignumber' + +const erc20Interface = Erc20__factory.createInterface() + +export interface PermitParameters { + owner: string + spender: string + value: BigNumber + deadline: BigNumber +} + +export function parsePermitData(callData: string): PermitParameters { + return erc20Interface.decodeFunctionData('permit', callData) as unknown as PermitParameters +} diff --git a/apps/cowswap-frontend/src/legacy/state/user/hooks.tsx b/apps/cowswap-frontend/src/legacy/state/user/hooks.tsx index 2f86b41a32..24aeb1e878 100644 --- a/apps/cowswap-frontend/src/legacy/state/user/hooks.tsx +++ b/apps/cowswap-frontend/src/legacy/state/user/hooks.tsx @@ -8,8 +8,11 @@ import { Currency } from '@uniswap/sdk-core' import { shallowEqual } from 'react-redux' +import { useIsHooksTradeType } from 'modules/trade/hooks/useIsHooksTradeType' + import { updateHooksEnabled, + updatePartialApprove, updateRecipientToggleVisible, updateUserDarkMode, updateUserDeadline, @@ -118,6 +121,22 @@ export function useUserTransactionTTL(): [number, (slippage: number) => void] { return [deadline, setUserDeadline] } +export function usePartialApprove(): [boolean, (value: boolean) => void] { + const dispatch = useAppDispatch() + const isHookTradeType = useIsHooksTradeType() + const partialApprove = useAppSelector((state) => state.user.partialApprove) + + const setPartialApprove = useCallback( + (partialApprove: boolean) => { + dispatch(updatePartialApprove({ partialApprove })) + }, + [dispatch], + ) + + // Partial approve is disabled for Hooks store + return [isHookTradeType ? false : partialApprove, setPartialApprove] +} + export function useSelectedWallet(): string | undefined { return useAppSelector(({ user: { selectedWallet } }) => selectedWallet) } diff --git a/apps/cowswap-frontend/src/legacy/state/user/reducer.ts b/apps/cowswap-frontend/src/legacy/state/user/reducer.ts index a9470d45b3..395811fa84 100644 --- a/apps/cowswap-frontend/src/legacy/state/user/reducer.ts +++ b/apps/cowswap-frontend/src/legacy/state/user/reducer.ts @@ -18,6 +18,7 @@ export interface UserState { // TODO: mod, shouldn't be here recipientToggleVisible: boolean hooksEnabled: boolean + partialApprove: boolean // deadline set by user in minutes, used in all txns userDeadline: number @@ -27,9 +28,9 @@ export const initialState: UserState = { selectedWallet: undefined, matchesDarkMode: false, userDarkMode: null, - // TODO: mod, shouldn't be here recipientToggleVisible: false, hooksEnabled: false, + partialApprove: false, userLocale: null, userDeadline: DEFAULT_DEADLINE_FROM_NOW, } @@ -56,6 +57,9 @@ const userSlice = createSlice({ updateUserDeadline(state, action) { state.userDeadline = action.payload.userDeadline }, + updatePartialApprove(state, action) { + state.partialApprove = action.payload.partialApprove + }, updateRecipientToggleVisible(state, action) { state.recipientToggleVisible = action.payload.recipientToggleVisible }, @@ -70,5 +74,6 @@ export const { updateUserDeadline, updateUserLocale, updateRecipientToggleVisible, + updatePartialApprove, } = userSlice.actions export default userSlice.reducer diff --git a/apps/cowswap-frontend/src/lib/hooks/useApproval.ts b/apps/cowswap-frontend/src/lib/hooks/useApproval.ts index 285b4afb2a..bfe49fa6c2 100644 --- a/apps/cowswap-frontend/src/lib/hooks/useApproval.ts +++ b/apps/cowswap-frontend/src/lib/hooks/useApproval.ts @@ -1,9 +1,7 @@ -import { useCallback, useMemo } from 'react' +import { useMemo } from 'react' -import { calculateGasMargin, getIsNativeToken } from '@cowprotocol/common-utils' +import { getIsNativeToken } from '@cowprotocol/common-utils' import { useWalletInfo } from '@cowprotocol/wallet' -import { MaxUint256 } from '@ethersproject/constants' -import { TransactionResponse } from '@ethersproject/providers' import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core' import { Nullish } from 'types' @@ -11,7 +9,6 @@ import { Nullish } from 'types' import { useTokenAllowance } from 'legacy/hooks/useTokenAllowance' import { ApprovalState } from 'common/hooks/useApproveState' -import { useTokenContract } from 'common/hooks/useContract' export interface ApprovalStateForSpenderResult { approvalState: ApprovalState @@ -22,7 +19,7 @@ function toApprovalState( amountToApprove: Nullish>, spender: string | undefined, currentAllowance?: CurrencyAmount, - pendingApproval?: boolean + pendingApproval?: boolean, ): ApprovalState { // Unknown amount or spender if (!amountToApprove || !spender) { @@ -50,7 +47,7 @@ function toApprovalState( export function useApprovalStateForSpender( amountToApprove: Nullish>, spender: string | undefined, - useIsPendingApproval: (token?: Token, spender?: string) => boolean + useIsPendingApproval: (token?: Token, spender?: string) => boolean, ): ApprovalStateForSpenderResult { const { account } = useWalletInfo() const currency = amountToApprove?.currency @@ -64,66 +61,3 @@ export function useApprovalStateForSpender( return { approvalState, currentAllowance } }, [amountToApprove, currentAllowance, pendingApproval, spender]) } - -export function useApproval( - amountToApprove: CurrencyAmount | undefined, - spender: string | undefined, - useIsPendingApproval: (token?: Token, spender?: string) => boolean -): [ - ApprovalState, - () => Promise<{ response: TransactionResponse; tokenAddress: string; spenderAddress: string } | undefined> -] { - const { chainId } = useWalletInfo() - const currency = amountToApprove?.currency - const token = currency && !getIsNativeToken(currency) ? currency : undefined - - // check the current approval status - const approvalState = useApprovalStateForSpender(amountToApprove, spender, useIsPendingApproval).approvalState - - const tokenContract = useTokenContract(token?.address) - - const approve = useCallback(async () => { - function logFailure(error: Error | string): undefined { - console.warn(`${token?.symbol || 'Token'} approval failed:`, error) - return - } - - // Bail early if there is an issue. - if (approvalState !== ApprovalState.NOT_APPROVED) { - return logFailure('approve was called unnecessarily') - } else if (!chainId) { - return logFailure('no chainId') - } else if (!token) { - return logFailure('no token') - } else if (!tokenContract) { - return logFailure('tokenContract is null') - } else if (!amountToApprove) { - return logFailure('missing amount to approve') - } else if (!spender) { - return logFailure('no spender') - } - - let useExact = false - const estimatedGas = await tokenContract.estimateGas.approve(spender, MaxUint256).catch(() => { - // general fallback for tokens which restrict approval amounts - useExact = true - return tokenContract.estimateGas.approve(spender, amountToApprove.quotient.toString()) - }) - - return tokenContract - .approve(spender, useExact ? amountToApprove.quotient.toString() : MaxUint256, { - gasLimit: calculateGasMargin(estimatedGas), - }) - .then((response) => ({ - response, - tokenAddress: token.address, - spenderAddress: spender, - })) - .catch((error: Error) => { - logFailure(error) - throw error - }) - }, [approvalState, token, tokenContract, amountToApprove, spender, chainId]) - - return [approvalState, approve] -} diff --git a/apps/cowswap-frontend/src/modules/operations/bundle/buildApproveTx.ts b/apps/cowswap-frontend/src/modules/operations/bundle/buildApproveTx.ts index abd22db931..51aba39eea 100644 --- a/apps/cowswap-frontend/src/modules/operations/bundle/buildApproveTx.ts +++ b/apps/cowswap-frontend/src/modules/operations/bundle/buildApproveTx.ts @@ -7,15 +7,16 @@ export type BuildApproveTxParams = { erc20Contract: Erc20 spender: string amountToApprove: CurrencyAmount + isPartialApprove?: boolean } /** * Builds the approval tx, without sending it */ export async function buildApproveTx(params: BuildApproveTxParams) { - const { erc20Contract, spender, amountToApprove } = params + const { erc20Contract, spender, amountToApprove, isPartialApprove } = params - const estimatedAmount = await estimateApprove(erc20Contract, spender, amountToApprove) + const estimatedAmount = await estimateApprove(erc20Contract, spender, amountToApprove, isPartialApprove) return erc20Contract.populateTransaction.approve(spender, estimatedAmount.approveAmount) } diff --git a/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts b/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts index 86d6b6bf65..2fbf6ba422 100644 --- a/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts +++ b/apps/cowswap-frontend/src/modules/permit/hooks/useGeneratePermitHook.ts @@ -19,7 +19,7 @@ import { GeneratePermitHook, GeneratePermitHookParams } from '../types' /** * Hook that returns callback to generate permit hook data */ -export function useGeneratePermitHook(): GeneratePermitHook { +export function useGeneratePermitHook(isPartialApprove?: boolean): GeneratePermitHook { const { chainId } = useWalletInfo() const storePermit = useSetAtom(storePermitCacheAtom) const getCachedPermit = useGetCachedPermit() @@ -44,14 +44,15 @@ export function useGeneratePermitHook(): GeneratePermitHook { const eip2162Utils = getPermitUtilsInstance(chainId, provider, account) const spender = customSpender || COW_PROTOCOL_VAULT_RELAYER_ADDRESS[chainId] + const amount = isPartialApprove ? params.amount : undefined // Always get the nonce for the real account, to know whether the cache should be invalidated // Static account should never need to pre-check the nonce as it'll never change once cached const nonce = account ? await eip2162Utils.getTokenNonce(inputToken.address, account) : undefined - const permitParams = { chainId, tokenAddress: inputToken.address, account, nonce } + const permitParams = { chainId, tokenAddress: inputToken.address, account, nonce, amount } - const cachedPermit = await getCachedPermit(inputToken.address, spender) + const cachedPermit = await getCachedPermit(inputToken.address, spender, amount) if (cachedPermit) { return cachedPermit @@ -66,12 +67,13 @@ export function useGeneratePermitHook(): GeneratePermitHook { eip2162Utils, account, nonce, + amount, }) hookData && storePermit({ ...permitParams, hookData, spender }) return hookData }, - [provider, chainId, getCachedPermit, storePermit], + [provider, chainId, getCachedPermit, storePermit, isPartialApprove], ) } diff --git a/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts b/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts index 4f0ba0dc7a..4476e8c7c4 100644 --- a/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts +++ b/apps/cowswap-frontend/src/modules/permit/hooks/useGetCachedPermit.ts @@ -13,13 +13,14 @@ import { getPermitCacheAtom } from '../state/permitCacheAtom' export function useGetCachedPermit(): ( tokenAddress: Nullish, customSpender?: string, + amount?: bigint, ) => Promise { const { chainId, account } = useWalletInfo() const provider = useWalletProvider() const getCachedPermit = useSetAtom(getPermitCacheAtom) return useCallback( - async (tokenAddress: Nullish, customSpender?: string) => { + async (tokenAddress: Nullish, customSpender?: string, amount?: bigint) => { if (!provider || !account || !tokenAddress) { return } @@ -32,7 +33,7 @@ export function useGetCachedPermit(): ( // Static account should never need to pre-check the nonce as it'll never change once cached const nonce = account ? await eip2162Utils.getTokenNonce(tokenAddress, account) : undefined - const permitParams = { chainId, tokenAddress, account, nonce, spender } + const permitParams = { chainId, tokenAddress, account, nonce, spender, amount } return getCachedPermit(permitParams) } catch (e) { diff --git a/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts b/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts index e09aa938f4..1ab0198f52 100644 --- a/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts +++ b/apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts @@ -1,6 +1,12 @@ import { atom } from 'jotai' import { atomWithStorage } from 'jotai/utils' +import { MaxUint256 } from '@ethersproject/constants' + +import { TradeType, tradeTypeAtom } from 'modules/trade' + +import { Routes } from 'common/constants/routes' + import { CachedPermitData, GetPermitCacheParams, @@ -14,14 +20,14 @@ import { * Should never change once it has been created. * Used exclusively for quote requests */ -export const staticPermitCacheAtom = atomWithStorage('staticPermitCache:v3', {}) +export const staticPermitCacheAtom = atomWithStorage('staticPermitCache:v4', {}) /** * Atom that stores permit data for user permit requests. * Should be updated whenever the permit nonce is updated. * Used exclusively for order requests */ -export const userPermitCacheAtom = atomWithStorage('userPermitCache:v1', {}) +export const userPermitCacheAtom = atomWithStorage('userPermitCache:v2', {}) /** * Atom to add/update permit cache data @@ -36,6 +42,7 @@ export const storePermitCacheAtom = atom(null, (get, set, params: StorePermitCac const dataToCache: CachedPermitData = { hookData: params.hookData, nonce: params.nonce, + amount: params.amount?.toString(), } set(atomToUpdate, (permitCache) => ({ ...permitCache, [key]: JSON.stringify(dataToCache) })) @@ -53,6 +60,10 @@ export const getPermitCacheAtom = atom(null, (get, set, params: GetPermitCachePa const atomToUpdate = params.account ? userPermitCacheAtom : staticPermitCacheAtom const permitCache = get(atomToUpdate) + const tradeType = get(tradeTypeAtom) + + const isSwap = tradeType?.tradeType === TradeType.SWAP && tradeType.route === Routes.SWAP + const key = buildKey(params) const cachedData = permitCache[key] @@ -61,7 +72,7 @@ export const getPermitCacheAtom = atom(null, (get, set, params: GetPermitCachePa } try { - const { hookData, nonce: storedNonce }: CachedPermitData = JSON.parse(cachedData) + const { hookData, nonce: storedNonce, amount }: CachedPermitData = JSON.parse(cachedData) if (params.account !== undefined) { // User type permit cache, check the nonce @@ -76,6 +87,16 @@ export const getPermitCacheAtom = atom(null, (get, set, params: GetPermitCachePa return undefined } + + // Only Swap might create partial amount permits + // Because of that, we skip cached permits with partial amount in other widgets + if (!isSwap && amount && amount !== MaxUint256.toString()) { + return undefined + } + + if (params.amount && params.amount.toString() !== amount) { + return undefined + } } // Cache hit for both static and user permit types @@ -91,8 +112,8 @@ export const getPermitCacheAtom = atom(null, (get, set, params: GetPermitCachePa } }) -function buildKey({ chainId, tokenAddress, account, spender }: PermitCacheKeyParams) { - const base = `${chainId}-${tokenAddress.toLowerCase()}-${spender.toLowerCase()}` +function buildKey({ chainId, tokenAddress, account, spender, amount }: PermitCacheKeyParams) { + const base = `${chainId}-${tokenAddress.toLowerCase()}-${spender.toLowerCase()}-${amount ? amount.toString() : ''}` return account ? `${base}-${account.toLowerCase()}` : base } diff --git a/apps/cowswap-frontend/src/modules/permit/types.ts b/apps/cowswap-frontend/src/modules/permit/types.ts index c1b9cd215d..2a42f1c941 100644 --- a/apps/cowswap-frontend/src/modules/permit/types.ts +++ b/apps/cowswap-frontend/src/modules/permit/types.ts @@ -14,7 +14,7 @@ export type AddPermitTokenParams = { permitInfo: PermitInfo } -export type GeneratePermitHookParams = Pick & { +export type GeneratePermitHookParams = Pick & { customSpender?: string } @@ -33,6 +33,7 @@ export type PermitCache = Record export type CachedPermitData = { hookData: PermitHookData nonce: number | undefined + amount: string | undefined } export type PermitCacheKeyParams = { @@ -41,6 +42,7 @@ export type PermitCacheKeyParams = { account: string | undefined nonce: number | undefined spender: string + amount: bigint | undefined } export type StorePermitCacheParams = PermitCacheKeyParams & { hookData: PermitHookData } diff --git a/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts b/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts index 1a13cb033c..1be97e5ee9 100644 --- a/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts +++ b/apps/cowswap-frontend/src/modules/permit/utils/handlePermit.ts @@ -22,7 +22,7 @@ import { HandlePermitParams } from '../types' * Returns the updated appData */ export async function handlePermit(params: HandlePermitParams): Promise { - const { permitInfo, inputToken, account, appData, typedHooks, generatePermitHook } = params + const { permitInfo, inputToken, account, appData, typedHooks, generatePermitHook, amount } = params if (isSupportedPermitInfo(permitInfo) && !getIsNativeToken(inputToken)) { // permitInfo will only be set if there's NOT enough allowance @@ -31,6 +31,7 @@ export async function handlePermit(params: HandlePermitParams): Promise {(restContent) => ( <> diff --git a/apps/cowswap-frontend/src/modules/swap/containers/EthFlow/index.tsx b/apps/cowswap-frontend/src/modules/swap/containers/EthFlow/index.tsx index a8b9760bd0..150a7eea22 100644 --- a/apps/cowswap-frontend/src/modules/swap/containers/EthFlow/index.tsx +++ b/apps/cowswap-frontend/src/modules/swap/containers/EthFlow/index.tsx @@ -9,6 +9,7 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { useSingleActivityDescriptor } from 'legacy/hooks/useRecentActivity' import { WrapUnwrapCallback } from 'legacy/hooks/useWrapCallback' +import { usePartialApprove } from 'legacy/state/user/hooks' import { getDerivedEthFlowState } from 'modules/swap/containers/EthFlow/utils/getDerivedEthFlowState' import { EthFlowModalContent } from 'modules/swap/pure/EthFlow/EthFlowModalContent' @@ -25,7 +26,6 @@ import { useEthFlowActions } from './hooks/useEthFlowActions' import useRemainingNativeTxsAndCosts from './hooks/useRemainingNativeTxsAndCosts' import { useSetupEthFlow } from './hooks/useSetupEthFlow' - export interface EthFlowProps { nativeInput?: CurrencyAmount hasEnoughWrappedBalanceForSwap: boolean @@ -45,10 +45,12 @@ export function EthFlowModal({ const native = useNativeCurrency() const wrapped = useWrappedToken() const { state: approvalState } = useApproveState(nativeInput || null) + const [isPartialApprove] = usePartialApprove() const ethFlowContext = useAtomValue(ethFlowContextAtom) const approveCallback = useTradeApproveCallback( - (nativeInput && currencyAmountToTokenAmount(nativeInput)) || undefined + (nativeInput && currencyAmountToTokenAmount(nativeInput)) || undefined, + isPartialApprove, ) const ethFlowActions = useEthFlowActions({ wrap: wrapCallback, @@ -86,7 +88,7 @@ export function EthFlowModal({ approvalState, approveActivity, wrapActivity, - onDismiss + onDismiss, }) return ( diff --git a/apps/cowswap-frontend/src/modules/swap/containers/SwapWidget/index.tsx b/apps/cowswap-frontend/src/modules/swap/containers/SwapWidget/index.tsx index a49a039628..d4fff58b3d 100644 --- a/apps/cowswap-frontend/src/modules/swap/containers/SwapWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/swap/containers/SwapWidget/index.tsx @@ -14,7 +14,12 @@ import { NetworkAlert } from 'legacy/components/NetworkAlert/NetworkAlert' import { useModalIsOpen } from 'legacy/state/application/hooks' import { ApplicationModal } from 'legacy/state/application/reducer' import { Field } from 'legacy/state/types' -import { useHooksEnabledManager, useRecipientToggleManager, useUserTransactionTTL } from 'legacy/state/user/hooks' +import { + useHooksEnabledManager, + usePartialApprove, + useRecipientToggleManager, + useUserTransactionTTL, +} from 'legacy/state/user/hooks' import { useCurrencyAmountBalanceCombined } from 'modules/combinedBalances' import { useInjectedWidgetParams } from 'modules/injectedWidget' @@ -85,6 +90,7 @@ export function SwapWidget({ topContent, bottomContent }: SwapWidgetProps) { const recipientToggleState = useRecipientToggleManager() const hooksEnabledState = useHooksEnabledManager() const deadlineState = useUserTransactionTTL() + const partialApproveState = usePartialApprove() const isHookTradeType = useIsHooksTradeType() const isTradePriceUpdating = useTradePricesUpdate() @@ -238,6 +244,8 @@ export function SwapWidget({ topContent, bottomContent }: SwapWidgetProps) { recipientToggleState={recipientToggleState} hooksEnabledState={hooksEnabledState} deadlineState={deadlineState} + // Partial approve is disabled for Hooks store + partialApproveState={isHookTradeType ? undefined : partialApproveState} /> ), diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useHandleSwapOrEthFlow.ts b/apps/cowswap-frontend/src/modules/swap/hooks/useHandleSwapOrEthFlow.ts index f550702e03..275a2798c3 100644 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useHandleSwapOrEthFlow.ts +++ b/apps/cowswap-frontend/src/modules/swap/hooks/useHandleSwapOrEthFlow.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { Field } from 'legacy/state/types' -import { useUserTransactionTTL } from 'legacy/state/user/hooks' +import { usePartialApprove, useUserTransactionTTL } from 'legacy/state/user/hooks' import { TradeWidgetActions, useTradePriceImpact } from 'modules/trade' import { logTradeFlow } from 'modules/trade/utils/logger' @@ -25,7 +25,11 @@ export function useHandleSwapOrEthFlow(actions: TradeWidgetActions) { const { onUserInput, onChangeRecipient } = actions const [deadline] = useUserTransactionTTL() - const { callback: handleSwap, contextIsReady } = useHandleSwap(useSafeMemoObject({ deadline }), actions) + const [isPartialApprove] = usePartialApprove() + const { callback: handleSwap, contextIsReady } = useHandleSwap( + useSafeMemoObject({ deadline, isPartialApprove }), + actions, + ) const callback = useCallback(async () => { if (!swapFlowContext) return diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts index 91fe6ec473..19906be3f4 100644 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts +++ b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapButtonContext.ts @@ -14,6 +14,7 @@ import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { useToggleWalletModal } from 'legacy/state/application/hooks' import { useGetQuoteAndStatus, useIsBestQuoteLoading } from 'legacy/state/price/hooks' import { Field } from 'legacy/state/types' +import { usePartialApprove } from 'legacy/state/user/hooks' import { useCurrencyAmountBalanceCombined } from 'modules/combinedBalances' import { useInjectedWidgetParams } from 'modules/injectedWidget' @@ -63,6 +64,7 @@ export function useSwapButtonContext(input: SwapButtonInput, actions: TradeWidge const tradeConfirmActions = useTradeConfirmActions() const { standaloneMode } = useInjectedWidgetParams() const isHooksStore = useIsHooksTradeType() + const [isPartialApprove] = usePartialApprove() const currencyIn = currencies[Field.INPUT] const currencyOut = currencies[Field.OUTPUT] @@ -143,6 +145,7 @@ export function useSwapButtonContext(input: SwapButtonInput, actions: TradeWidge onCurrencySelection, widgetStandaloneMode: standaloneMode, quoteDeadlineParams, + isPartialApprove, }), [ swapButtonState, @@ -159,6 +162,7 @@ export function useSwapButtonContext(input: SwapButtonInput, actions: TradeWidge onCurrencySelection, standaloneMode, quoteDeadlineParams, + isPartialApprove, ], ) } diff --git a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFlowContext.ts b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFlowContext.ts index afed5d89d7..bc70dfb18f 100644 --- a/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFlowContext.ts +++ b/apps/cowswap-frontend/src/modules/swap/hooks/useSwapFlowContext.ts @@ -1,4 +1,4 @@ -import { useUserTransactionTTL } from 'legacy/state/user/hooks' +import { usePartialApprove, useUserTransactionTTL } from 'legacy/state/user/hooks' import { useTradeFlowContext } from 'modules/tradeFlow' @@ -6,5 +6,7 @@ import { useSafeMemoObject } from 'common/hooks/useSafeMemo' export function useSwapFlowContext() { const [deadline] = useUserTransactionTTL() - return useTradeFlowContext(useSafeMemoObject({ deadline })) + const [isPartialApprove] = usePartialApprove() + + return useTradeFlowContext(useSafeMemoObject({ deadline, isPartialApprove })) } diff --git a/apps/cowswap-frontend/src/modules/swap/pure/EthFlow/EthFlowModalContent/configs.ts b/apps/cowswap-frontend/src/modules/swap/pure/EthFlow/EthFlowModalContent/configs.ts index b5648ccb99..e599b3e3f3 100644 --- a/apps/cowswap-frontend/src/modules/swap/pure/EthFlow/EthFlowModalContent/configs.ts +++ b/apps/cowswap-frontend/src/modules/swap/pure/EthFlow/EthFlowModalContent/configs.ts @@ -68,9 +68,7 @@ export const ethFlowConfigs: { [EthFlowState.ApproveNeeded]: ({ wrappedSymbol }) => ({ title: `Approve ${wrappedSymbol}`, buttonText: `Approve ${wrappedSymbol}`, - descriptions: [ - `It is required to do a one-time approval of ${wrappedSymbol} via an on-chain ERC20 Approve transaction.`, - ], + descriptions: [`It is required to do an approval of ${wrappedSymbol} via an on-chain ERC20 Approve transaction.`], }), [EthFlowState.SwapReady]: ({ wrappedSymbol }) => ({ title: `Continue swap with ${wrappedSymbol}`, diff --git a/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.cosmos.tsx index 110dd701f1..c64663d107 100644 --- a/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.cosmos.tsx @@ -26,6 +26,7 @@ const swapButtonsContext: SwapButtonsContext = { openSwapConfirm: () => void 0, toggleWalletModal: () => void 0, hasEnoughWrappedBalanceForSwap: true, + isPartialApprove: false, quoteDeadlineParams: { validFor: 0, quoteValidTo: 0, diff --git a/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.tsx b/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.tsx index 28c5618757..a0dd7e1e41 100644 --- a/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.tsx +++ b/apps/cowswap-frontend/src/modules/swap/pure/SwapButtons/index.tsx @@ -40,6 +40,7 @@ export interface SwapButtonsContext { onCurrencySelection: (field: Field, currency: Currency) => void widgetStandaloneMode?: boolean quoteDeadlineParams: QuoteDeadlineParams + isPartialApprove: boolean } const swapButtonStateMap: { [key in SwapButtonState]: (props: SwapButtonsContext) => JSX.Element } = { @@ -126,7 +127,7 @@ const swapButtonStateMap: { [key in SwapButtonState]: (props: SwapButtonsContext {props.inputAmount && ( - + Swap diff --git a/apps/cowswap-frontend/src/modules/swap/pure/banners/PartialApprovalBanner.tsx b/apps/cowswap-frontend/src/modules/swap/pure/banners/PartialApprovalBanner.tsx new file mode 100644 index 0000000000..caddfb37b5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/swap/pure/banners/PartialApprovalBanner.tsx @@ -0,0 +1,31 @@ +import ICON_TOKENS from '@cowprotocol/assets/svg/tokens.svg' +import { Command } from '@cowprotocol/types' +import { BannerOrientation, ClosableBanner, InlineBanner, UnderlinedLinkStyledButton } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +const BANNER_STORAGE_KEY = 'partialPermitBannerKey:v0' + +type PartialApprovalBannerProps = { + openSettings: Command +} + +export function PartialApprovalBanner({ openSettings }: PartialApprovalBannerProps) { + return ClosableBanner(BANNER_STORAGE_KEY, (onClose) => ( + +

+ NEW: You can now choose to do minimal token approvals in the settings. +

+
+ )) +} + +const Link = styled(UnderlinedLinkStyledButton)` + padding: 0; +` diff --git a/apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/index.tsx b/apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/index.tsx index 979c49913d..6edebb05e2 100644 --- a/apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/index.tsx +++ b/apps/cowswap-frontend/src/modules/trade/pure/TradeConfirmation/index.tsx @@ -9,6 +9,7 @@ import { CustomRecipientWarningBanner, LongLoadText, } from '@cowprotocol/ui' +import { Currency, CurrencyAmount } from '@uniswap/sdk-core' import { Trans } from '@lingui/macro' import ms from 'ms.macro' @@ -49,6 +50,9 @@ export interface TradeConfirmationProps { recipient?: string | null buttonText?: React.ReactNode children?: (restContent: ReactElement) => ReactElement + slippageAdjustedSellAmount?: CurrencyAmount + isPartialApprove?: boolean + displayHookDetails?: boolean } export function TradeConfirmation(props: TradeConfirmationProps) { @@ -76,6 +80,9 @@ export function TradeConfirmation(props: TradeConfirmationProps) { recipient, isPriceStatic, appData, + isPartialApprove, + slippageAdjustedSellAmount, + displayHookDetails, } = frozenProps || props /** @@ -126,15 +133,20 @@ export function TradeConfirmation(props: TradeConfirmationProps) { onConfirm() } - const hookDetailsElement = ( + const hookDetailsElement = displayHookDetails ? ( <> {appData && ( - + {(hookChildren) => hookChildren} )} - ) + ) : null return ( e.key === 'Escape' && onDismiss()}> diff --git a/apps/cowswap-frontend/src/modules/tradeFlow/hooks/useTradeFlowContext.ts b/apps/cowswap-frontend/src/modules/tradeFlow/hooks/useTradeFlowContext.ts index fafa6e7594..945cf69e0f 100644 --- a/apps/cowswap-frontend/src/modules/tradeFlow/hooks/useTradeFlowContext.ts +++ b/apps/cowswap-frontend/src/modules/tradeFlow/hooks/useTradeFlowContext.ts @@ -28,9 +28,10 @@ import { TradeFlowContext } from '../types/TradeFlowContext' export interface TradeFlowParams { deadline: number + isPartialApprove?: boolean } -export function useTradeFlowContext({ deadline }: TradeFlowParams): TradeFlowContext | null { +export function useTradeFlowContext({ deadline, isPartialApprove }: TradeFlowParams): TradeFlowContext | null { const { chainId, account } = useWalletInfo() const provider = useWalletProvider() const { allowsOffchainSigning } = useWalletDetails() @@ -49,7 +50,7 @@ export function useTradeFlowContext({ deadline }: TradeFlowParams): TradeFlowCon const networkFee = receiveAmountInfo?.costs.networkFee.amountInSellCurrency const permitInfo = usePermitInfo(sellCurrency, tradeType) - const generatePermitHook = useGeneratePermitHook() + const generatePermitHook = useGeneratePermitHook(isPartialApprove) const getCachedPermit = useGetCachedPermit() const closeModals = useCloseModals() const dispatch = useDispatch() @@ -122,6 +123,7 @@ export function useTradeFlowContext({ deadline }: TradeFlowParams): TradeFlowCon deadline, orderKind, uiOrderType, + isPartialApprove, ] : null, ([ @@ -152,6 +154,7 @@ export function useTradeFlowContext({ deadline }: TradeFlowParams): TradeFlowCon deadline, orderKind, uiOrderType, + isPartialApprove, ]) => { return { context: { @@ -162,6 +165,7 @@ export function useTradeFlowContext({ deadline }: TradeFlowParams): TradeFlowCon }, flags: { allowsOffchainSigning, + isPartialApprove, }, callbacks: { closeModals, diff --git a/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleApprovalFlow.ts b/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleApprovalFlow.ts index cae474c566..eafae28aff 100644 --- a/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleApprovalFlow.ts +++ b/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleApprovalFlow.ts @@ -33,7 +33,15 @@ export async function safeBundleApprovalFlow( return false } - const { context, callbacks, orderParams, swapFlowAnalyticsContext, tradeConfirmActions, typedHooks } = tradeContext + const { + context, + callbacks, + orderParams, + swapFlowAnalyticsContext, + tradeConfirmActions, + typedHooks, + flags: { isPartialApprove }, + } = tradeContext const { spender, settlementContract, safeAppsSdk, erc20Contract } = safeBundleContext @@ -52,6 +60,7 @@ export async function safeBundleApprovalFlow( erc20Contract, spender, amountToApprove: context.inputAmount, + isPartialApprove, }) orderParams.appData = await removePermitHookFromAppData(orderParams.appData, typedHooks) diff --git a/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleEthFlow.ts b/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleEthFlow.ts index 0247b09d84..c9b09bd002 100644 --- a/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleEthFlow.ts +++ b/apps/cowswap-frontend/src/modules/tradeFlow/services/safeBundleFlow/safeBundleEthFlow.ts @@ -35,7 +35,14 @@ export async function safeBundleEthFlow( return false } - const { context, callbacks, swapFlowAnalyticsContext, tradeConfirmActions, typedHooks } = tradeContext + const { + context, + callbacks, + swapFlowAnalyticsContext, + tradeConfirmActions, + typedHooks, + flags: { isPartialApprove }, + } = tradeContext const { spender, settlementContract, safeAppsSdk, needsApproval, wrappedNativeContract } = safeBundleContext @@ -73,6 +80,7 @@ export async function safeBundleEthFlow( erc20Contract: wrappedNativeContract as unknown as Erc20, spender, amountToApprove: inputAmount, + isPartialApprove, }) txs.push({ diff --git a/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts b/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts index d26e22b9d4..1a8589d56c 100644 --- a/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts +++ b/apps/cowswap-frontend/src/modules/tradeFlow/services/swapFlow/index.ts @@ -59,6 +59,7 @@ export async function swapFlow( inputToken: inputCurrency, permitInfo, generatePermitHook, + amount: BigInt(inputAmount.quotient.toString()), }) if (callDataContainsPermitSigner(orderParams.appData.fullAppData)) { diff --git a/apps/cowswap-frontend/src/modules/tradeFlow/types/TradeFlowContext.ts b/apps/cowswap-frontend/src/modules/tradeFlow/types/TradeFlowContext.ts index 16f6dc07b8..c129b37df6 100644 --- a/apps/cowswap-frontend/src/modules/tradeFlow/types/TradeFlowContext.ts +++ b/apps/cowswap-frontend/src/modules/tradeFlow/types/TradeFlowContext.ts @@ -26,6 +26,7 @@ export interface TradeFlowContext { } flags: { allowsOffchainSigning: boolean + isPartialApprove?: boolean } callbacks: { closeModals: Command diff --git a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/SettingsTab/index.tsx b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/SettingsTab/index.tsx index fc7273935d..7090446fdb 100644 --- a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/SettingsTab/index.tsx +++ b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/SettingsTab/index.tsx @@ -27,12 +27,20 @@ interface SettingsTabProps { className?: string recipientToggleState: StatefulValue hooksEnabledState?: StatefulValue + partialApproveState?: StatefulValue deadlineState: StatefulValue } -export function SettingsTab({ className, recipientToggleState, hooksEnabledState, deadlineState }: SettingsTabProps) { +export function SettingsTab({ + className, + recipientToggleState, + hooksEnabledState, + deadlineState, + partialApproveState, +}: SettingsTabProps) { const menuButtonRef = useRef(null) + const [isPartialApprove, setPartialApprove] = partialApproveState || [null, null] const [recipientToggleVisible, toggleRecipientVisibilityAux] = recipientToggleState const toggleRecipientVisibility = useCallback( (value?: boolean) => { @@ -104,7 +112,7 @@ export function SettingsTab({ className, recipientToggleState, hooksEnabledState Experimental: {' '} - Add DeFI interactions before and after your trade + Add DeFI interactions before and after your trade. } /> @@ -112,6 +120,35 @@ export function SettingsTab({ className, recipientToggleState, hooksEnabledState )} + + {isPartialApprove !== null && setPartialApprove && ( + + + + Minimal Approvals + + + By default, token approvals & permits are for an unlimited amount, which ensures you don't pay extra for subsequent trades. +
+
+ When this setting is enabled, approvals & permits will be for the minimum amount instead of unlimited. + This incurs additional costs on every trade. +
+
+ Existing approvals must be revoked manually before you can re-approve. + + } + /> +
+ setPartialApprove(!isPartialApprove)} + /> +
+ )}
diff --git a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowSlippageContent/index.tsx b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowSlippageContent/index.tsx index 741bc17006..4a9c0ec9d9 100644 --- a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowSlippageContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/pure/Row/RowSlippageContent/index.tsx @@ -1,4 +1,3 @@ -import { useSetAtom } from 'jotai' import { SupportedChainId } from '@cowprotocol/cow-sdk' import { Command } from '@cowprotocol/types' @@ -10,7 +9,7 @@ import styled from 'styled-components/macro' import { getNativeSlippageTooltip, getNonNativeSlippageTooltip } from 'common/utils/tradeSettingsTooltips' -import { settingsTabStateAtom } from '../../../state/settingsTabState' +import { useOpenSettingsTab } from '../../../state/settingsTabState' import { RowStyleProps, StyledInfoIcon, StyledRowBetween, TextWrapper, TransactionText } from '../styled' const DefaultSlippage = styled.span` @@ -65,9 +64,7 @@ export function RowSlippageContent(props: RowSlippageContentProps) { isSmartSlippageLoading, } = props - const setSettingTabState = useSetAtom(settingsTabStateAtom) - - const openSettings = () => setSettingTabState({ open: true }) + const openSettings = useOpenSettingsTab() const tooltipContent = slippageTooltip || diff --git a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/state/settingsTabState.ts b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/state/settingsTabState.ts index 659fb6321f..61b3e2561a 100644 --- a/apps/cowswap-frontend/src/modules/tradeWidgetAddons/state/settingsTabState.ts +++ b/apps/cowswap-frontend/src/modules/tradeWidgetAddons/state/settingsTabState.ts @@ -1,3 +1,9 @@ -import { atom } from 'jotai' +import { atom, useSetAtom } from 'jotai' export const settingsTabStateAtom = atom({ open: false }) + +export function useOpenSettingsTab() { + const setSettingTabState = useSetAtom(settingsTabStateAtom) + + return () => setSettingTabState({ open: true }) +} diff --git a/apps/cowswap-frontend/src/pages/Swap/index.tsx b/apps/cowswap-frontend/src/pages/Swap/index.tsx index d672663c10..c90c727003 100644 --- a/apps/cowswap-frontend/src/pages/Swap/index.tsx +++ b/apps/cowswap-frontend/src/pages/Swap/index.tsx @@ -5,13 +5,16 @@ import { useWalletInfo } from '@cowprotocol/wallet' import { Navigate, useLocation, useParams } from 'react-router-dom' import { SwapUpdaters, SwapWidget } from 'modules/swap' +import { PartialApprovalBanner } from 'modules/swap/pure/banners/PartialApprovalBanner' import { getDefaultTradeRawState } from 'modules/trade/types/TradeRawState' import { parameterizeTradeRoute } from 'modules/trade/utils/parameterizeTradeRoute' +import { useOpenSettingsTab } from 'modules/tradeWidgetAddons/state/settingsTabState' import { Routes } from 'common/constants/routes' export function SwapPage() { const params = useParams() + const openSettings = useOpenSettingsTab() if (!params.chainId) { return @@ -20,7 +23,7 @@ export function SwapPage() { return ( <> - + } /> ) } diff --git a/apps/cowswap-frontend/src/utils/orderUtils/getOrderPermitAmount.ts b/apps/cowswap-frontend/src/utils/orderUtils/getOrderPermitAmount.ts index d579add099..ab4c3d7c8f 100644 --- a/apps/cowswap-frontend/src/utils/orderUtils/getOrderPermitAmount.ts +++ b/apps/cowswap-frontend/src/utils/orderUtils/getOrderPermitAmount.ts @@ -1,11 +1,10 @@ -import { Erc20__factory } from '@cowprotocol/abis' import type { LatestAppDataDocVersion } from '@cowprotocol/app-data' import { COW_PROTOCOL_VAULT_RELAYER_ADDRESS, SupportedChainId } from '@cowprotocol/cow-sdk' import { BigNumber } from '@ethersproject/bignumber' -import { ParsedOrder } from './parseOrder' +import { parsePermitData } from 'common/utils/parsePermitData' -const erc20Interface = Erc20__factory.createInterface() +import { ParsedOrder } from './parseOrder' export function getOrderPermitAmount(chainId: SupportedChainId, order: ParsedOrder): BigNumber | null { if (!order.fullAppData) return null @@ -20,7 +19,7 @@ export function getOrderPermitAmount(chainId: SupportedChainId, order: ParsedOrd const permitData = preHooks .map((hook) => { try { - return erc20Interface.decodeFunctionData('permit', hook.callData) + return parsePermitData(hook.callData) } catch { return null } diff --git a/libs/hook-dapp-lib/src/hookDappsRegistry.json b/libs/hook-dapp-lib/src/hookDappsRegistry.json index 4ce8c5e3e2..7f973f7768 100644 --- a/libs/hook-dapp-lib/src/hookDappsRegistry.json +++ b/libs/hook-dapp-lib/src/hookDappsRegistry.json @@ -24,7 +24,7 @@ "PERMIT_TOKEN": { "type": "INTERNAL", "name": "Permit a token", - "descriptionShort": "Infinite permit an address to spend one token on your behalf.", + "descriptionShort": "Permit an address to spend one token on your behalf.", "description": "This hook allows you to permit an address to spend your tokens on your behalf. This is useful for allowing a smart contract to spend your tokens without needing to approve each transaction.", "image": "https://raw.githubusercontent.com/cowprotocol/cowswap/refs/heads/develop/apps/cowswap-frontend/src/modules/hooksStore/dapps/PermitHookApp/icon.png", "version": "v0.1.0", diff --git a/libs/permit-utils/src/lib/generatePermitHook.ts b/libs/permit-utils/src/lib/generatePermitHook.ts index e9f2e32675..f121a67d83 100644 --- a/libs/permit-utils/src/lib/generatePermitHook.ts +++ b/libs/permit-utils/src/lib/generatePermitHook.ts @@ -37,7 +37,17 @@ export async function generatePermitHook(params: PermitHookParams): Promise { - const { inputToken, spender, chainId, permitInfo, provider, account, eip2162Utils, nonce: preFetchedNonce } = params + const { + inputToken, + spender, + chainId, + permitInfo, + provider, + account, + eip2162Utils, + nonce: preFetchedNonce, + amount, + } = params const tokenAddress = inputToken.address // TODO: remove the need for `name` from input token. Should come from permitInfo instead @@ -58,7 +68,7 @@ async function generatePermitHookRaw(params: PermitHookParams): Promise