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

Merged
merged 23 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
23 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
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
7456ae1
Merge branch 'develop' into feat/partial-approve
shoom3301 Dec 25, 2024
0f9afb1
Merge branch 'feat/partial-approve' of https://github.com/cowprotocol…
shoom3301 Dec 25, 2024
6e8ab09
fix: skip partial permits in widgets besides swap
shoom3301 Dec 25, 2024
fed5b99
chore: fix permit hook description
shoom3301 Dec 25, 2024
a7746a0
chore: fix conditions
shoom3301 Dec 25, 2024
f0a55ea
fix: disable partial approve for Hooks store
shoom3301 Dec 26, 2024
aa86013
fix: support partial approve it classic eth flow
shoom3301 Dec 26, 2024
92a8253
fix: do not use infinite approvals in swap when partial approve mode
shoom3301 Dec 26, 2024
ba3e996
chore: fix circular dependency
shoom3301 Dec 26, 2024
e188b2b
chore: add a dot
shoom3301 Dec 26, 2024
9438a4d
chore: fix tooltips
shoom3301 Dec 26, 2024
5098421
chore: adjust approve tooltip
shoom3301 Dec 26, 2024
11c6863
fix: display hook details only in Hooks store confirm modal
shoom3301 Dec 26, 2024
05a723f
feat(partial-approvals): partial approve v2 (#5269)
alfetopito Jan 8, 2025
da14347
Merge branch 'develop' into feat/partial-approve
alfetopito Jan 8, 2025
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
}
Comment on lines +75 to +77
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would this throw?

})
: 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