Skip to content

Commit

Permalink
feat(swap): partial approve (#5256)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
shoom3301 and alfetopito authored Jan 8, 2025
1 parent 198231f commit f080ffd
Show file tree
Hide file tree
Showing 43 changed files with 350 additions and 149 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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<Currency>
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])
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ export interface TradeApproveButtonProps {
amountToApprove: CurrencyAmount<Currency>
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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ export interface TradeApproveCallback {
(params?: TradeApproveCallbackParams): Promise<TransactionResponse | undefined>
}

export function useTradeApproveCallback(amountToApprove?: CurrencyAmount<Currency>): TradeApproveCallback {
export function useTradeApproveCallback(
amountToApprove?: CurrencyAmount<Currency>,
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 }) => {
Expand Down Expand Up @@ -58,6 +61,6 @@ export function useTradeApproveCallback(amountToApprove?: CurrencyAmount<Currenc
return undefined
})
},
[symbol, approveCallback, updateTradeApproveState, currency]
[symbol, approveCallback, updateTradeApproveState, currency],
)
}
15 changes: 10 additions & 5 deletions apps/cowswap-frontend/src/common/hooks/useApproveCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,18 @@ export async function estimateApprove(
tokenContract: Erc20,
spender: string,
amountToApprove: CurrencyAmount<Currency>,
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).
Expand All @@ -45,7 +49,7 @@ export async function estimateApprove(
)

return {
approveAmount: MaxUint256,
approveAmount,
gasLimit: GAS_LIMIT_DEFAULT,
}
}
Expand All @@ -55,6 +59,7 @@ export async function estimateApprove(
export function useApproveCallback(
amountToApprove?: CurrencyAmount<Currency>,
spender?: string,
isPartialApprove?: boolean,
): (summary?: string) => Promise<TransactionResponse | undefined> {
const { chainId } = useWalletInfo()
const currency = amountToApprove?.currency
Expand All @@ -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),
Expand All @@ -81,5 +86,5 @@ export function useApproveCallback(
})
return response
})
}, [chainId, token, tokenContract, amountToApprove, spender, addTransaction])
}, [chainId, token, tokenContract, amountToApprove, spender, addTransaction, isPartialApprove])
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export function ApproveButton(props: ApproveButtonProps) {
content={
<Trans>
You must give the CoW Protocol smart contracts permission to use your <TokenSymbol token={currency} />.
If you approve the default amount, you will only have to do this once per token.
</Trans>
}
>
Expand Down
15 changes: 12 additions & 3 deletions apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1123,14 +1132,14 @@ function ExpiredStep(props: OrderProgressBarV2Props) {
<h3>The good news</h3>
<p>
Unlike on other exchanges, you won't be charged for this! Feel free to{' '}
<styledEl.Button
<UnderlinedLinkStyledButton
onClick={() => {
props.navigateToNewOrder?.()
trackNewOrderClick()
}}
>
place a new order
</styledEl.Button>{' '}
</UnderlinedLinkStyledButton>{' '}
without worry.
</p>
</styledEl.InfoCard>
Expand Down
12 changes: 2 additions & 10 deletions apps/cowswap-frontend/src/common/pure/OrderProgressBarV2/styled.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'};
Expand Down
15 changes: 15 additions & 0 deletions apps/cowswap-frontend/src/common/utils/parsePermitData.ts
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions apps/cowswap-frontend/src/legacy/state/user/hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Expand Down
7 changes: 6 additions & 1 deletion apps/cowswap-frontend/src/legacy/state/user/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
Expand All @@ -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
},
Expand All @@ -70,5 +74,6 @@ export const {
updateUserDeadline,
updateUserLocale,
updateRecipientToggleVisible,
updatePartialApprove,
} = userSlice.actions
export default userSlice.reducer
Loading

0 comments on commit f080ffd

Please sign in to comment.