Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(swap): partial approve #5256

Open
wants to merge 21 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8c617cc
feat(swap): add settings option for partial approve
shoom3301 Dec 24, 2024
9cf72b4
feat(swap): add sell amount to regular approve tx
shoom3301 Dec 24, 2024
a017d48
feat(swap): add sell amount to permit value
shoom3301 Dec 24, 2024
40a4ba8
chore: fix build
shoom3301 Dec 24, 2024
2cbf15a
fix: cache permit taking amount into account
shoom3301 Dec 25, 2024
4573c4d
feat(swap): take partial approves into account for sc wallets
shoom3301 Dec 25, 2024
0c63feb
Merge branch 'develop' into feat/partial-approve
shoom3301 Dec 25, 2024
7d412c5
fix: ignore account agnostic permit in hooks details
shoom3301 Dec 25, 2024
70b26a8
fix: take permit amount into account when caching
shoom3301 Dec 25, 2024
0fcb1f8
Merge branch 'feat/partial-approve' of https://github.com/cowprotocol…
shoom3301 Dec 25, 2024
ba8e237
fix: skip partial permits in widgets besides swap
shoom3301 Dec 25, 2024
21e735b
chore: fix permit hook description
shoom3301 Dec 25, 2024
89b45ba
chore: fix conditions
shoom3301 Dec 25, 2024
aa42dfc
fix: disable partial approve for Hooks store
shoom3301 Dec 26, 2024
98be3cd
fix: support partial approve it classic eth flow
shoom3301 Dec 26, 2024
8e72b4c
fix: do not use infinite approvals in swap when partial approve mode
shoom3301 Dec 26, 2024
3b13aec
chore: fix circular dependency
shoom3301 Dec 26, 2024
6c9b62e
chore: add a dot
shoom3301 Dec 26, 2024
f3764c5
chore: fix tooltips
shoom3301 Dec 26, 2024
d33d835
chore: adjust approve tooltip
shoom3301 Dec 26, 2024
70f29e3
fix: display hook details only in Hooks store confirm modal
shoom3301 Dec 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 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
74 changes: 4 additions & 70 deletions apps/cowswap-frontend/src/lib/hooks/useApproval.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
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'

import { useTokenAllowance } from 'legacy/hooks/useTokenAllowance'

import { ApprovalState } from 'common/hooks/useApproveState'
import { useTokenContract } from 'common/hooks/useContract'

export interface ApprovalStateForSpenderResult {
approvalState: ApprovalState
Expand All @@ -22,7 +19,7 @@ function toApprovalState(
amountToApprove: Nullish<CurrencyAmount<Currency>>,
spender: string | undefined,
currentAllowance?: CurrencyAmount<Token>,
pendingApproval?: boolean
pendingApproval?: boolean,
): ApprovalState {
// Unknown amount or spender
if (!amountToApprove || !spender) {
Expand Down Expand Up @@ -50,7 +47,7 @@ function toApprovalState(
export function useApprovalStateForSpender(
amountToApprove: Nullish<CurrencyAmount<Currency>>,
spender: string | undefined,
useIsPendingApproval: (token?: Token, spender?: string) => boolean
useIsPendingApproval: (token?: Token, spender?: string) => boolean,
): ApprovalStateForSpenderResult {
const { account } = useWalletInfo()
const currency = amountToApprove?.currency
Expand All @@ -64,66 +61,3 @@ export function useApprovalStateForSpender(
return { approvalState, currentAllowance }
}, [amountToApprove, currentAllowance, pendingApproval, spender])
}

export function useApproval(
amountToApprove: CurrencyAmount<Currency> | 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]
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@ export type BuildApproveTxParams = {
erc20Contract: Erc20
spender: string
amountToApprove: CurrencyAmount<Currency>
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)
}
Loading
Loading