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(permit): refactor permit caching #3183

Merged
merged 11 commits into from
Oct 5, 2023
1 change: 1 addition & 0 deletions apps/cowswap-frontend/src/mocks/tradeStateMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const outputCurrencyInfoMock: CurrencyInfo = {

export const tradeContextMock: TradeFlowContext = {
permitInfo: undefined,
generatePermitHook: (() => void 0) as any,
postOrderParams: {
class: OrderClass.LIMIT,
account: '0x000',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useAppData } from 'modules/appData'
import { useRateImpact } from 'modules/limitOrders/hooks/useRateImpact'
import { TradeFlowContext } from 'modules/limitOrders/services/types'
import { limitOrdersSettingsAtom } from 'modules/limitOrders/state/limitOrdersSettingsAtom'
import { useIsTokenPermittable } from 'modules/permit'
import { useGeneratePermitHook, useIsTokenPermittable } from 'modules/permit'
import { useEnoughBalanceAndAllowance } from 'modules/tokens'
import { TradeType } from 'modules/trade'
import { useTradeQuote } from 'modules/tradeQuote'
Expand All @@ -42,6 +42,7 @@ export function useTradeFlowContext(): TradeFlowContext | null {
amount: state.slippageAdjustedSellAmount || undefined,
checkAllowanceAddress,
})
const generatePermitHook = useGeneratePermitHook()

if (
!chainId ||
Expand Down Expand Up @@ -76,6 +77,7 @@ export function useTradeFlowContext(): TradeFlowContext | null {
provider,
rateImpact,
permitInfo: !enoughAllowance ? permitInfo : undefined,
generatePermitHook,
postOrderParams: {
class: OrderClass.LIMIT,
kind: state.orderKind,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const outputCurrency = GNO[SupportedChainId.MAINNET]

const tradeContext: TradeFlowContext = {
permitInfo: undefined,
generatePermitHook: () => Promise.resolve(undefined),
postOrderParams: {
class: OrderClass.LIMIT,
account: '0x000',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export async function tradeFlow(
settlementContract,
dispatch,
isGnosisSafeWallet,
generatePermitHook,
} = params
const { account, recipientAddressOrName, sellToken, buyToken, appData } = postOrderParams
const marketLabel = [sellToken.symbol, buyToken.symbol].join(',')
Expand Down Expand Up @@ -61,10 +62,9 @@ export async function tradeFlow(
postOrderParams.appData = await handlePermit({
permitInfo,
inputToken: sellToken,
provider,
account,
chainId,
appData,
generatePermitHook,
})

logTradeFlow('LIMIT ORDER FLOW', 'STEP 3: send transaction')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import SafeAppsSDK from '@safe-global/safe-apps-sdk'
import { AppDispatch } from 'legacy/state'
import { PostOrderParams } from 'legacy/utils/trade'

import { IsTokenPermittableResult } from 'modules/permit'
import { GeneratePermitHook, IsTokenPermittableResult } from 'modules/permit'

export interface TradeFlowContext {
// signer changes creates redundant re-renders
Expand All @@ -20,6 +20,7 @@ export interface TradeFlowContext {
allowsOffchainSigning: boolean
isGnosisSafeWallet: boolean
permitInfo: IsTokenPermittableResult
generatePermitHook: GeneratePermitHook
}

export interface SafeBundleFlowContext extends TradeFlowContext {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import { useEffect, useState } from 'react'

import { useWalletInfo } from '@cowprotocol/wallet'
import { Token } from '@uniswap/sdk-core'
import { useWeb3React } from '@web3-react/core'

import { useDerivedTradeState } from 'modules/trade'

import { useSafeMemo } from 'common/hooks/useSafeMemo'

import { useGeneratePermitHook } from './useGeneratePermitHook'
import { useIsTokenPermittable } from './useIsTokenPermittable'

import { PermitHookData, PermitHookParams } from '../types'
import { generatePermitHook } from '../utils/generatePermitHook'
import { GeneratePermitHookParams, PermitHookData } from '../types'

/**
* Returns PermitHookData using an account agnostic signer if inputCurrency is permittable
Expand All @@ -21,7 +19,8 @@ import { generatePermitHook } from '../utils/generatePermitHook'
* If not permittable or not able to tell, returns undefined
*/
export function useAccountAgnosticPermitHookData(): PermitHookData | undefined {
const params = usePermitHookParams()
const params = useGeneratePermitHookParams()
const generatePermitHook = useGeneratePermitHook()

const [data, setData] = useState<PermitHookData | undefined>(undefined)

Expand All @@ -33,28 +32,23 @@ export function useAccountAgnosticPermitHookData(): PermitHookData | undefined {
}

generatePermitHook(params).then(setData)
}, [params])
}, [generatePermitHook, params])

return data
}

function usePermitHookParams(): PermitHookParams | undefined {
const { chainId } = useWalletInfo()
const { provider } = useWeb3React()

function useGeneratePermitHookParams(): GeneratePermitHookParams | undefined {
const { state } = useDerivedTradeState()
const { inputCurrency, tradeType } = state || {}

const permitInfo = useIsTokenPermittable(inputCurrency, tradeType)

return useSafeMemo(() => {
if (!inputCurrency || !provider || !permitInfo) return undefined
if (!inputCurrency || !permitInfo) return undefined

return {
chainId,
provider,
inputToken: inputCurrency as Token,
permitInfo,
}
}, [inputCurrency, provider, permitInfo, chainId])
}, [inputCurrency, permitInfo])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useSetAtom } from 'jotai'
import { useCallback } from 'react'

import { useWalletInfo } from '@cowprotocol/wallet'
import { useWeb3React } from '@web3-react/core'

import { getPermitCacheAtom, storePermitCacheAtom } from '../state/permitCacheAtom'
import { GeneratePermitHook, GeneratePermitHookParams, PermitHookData } from '../types'
import { generatePermitHook } from '../utils/generatePermitHook'
import { getPermitUtilsInstance } from '../utils/getPermitUtilsInstance'

/**
* Hook that returns callback to generate permit hook data
*/
export function useGeneratePermitHook(): GeneratePermitHook {
const getCachedPermit = useSetAtom(getPermitCacheAtom)
const storePermit = useSetAtom(storePermitCacheAtom)
const { chainId } = useWalletInfo()
const { provider } = useWeb3React()

return useCallback(
async (params: GeneratePermitHookParams): Promise<PermitHookData | undefined> => {
const { inputToken, account, permitInfo } = params

if (!provider) {
return
}

const eip2162Utils = getPermitUtilsInstance(chainId, provider, account)

// 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 cachedPermit = getCachedPermit(permitParams)

if (cachedPermit) {
return cachedPermit
}

const hookData = await generatePermitHook({
chainId,
inputToken,
provider,
permitInfo,
eip2162Utils,
account,
nonce,
})

storePermit({ ...permitParams, hookData })

return hookData
},
[storePermit, chainId, getCachedPermit, provider]
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { TradeType } from 'modules/trade'
import { useIsPermitEnabled } from 'common/hooks/featureFlags/useIsPermitEnabled'

import { ORDER_TYPE_SUPPORTS_PERMIT } from '../const'
import { addPermitInfoForTokenAtom, permittableTokensAtom } from '../state/atoms'
import { addPermitInfoForTokenAtom, permittableTokensAtom } from '../state/permittableTokensAtom'
import { IsTokenPermittableResult } from '../types'
import { checkIsTokenPermittable } from '../utils/checkIsTokenPermittable'

Expand Down
6 changes: 3 additions & 3 deletions apps/cowswap-frontend/src/modules/permit/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { useAccountAgnosticPermitHookData } from './hooks/useAccountAgnosticPermitHookData'
export { generatePermitHook } from './utils/generatePermitHook'
export { useIsTokenPermittable } from './hooks/useIsTokenPermittable'
export * from './hooks/useAccountAgnosticPermitHookData'
export * from './hooks/useIsTokenPermittable'
export * from './hooks/useGeneratePermitHook'
export * from './utils/handlePermit'
export * from './types'
106 changes: 106 additions & 0 deletions apps/cowswap-frontend/src/modules/permit/state/permitCacheAtom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

import {
CachedPermitData,
GetPermitCacheParams,
PermitCache,
PermitCacheKeyParams,
StorePermitCacheParams,
} from '../types'

/**
* Atom that stores permit data for static permit requests.
* Should never change once it has been created.
* Used exclusively for quote requests
*/
export const staticPermitCacheAtom = atomWithStorage<PermitCache>('staticPermitCache:v0', {})

/**
* 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<PermitCache>('userPermitCache:v0', {})

/**
* Atom to add/update permit cache data
*
* Input depends on the target type of cache: static or user
*/
export const storePermitCacheAtom = atom(null, (get, set, params: StorePermitCacheParams) => {
const atomToUpdate = params.account ? userPermitCacheAtom : staticPermitCacheAtom

const key = buildKey(params)

const dataToCache: CachedPermitData = {
hookData: params.hookData,
nonce: params.nonce,
}

set(atomToUpdate, (permitCache) => ({ ...permitCache, [key]: JSON.stringify(dataToCache) }))
})

/**
* Atom to get the cached permit data.
*
* Returns either undefined when no cache or cache is outdated, or the cached permit hook data.
*
* When cache is outdated, it will remove the cache key from the target cache.
* For this reason it's a writable atom.
*/
export const getPermitCacheAtom = atom(null, (get, set, params: GetPermitCacheParams) => {
const atomToUpdate = params.account ? userPermitCacheAtom : staticPermitCacheAtom

const permitCache = get(atomToUpdate)
const key = buildKey(params)
const cachedData = permitCache[key]

if (!cachedData) {
return undefined
}

try {
const { hookData, nonce: storedNonce }: CachedPermitData = JSON.parse(cachedData)

if (params.account !== undefined) {
// User type permit cache, check the nonce

const inputNonce = params.nonce

if (storedNonce !== undefined && inputNonce !== undefined && storedNonce < inputNonce) {
// When both nonces exist and storedNonce < inputNonce, data is outdated

// Remove cache key
set(atomToUpdate, removePermitCacheBuilder(key))

return undefined
}
}

// Cache hit for both static and user permit types
return hookData
} catch (e) {
// Failed to parse stored data, clear cache and return nothing

set(atomToUpdate, removePermitCacheBuilder(key))

console.info(`[getPermitCacheAtom] failed to parse stored data`, cachedData, e)

return undefined
}
})

function buildKey({ chainId, tokenAddress, account }: PermitCacheKeyParams) {
const base = `${chainId}-${tokenAddress.toLowerCase()}`

return account ? `${base}-${account.toLowerCase()}` : base
}

const removePermitCacheBuilder = (key: string) => (permitCache: PermitCache) => {
const newPermitCache = { ...permitCache }

delete newPermitCache[key]

return newPermitCache
}
29 changes: 27 additions & 2 deletions apps/cowswap-frontend/src/modules/permit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@ export type PermitHookParams = {
chainId: SupportedChainId
permitInfo: SupportedPermitInfo
provider: Web3Provider
account?: string
eip2162Utils: Eip2612PermitUtils
account?: string | undefined
nonce?: number | undefined
}

export type HandlePermitParams = Omit<PermitHookParams, 'permitInfo'> & {
export type GeneratePermitHookParams = Pick<PermitHookParams, 'inputToken' | 'permitInfo' | 'account'>

export type GeneratePermitHook = (params: GeneratePermitHookParams) => Promise<PermitHookData | undefined>

export type HandlePermitParams = Omit<GeneratePermitHookParams, 'permitInfo'> & {
permitInfo: IsTokenPermittableResult
appData: AppDataInfo
generatePermitHook: GeneratePermitHook
}

export type PermitHookData = latest.CoWHook
Expand Down Expand Up @@ -67,3 +74,21 @@ export type CheckIsTokenPermittableParams = {
chainId: SupportedChainId
provider: Web3Provider
}

export type PermitCache = Record<string, string>

export type CachedPermitData = {
hookData: PermitHookData
nonce: number | undefined
}

export type PermitCacheKeyParams = {
chainId: SupportedChainId
tokenAddress: string
account: string | undefined
nonce: number | undefined
}

export type StorePermitCacheParams = PermitCacheKeyParams & { hookData: PermitHookData }

export type GetPermitCacheParams = PermitCacheKeyParams
Loading
Loading