From 97887c52eafddaee919c0cd666149ff2559de2d5 Mon Sep 17 00:00:00 2001 From: Leandro Boscariol Date: Fri, 1 Jul 2022 12:58:16 -0700 Subject: [PATCH] 332/Store slippage info on appData (#629) * Bumped latest cow-sdk version * Creating cow-sdk instances for all networks * Removed redundant import * Updated .env files with now mandatory env var for IPFS uploading * Added/moved appCode related consts to constants file * New hook useAppCode * Added appData/atoms using Jotai WARNING! Still needs refactoring * New utils for dealing with appData * New hook useAppData * Adding newly calculated appData to order and storing it in the to-be-uploaded queue * Added appData/updater WARNING! Still needs refactoring * Disabling affiliate data IPFS upload; will be handled on every order WARNING! Needs to review if affiliate flow is still working as before * Removing helper files no longer in use Most have been replaced by the correspondent sdk methods * Refactored state/appData/types * Refactored state/appData/atoms * Refactored state/AppData/hooks * Updater state/appData/updater does not need to be tsx file * Added state/appData/utils to handle key creation/parsing * Refactored upload queue to a flat object rather than nested by network * Removing debug loggs from state/appData/atoms * Refactored state/appData/updater * `environment` is now part of the appData rather than a metadata * Forcing all inputs in an attempt to fix cypress occasional failures * Refactor: removed redundant initial state on state/appData/atoms * Refactor: improved logging in case of appDataHash generation * 332/exponential back off (#726) * Updated stored types to contain lastAttempt rather than tryAfter * New helper function to check when we can try to upload to ipfs again * Improved logging for ipfs upload updater * Changed soft upload failures log level from debug to warn * Refactor: extracted helper function _actuallyUploadToIpfs * Refactor: Renamed BASE_TIME_BETWEEN_ATTEMPTS to BASE_FOR_EXPONENTIAL_BACKOFF * 332/update affiliate flow (#647) * Updated state/affiliate - Removed no longer needed state (appDataHash) - Added new status referralAddress.isActive - Updated associated actions, hooks and reducer - Updated AffiliateStatusCheck to use new state * Updated hooks/useAppData to use new state/affiliate state * Updated useEffect deps to prevent unecessary re-renders * Refactor: removed redundant variable * Fixed issue where invalid referral would not be tagged as so * 332/warning when pinata envs not set locally (#659) * Added `localWarning` for PINATA keys * Displaying localWarning if any * Added alternative warning display: As a permanent toast notification * Refactor: Renamed WarningPopupContent `message` to `warning` * Added warning icon to toast notification * Moved localWarning from Header to state/application * Changed warning popup key to a more generic value * Removed banner with warning in favor of the popup notification * 332/quote id on metadata (#750) * Ignore quoteId when checking if the order is unfillable * Persiste quoteId from api to redux state * Add quoteId to GpTrade class * Pass quoteId down to appData * Include quoteId on order placement * Bumped cow-sdk to 0.0.15-RC.0 * Refactor: Replaced map upload queue with arrary (#747) * Refactor: Replaced map upload queue with arrary * Refactor: Using slice(0) to clone array instead of spread operator * Refactor: using Array.some instead of Array.find As I do not need to the stored element * 332/slippage bips rather than amounts on metadata (#758) * Bumop quote metadata version * Added helper function to transfor Percent instances to bip string * Refactored appData utils functions to use new quote metadata schema Also changed the fn signature to accomodate different options if needed * Updated useAppData to use slippage in the quote metadata * Refactored useAppData interface * Removed code that is not related to slippageBips for quote metadata * Actually, _buildQuoteMetadata will never return undefined * 332/upload right away (#767) * Increased upload to IPFS queue check interval to 1m * Try to upload docs added to the upload queue right away * 332/refactor use address (#748) * Refactor: renamed useAddress to useAffiliateAddress * Typo fix on comment * Fixed issue with affiliate not valid displayed when there was no affiliate * Refeset affiliate state whenever error is reset * Referral address cannot be null, but undefined :facepalm: --- .env | 4 + .env.production | 4 + cypress-custom/integration/swapMod.ts | 12 +- package.json | 2 +- src/custom/api/ipfs/index.ts | 37 ------ .../components/AffiliateStatusCheck/index.tsx | 82 +++++++----- .../components/Header/URLWarning/index.tsx | 1 + src/custom/components/Popups/PopupItemMod.tsx | 4 + src/custom/components/Popups/WarningPopup.tsx | 28 ++++ src/custom/constants/index.ts | 4 + src/custom/constants/ipfs.ts | 2 - src/custom/hooks/useAppCode.ts | 15 +++ src/custom/hooks/useAppData.ts | 66 ++++++++++ .../hooks/useParseReferralQueryParam.ts | 4 +- src/custom/hooks/useSwapCallback.ts | 19 ++- src/custom/hooks/useWalletInfo.ts | 6 + src/custom/pages/Account/AddressSelector.tsx | 4 +- src/custom/pages/Account/index.tsx | 4 +- src/custom/state/affiliate/actions.ts | 4 +- src/custom/state/affiliate/hooks.ts | 40 ++---- src/custom/state/affiliate/reducer.ts | 21 ++- src/custom/state/affiliate/updater.tsx | 2 +- src/custom/state/appData/atoms.ts | 87 +++++++++++++ src/custom/state/appData/hooks.ts | 18 +++ src/custom/state/appData/types.tsx | 27 ++++ src/custom/state/appData/updater.ts | 122 ++++++++++++++++++ src/custom/state/appData/utils.ts | 11 ++ src/custom/state/application/initialState.ts | 17 +++ src/custom/state/application/localWarning.ts | 12 ++ .../updaters/UnfillableOrdersUpdater.ts | 2 +- src/custom/state/orders/utils.ts | 2 +- src/custom/state/swap/TradeGp.ts | 10 ++ src/custom/state/swap/extension.ts | 2 + src/custom/utils/appData.ts | 55 ++++++++ src/custom/utils/metadata.ts | 55 -------- src/custom/utils/misc.ts | 9 ++ src/custom/utils/price.ts | 3 +- src/custom/utils/signatures.ts | 1 + src/custom/utils/trade.ts | 3 + src/index.tsx | 43 +++--- src/state/application/reducer.ts | 11 +- yarn.lock | 8 +- 42 files changed, 650 insertions(+), 213 deletions(-) delete mode 100644 src/custom/api/ipfs/index.ts create mode 100644 src/custom/components/Popups/WarningPopup.tsx create mode 100644 src/custom/hooks/useAppCode.ts create mode 100644 src/custom/hooks/useAppData.ts create mode 100644 src/custom/state/appData/atoms.ts create mode 100644 src/custom/state/appData/hooks.ts create mode 100644 src/custom/state/appData/types.tsx create mode 100644 src/custom/state/appData/updater.ts create mode 100644 src/custom/state/appData/utils.ts create mode 100644 src/custom/state/application/initialState.ts create mode 100644 src/custom/state/application/localWarning.ts create mode 100644 src/custom/utils/appData.ts delete mode 100644 src/custom/utils/metadata.ts diff --git a/.env b/.env index 92d3ff1b84..437d402ec7 100644 --- a/.env +++ b/.env @@ -72,3 +72,7 @@ REACT_APP_MOCK=true # Locales REACT_APP_LOCALES="locales" + +# IPFS uploading +REACT_APP_PINATA_API_KEY= +REACT_APP_PINATA_SECRET_API_KEY= diff --git a/.env.production b/.env.production index e12f35df3e..2f9753dd8e 100644 --- a/.env.production +++ b/.env.production @@ -73,3 +73,7 @@ REACT_APP_MOCK=false # Locales REACT_APP_LOCALES="locales" + +# IPFS uploading +#REACT_APP_PINATA_API_KEY= +#REACT_APP_PINATA_SECRET_API_KEY= diff --git a/cypress-custom/integration/swapMod.ts b/cypress-custom/integration/swapMod.ts index c324f4638b..c20a405532 100644 --- a/cypress-custom/integration/swapMod.ts +++ b/cypress-custom/integration/swapMod.ts @@ -12,22 +12,22 @@ describe('Swap (mod)', () => { it('can enter an amount into input', () => { cy.get('#swap-currency-input .token-amount-input') - .clear() - .type('0.001', { delay: 400, force: true }) + .type('{selectall}{backspace}{selectall}{backspace}') + .type('0.001') .should('have.value', '0.001') }) it('zero swap amount', () => { cy.get('#swap-currency-input .token-amount-input') - .clear() - .type('0.0', { delay: 400, force: true }) + .type('{selectall}{backspace}{selectall}{backspace}') + .type('0.0') .should('have.value', '0.0') }) it('invalid swap amount', () => { cy.get('#swap-currency-input .token-amount-input') - .clear() - .type('\\', { delay: 400, force: true }) + .type('{selectall}{backspace}{selectall}{backspace}') + .type('\\') .should('have.value', '') }) diff --git a/package.json b/package.json index a8d2fd2f7e..8e42e906f4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@babel/preset-env": "^7.16.11", "@babel/preset-react": "^7.16.7", "@babel/preset-typescript": "^7.16.7", - "@cowprotocol/cow-sdk": "^0.0.14", + "@cowprotocol/cow-sdk": "^0.0.15-RC.0", "@craco/craco": "^5.7.0", "@ethersproject/experimental": "^5.4.0", "@graphql-codegen/cli": "1.21.5", diff --git a/src/custom/api/ipfs/index.ts b/src/custom/api/ipfs/index.ts deleted file mode 100644 index 0bf3f8f56b..0000000000 --- a/src/custom/api/ipfs/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { PINATA_API_KEY, PINATA_SECRET_API_KEY, PINATA_API_URL } from 'constants/ipfs' - -type PinataPinResponse = { - IpfsHash: string - PinSize: number - Timestamp: string -} - -const pinataUrl = `${PINATA_API_URL}/pinning/pinJSONToIPFS` - -const headers = new Headers({ - 'Content-Type': 'application/json', - pinata_api_key: PINATA_API_KEY, - pinata_secret_api_key: PINATA_SECRET_API_KEY, -}) - -export async function pinJSONToIPFS(file: any): Promise { - const body = JSON.stringify({ - pinataContent: file, - pinataMetadata: { name: 'appData-affiliate' }, - }) - - const request = new Request(pinataUrl, { - method: 'POST', - headers, - body, - }) - - const response = await fetch(request) - const data = await response.json() - - if (response.status !== 200) { - throw new Error(data.error.details || data.error) - } - - return data -} diff --git a/src/custom/components/AffiliateStatusCheck/index.tsx b/src/custom/components/AffiliateStatusCheck/index.tsx index c665b92d7e..bf9a8ca133 100644 --- a/src/custom/components/AffiliateStatusCheck/index.tsx +++ b/src/custom/components/AffiliateStatusCheck/index.tsx @@ -2,11 +2,8 @@ import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react' import { useHistory, useLocation } from 'react-router-dom' import { useActiveWeb3React } from 'hooks/web3' import NotificationBanner from 'components/NotificationBanner' -import { useReferralAddress, useResetReferralAddress } from 'state/affiliate/hooks' -import { updateAppDataHash } from 'state/affiliate/actions' -import { useAppDispatch } from 'state/hooks' +import { useReferralAddress, useResetReferralAddress, useSetReferralAddressActive } from 'state/affiliate/hooks' import { hasTrades } from 'utils/trade' -import { generateReferralMetadataDoc, uploadMetadataDocToIpfs } from 'utils/metadata' import { retry, RetryOptions } from 'utils/retry' import { SupportedChainId } from 'constants/chains' import useParseReferralQueryParam from 'hooks/useParseReferralQueryParam' @@ -26,15 +23,34 @@ const STATUS_TO_MESSAGE_MAPPING: Record = { const DEFAULT_RETRY_OPTIONS: RetryOptions = { n: 3, minWait: 1000, maxWait: 3000 } export default function AffiliateStatusCheck() { - const appDispatch = useAppDispatch() const resetReferralAddress = useResetReferralAddress() + const setReferralAddressActive = useSetReferralAddressActive() const history = useHistory() const location = useLocation() const { account, chainId } = useActiveWeb3React() const referralAddress = useReferralAddress() const referralAddressQueryParam = useParseReferralQueryParam() const allRecentActivity = useRecentActivity() - const [affiliateState, setAffiliateState] = useState() + const [affiliateState, _setAffiliateState] = useState() + + /** + * Wrapper around setAffiliateState (local) and setReferralAddressActive (global) + * Need to keep track when affiliate is ACTIVE to know whether it should be included in the + * metadata, no longer uploaded to IPFS here + */ + const setAffiliateState = useCallback( + (state: AffiliateStatus | null) => { + _setAffiliateState(state) + setReferralAddressActive(state === 'ACTIVE') + }, + [setReferralAddressActive] + ) + + // De-normalized to avoid unnecessary useEffect triggers + const isReferralAddressNotSet = !referralAddress + const referralAddressAccount = referralAddress?.value + const referralAddressIsValid = referralAddress?.isValid + const [error, setError] = useState('') const isFirstTrade = useRef(false) const fulfilledOrders = allRecentActivity.filter((data) => { @@ -42,23 +58,24 @@ export default function AffiliateStatusCheck() { }) const notificationBannerId = useMemo(() => { - if (!referralAddress?.value) { + if (!referralAddressAccount) { return } if (!account) { - return `referral-${referralAddress.value}` + return `referral-${referralAddressAccount}` } - return `wallet-${account}:referral-${referralAddress.value}:chain-${chainId}` - }, [account, chainId, referralAddress?.value]) + return `wallet-${account}:referral-${referralAddressAccount}:chain-${chainId}` + }, [account, chainId, referralAddressAccount]) const handleAffiliateState = useCallback(async () => { - if (!chainId || !account || !referralAddress) { + if (!chainId || !account) { return } - if (!referralAddress.isValid) { + // Note: comparing with `false` because in case `undefined` msg shouldn't be displayed + if (referralAddressIsValid === false) { setError('Affiliate program: The referral address is invalid.') return } @@ -85,28 +102,23 @@ export default function AffiliateStatusCheck() { setAffiliateState('ACTIVE') isFirstTrade.current = true - }, [referralAddress, chainId, account, fulfilledOrders.length, history, resetReferralAddress]) - - useEffect(() => { - async function handleReferralAddress(referralAddress: { value: string; isValid: boolean } | undefined) { - if (!referralAddress?.value) return - try { - const appDataHash = await uploadMetadataDocToIpfs(generateReferralMetadataDoc(referralAddress.value)) - appDispatch(updateAppDataHash(appDataHash)) - } catch (e) { - console.error(e) - setError('Affiliate program: There was an error while uploading your referral data. Please try again later.') - } - } - if (affiliateState === 'ACTIVE') handleReferralAddress(referralAddress) - }, [referralAddress, affiliateState, appDispatch]) + }, [ + account, + chainId, + fulfilledOrders.length, + history, + referralAddressIsValid, + resetReferralAddress, + setAffiliateState, + ]) useEffect(() => { - if (!referralAddress) { + if (isReferralAddressNotSet) { return } setError('') + setAffiliateState(null) if (!account) { setAffiliateState('NOT_CONNECTED') @@ -118,7 +130,7 @@ export default function AffiliateStatusCheck() { return } - if (referralAddress.value === account) { + if (referralAddressAccount === account) { // clean-up saved referral address if the user follows its own referral link setAffiliateState('OWN_LINK') @@ -129,7 +141,17 @@ export default function AffiliateStatusCheck() { } handleAffiliateState() - }, [referralAddress, account, history, chainId, handleAffiliateState, location.search, referralAddressQueryParam]) + }, [ + account, + history, + chainId, + handleAffiliateState, + location.search, + referralAddressQueryParam, + setAffiliateState, + referralAddressAccount, + isReferralAddressNotSet, + ]) if (error) { return ( diff --git a/src/custom/components/Header/URLWarning/index.tsx b/src/custom/components/Header/URLWarning/index.tsx index 4191fb1435..f4eda7181f 100644 --- a/src/custom/components/Header/URLWarning/index.tsx +++ b/src/custom/components/Header/URLWarning/index.tsx @@ -71,6 +71,7 @@ export default function URLWarning() { const announcementVisible = useAnnouncementVisible(contentHash) const closeAnnouncement = useCloseAnnouncement() + const announcement = announcementVisible && announcementText && ( <>
diff --git a/src/custom/components/Popups/PopupItemMod.tsx b/src/custom/components/Popups/PopupItemMod.tsx index 552aa04c29..00425cb906 100644 --- a/src/custom/components/Popups/PopupItemMod.tsx +++ b/src/custom/components/Popups/PopupItemMod.tsx @@ -11,6 +11,7 @@ import TransactionPopup from './TransactionPopupMod' // MOD imports import ListUpdatePopup from 'components/Popups/ListUpdatePopup' +import { WarningPopup } from 'components/Popups/WarningPopup' export const StyledClose = styled(X)` position: absolute; @@ -84,6 +85,7 @@ export default function PopupItem({ const isListUpdate = 'listUpdate' in content const isUnsupportedNetwork = 'unsupportedNetwork' in content const isMetaTxn = 'metatxn' in content + const isWarningTxn = 'warning' in content let popupContent if (isTxn) { @@ -105,6 +107,8 @@ export default function PopupItem({ popupContent = } else if ('failedSwitchNetwork' in content) { popupContent = + } else if (isWarningTxn) { + popupContent = } const faderStyle = useSpring({ diff --git a/src/custom/components/Popups/WarningPopup.tsx b/src/custom/components/Popups/WarningPopup.tsx new file mode 100644 index 0000000000..0284d23160 --- /dev/null +++ b/src/custom/components/Popups/WarningPopup.tsx @@ -0,0 +1,28 @@ +import { useContext } from 'react' +import styled, { ThemeContext } from 'styled-components/macro' + +import { ThemedText } from 'theme' +import { AutoColumn } from 'components/Column' +import { AutoRow } from 'components/Row' +import { AlertCircle } from 'react-feather' + +const RowNoFlex = styled(AutoRow)` + flex-wrap: nowrap; +` + +export function WarningPopup({ warning }: { warning: string | JSX.Element }) { + const theme = useContext(ThemeContext) + + return ( + +
+ +
+ + + {warning} + + +
+ ) +} diff --git a/src/custom/constants/index.ts b/src/custom/constants/index.ts index e5ffbc0c0b..cc96415d9c 100644 --- a/src/custom/constants/index.ts +++ b/src/custom/constants/index.ts @@ -32,6 +32,9 @@ export const SHORT_LOAD_THRESHOLD = 500 export const LONG_LOAD_THRESHOLD = 2000 export const APP_DATA_HASH = getAppDataHash() +export const DEFAULT_APP_CODE = 'CowSwap' +export const SAFE_APP_CODE = `${DEFAULT_APP_CODE}-SafeApp` + export const PRODUCTION_URL = 'cowswap.exchange' export const BARN_URL = `barn.${PRODUCTION_URL}` @@ -170,6 +173,7 @@ export const SWR_OPTIONS = { revalidateOnFocus: false, } +// TODO: show banner warning when PINATA env vars are missing const COW_SDK_OPTIONS = { ipfs: { pinataApiKey: PINATA_API_KEY, pinataApiSecret: PINATA_SECRET_API_KEY }, } diff --git a/src/custom/constants/ipfs.ts b/src/custom/constants/ipfs.ts index 46d7e90d9e..dd68b4823a 100644 --- a/src/custom/constants/ipfs.ts +++ b/src/custom/constants/ipfs.ts @@ -1,4 +1,2 @@ export const PINATA_API_KEY = process.env.REACT_APP_PINATA_API_KEY as string export const PINATA_SECRET_API_KEY = process.env.REACT_APP_PINATA_SECRET_API_KEY as string -export const PINATA_API_URL = process.env.REACT_APP_PINATA_API_URL || 'https://api.pinata.cloud' -export const IPFS_URI = process.env.REACT_APP_IPFS_URI || 'https://ipfs.infura.io:5001/api/v0' diff --git a/src/custom/hooks/useAppCode.ts b/src/custom/hooks/useAppCode.ts new file mode 100644 index 0000000000..1c47d982ef --- /dev/null +++ b/src/custom/hooks/useAppCode.ts @@ -0,0 +1,15 @@ +import { useIsGnosisSafeApp } from 'hooks/useWalletInfo' +import { DEFAULT_APP_CODE, SAFE_APP_CODE } from 'constants/index' + +const APP_CODE = process.env.REACT_APP_APP_CODE + +export function useAppCode(): string { + const isSafeApp = useIsGnosisSafeApp() + + if (APP_CODE) { + // appCode coming from env var has priority + return APP_CODE + } + + return isSafeApp ? SAFE_APP_CODE : DEFAULT_APP_CODE +} diff --git a/src/custom/hooks/useAppData.ts b/src/custom/hooks/useAppData.ts new file mode 100644 index 0000000000..70c1790470 --- /dev/null +++ b/src/custom/hooks/useAppData.ts @@ -0,0 +1,66 @@ +import { useEffect } from 'react' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { useAtom } from 'jotai' +import { Percent } from '@uniswap/sdk-core' + +import { APP_DATA_HASH } from 'constants/index' +import { buildAppData, BuildAppDataParams } from 'utils/appData' +import { appDataInfoAtom } from 'state/appData/atoms' +import { AppDataInfo } from 'state/appData/types' +import { useReferralAddress } from 'state/affiliate/hooks' +import { useAppCode } from 'hooks/useAppCode' +import { percentToBips } from 'utils/misc' + +type UseAppDataParams = { + chainId?: SupportedChainId + allowedSlippage: Percent +} + +/** + * Fetches and updates appDataInfo whenever a dependency changes + */ +export function useAppData({ chainId, allowedSlippage }: UseAppDataParams): AppDataInfo | null { + // AppDataInfo, from Jotai + const [appDataInfo, setAppDataInfo] = useAtom(appDataInfoAtom) + // Referrer address, from Redux + const referrer = useReferralAddress() + // De-normalizing as we only care about the address if it's set, valid and active + const referrerAccount = referrer?.value && referrer?.isActive && referrer?.isValid ? referrer.value : undefined + // AppCode is dynamic and based on how it's loaded (if used as a Gnosis Safe app) + const appCode = useAppCode() + + // Transform slippage Percent to bips + const slippageBips = percentToBips(allowedSlippage) + + useEffect(() => { + if (!chainId) { + // reset values when there is no price estimation or network changes + setAppDataInfo(null) + return + } + + const params: BuildAppDataParams = { chainId, slippageBips, referrerAccount, appCode } + + const updateAppData = async (): Promise => { + try { + const { doc, calculatedAppData } = await buildAppData(params) + + console.debug(`[useAppData] appDataInfo`, JSON.stringify(doc), calculatedAppData) + + if (calculatedAppData?.appDataHash) { + setAppDataInfo({ doc, hash: calculatedAppData.appDataHash }) + } else { + // For some reason failed to calculate the appDataHash, use a default hash + throw new Error("Couldn't calculate appDataHash") + } + } catch (e) { + console.error(`[useAppData] failed to generate appData, falling back to default`, params, e.message) + setAppDataInfo({ hash: APP_DATA_HASH }) + } + } + + updateAppData() + }, [appCode, chainId, referrerAccount, setAppDataInfo, slippageBips]) + + return appDataInfo +} diff --git a/src/custom/hooks/useParseReferralQueryParam.ts b/src/custom/hooks/useParseReferralQueryParam.ts index d1b5cf0b4e..bd3d91f98a 100644 --- a/src/custom/hooks/useParseReferralQueryParam.ts +++ b/src/custom/hooks/useParseReferralQueryParam.ts @@ -18,7 +18,7 @@ export default function useParseReferralQueryParam(): ReferralQueryValue { const result = useENS(referralAddress) const [loading, setLoading] = useState(isAddress(referralAddress) === false) // this is a hack to force a initial loading state to true in case of referralAddress is a ens name because the useENS hook returns loading as false when initialized - const referral = useMemo(() => { + return useMemo(() => { if (loading || result.loading || !referralAddress) { if (result.loading) { setLoading(false) @@ -33,6 +33,4 @@ export default function useParseReferralQueryParam(): ReferralQueryValue { console.warn('Invalid referral address') return { value: '', isValid: false } }, [result.loading, result.address, referralAddress, loading]) - - return referral } diff --git a/src/custom/hooks/useSwapCallback.ts b/src/custom/hooks/useSwapCallback.ts index d36e650245..3cba305b16 100644 --- a/src/custom/hooks/useSwapCallback.ts +++ b/src/custom/hooks/useSwapCallback.ts @@ -26,7 +26,8 @@ import { GpEther as ETHER } from 'constants/tokens' import { useWalletInfo } from './useWalletInfo' import { usePresignOrder, PresignOrder } from 'hooks/usePresignOrder' import { Web3Provider, ExternalProvider, JsonRpcProvider } from '@ethersproject/providers' -import { useAppDataHash } from 'state/affiliate/hooks' +import { useAppData } from 'hooks/useAppData' +import { useAddAppDataToUploadQueue } from 'state/appData/hooks' export const MAX_VALID_TO_EPOCH = BigNumber.from('0xFFFFFFFF').toNumber() // Max uint32 (Feb 07 2106 07:28:15 GMT+0100) @@ -91,6 +92,7 @@ interface SwapParams { // Callbacks wrapEther: Wrap | null presignOrder: PresignOrder + addAppDataToUploadQueue: (orderId: string) => void // Ui actions addPendingOrder: AddOrderCallback @@ -125,6 +127,7 @@ async function _swap(params: SwapParams): Promise { recipientAddressOrName, recipient, appDataHash, + addAppDataToUploadQueue, // Callbacks wrapEther, @@ -208,6 +211,7 @@ async function _swap(params: SwapParams): Promise { signer: library.getSigner(), allowsOffchainSigning, appDataHash, + quoteId: trade.quoteId, }) let pendingOrderParams: AddUnserialisedPendingOrderParams @@ -249,6 +253,8 @@ async function _swap(params: SwapParams): Promise { presignGnosisSafeTxHash, }, }) + // Set appData to be uploaded to IPFS in the background + addAppDataToUploadQueue(orderId) return orderId } @@ -343,7 +349,11 @@ export function useSwapCallback(params: SwapCallbackParams): { const recipient = recipientAddressOrName === null ? account : recipientAddress const [deadline] = useUserTransactionTTL() - const appDataHash = useAppDataHash() + + const appData = useAppData({ chainId, allowedSlippage }) + const { hash: appDataHash } = appData || {} + const addAppDataToUploadQueue = useAddAppDataToUploadQueue(chainId, appData) + const addPendingOrder = useAddPendingOrder() const { INPUT: inputAmountWithSlippage, OUTPUT: outputAmountWithSlippage } = computeSlippageAdjustedAmounts( trade, @@ -360,7 +370,8 @@ export function useSwapCallback(params: SwapCallbackParams): { !chainId || !inputAmountWithSlippage || !outputAmountWithSlippage || - !presignOrder + !presignOrder || + !appDataHash ) { return { state: SwapCallbackState.INVALID, callback: null, error: 'Missing dependencies' } } @@ -406,6 +417,7 @@ export function useSwapCallback(params: SwapCallbackParams): { // Callbacks wrapEther, presignOrder, + addAppDataToUploadQueue, // Ui actions addPendingOrder, @@ -437,5 +449,6 @@ export function useSwapCallback(params: SwapCallbackParams): { closeModals, presignOrder, appDataHash, + addAppDataToUploadQueue, ]) } diff --git a/src/custom/hooks/useWalletInfo.ts b/src/custom/hooks/useWalletInfo.ts index e84ad777cb..417b385f17 100644 --- a/src/custom/hooks/useWalletInfo.ts +++ b/src/custom/hooks/useWalletInfo.ts @@ -156,3 +156,9 @@ export function useWalletInfo(): ConnectedWalletInfo { gnosisSafeInfo, } } + +export function useIsGnosisSafeApp(): boolean { + const { walletName } = useWalletInfo() + + return walletName === GNOSIS_SAFE_APP_NAME +} diff --git a/src/custom/pages/Account/AddressSelector.tsx b/src/custom/pages/Account/AddressSelector.tsx index 4d9443e953..26c04900e0 100644 --- a/src/custom/pages/Account/AddressSelector.tsx +++ b/src/custom/pages/Account/AddressSelector.tsx @@ -4,7 +4,7 @@ import { Check, ChevronDown } from 'react-feather' import { useOnClickOutside } from 'hooks/useOnClickOutside' import { useActiveWeb3React } from 'hooks/web3' import { ensNames } from './ens' -import { useAddress } from 'state/affiliate/hooks' +import { useAffiliateAddress } from 'state/affiliate/hooks' import { updateAddress } from 'state/affiliate/actions' import { useAppDispatch } from 'state/hooks' import { isAddress, shortenAddress } from 'utils' @@ -16,7 +16,7 @@ type AddressSelectorProps = { export default function AddressSelector(props: AddressSelectorProps) { const { address } = props const dispatch = useAppDispatch() - const selectedAddress = useAddress() + const selectedAddress = useAffiliateAddress() const { chainId, library } = useActiveWeb3React() const [open, setOpen] = useState(false) const [items, setItems] = useState([address]) diff --git a/src/custom/pages/Account/index.tsx b/src/custom/pages/Account/index.tsx index 6f17e4990d..77b6f6e7bc 100644 --- a/src/custom/pages/Account/index.tsx +++ b/src/custom/pages/Account/index.tsx @@ -41,7 +41,7 @@ import { SupportedChainId, SupportedChainId as ChainId } from 'constants/chains' import AffiliateStatusCheck from 'components/AffiliateStatusCheck' import AddressSelector from './AddressSelector' import { useHasOrders } from 'api/gnosisProtocol/hooks' -import { useAddress } from 'state/affiliate/hooks' +import { useAffiliateAddress } from 'state/affiliate/hooks' import { Title, SectionTitle, HelpCircle } from 'components/Page' import { ButtonPrimary } from 'custom/components/Button' import vCOWImage from 'assets/cow-swap/vCOW.png' @@ -78,7 +78,7 @@ export default function Profile() { const lastUpdated = useTimeAgo(profileData?.lastUpdated) const isTradesTooltipVisible = account && chainId === SupportedChainId.MAINNET && !!profileData?.totalTrades const hasOrders = useHasOrders(account) - const selectedAddress = useAddress() + const selectedAddress = useAffiliateAddress() const previousAccount = usePrevious(account) const blockNumber = useBlockNumber() diff --git a/src/custom/state/affiliate/actions.ts b/src/custom/state/affiliate/actions.ts index 3f0216478f..97bac9de10 100644 --- a/src/custom/state/affiliate/actions.ts +++ b/src/custom/state/affiliate/actions.ts @@ -5,8 +5,8 @@ export const updateReferralAddress = createAction<{ isValid: boolean } | null>('affiliate/updateReferralAddress') -export const updateAddress = createAction('affiliate/updateAddress') +export const setReferralAddressActive = createAction('affiliate/setReferralAddressActive') -export const updateAppDataHash = createAction('affiliate/updateAppDataHash') +export const updateAddress = createAction('affiliate/updateAddress') export const dismissNotification = createAction('affiliate/dismissNotification') diff --git a/src/custom/state/affiliate/hooks.ts b/src/custom/state/affiliate/hooks.ts index d1c664c74b..408ae7b08e 100644 --- a/src/custom/state/affiliate/hooks.ts +++ b/src/custom/state/affiliate/hooks.ts @@ -1,33 +1,13 @@ import { useCallback } from 'react' -import { useSelector } from 'react-redux' -import { AppState } from 'state' -import { useAppDispatch } from 'state/hooks' -import { updateReferralAddress } from 'state/affiliate/actions' -import { APP_DATA_HASH } from 'constants/index' - -export function useAppDataHash() { - return useSelector((state) => { - return state.affiliate.appDataHash || APP_DATA_HASH - }) -} +import { useAppDispatch, useAppSelector } from 'state/hooks' +import { setReferralAddressActive, updateReferralAddress } from 'state/affiliate/actions' export function useReferralAddress() { - return useSelector< - AppState, - | { - value: string - isValid: boolean - } - | undefined - >((state) => { - return state.affiliate.referralAddress - }) + return useAppSelector((state) => state.affiliate.referralAddress) } -export function useAddress() { - return useSelector((state) => { - return state.affiliate.address - }) +export function useAffiliateAddress() { + return useAppSelector((state) => state.affiliate.address) } export function useResetReferralAddress() { @@ -36,8 +16,12 @@ export function useResetReferralAddress() { return useCallback(() => dispatch(updateReferralAddress(null)), [dispatch]) } +export function useSetReferralAddressActive() { + const dispatch = useAppDispatch() + + return useCallback((isActive: boolean) => dispatch(setReferralAddressActive(isActive)), [dispatch]) +} + export function useIsNotificationClosed(id?: string): boolean | null { - return useSelector((state) => { - return id ? state.affiliate.isNotificationClosed?.[id] ?? false : null - }) + return useAppSelector((state) => (id ? state.affiliate.isNotificationClosed?.[id] ?? false : null)) } diff --git a/src/custom/state/affiliate/reducer.ts b/src/custom/state/affiliate/reducer.ts index 8858a6ed1a..25f6446203 100644 --- a/src/custom/state/affiliate/reducer.ts +++ b/src/custom/state/affiliate/reducer.ts @@ -1,34 +1,33 @@ import { createReducer } from '@reduxjs/toolkit' -import { dismissNotification, updateAddress, updateAppDataHash, updateReferralAddress } from './actions' -import { APP_DATA_HASH } from 'constants/index' +import { dismissNotification, setReferralAddressActive, updateAddress, updateReferralAddress } from './actions' export interface AffiliateState { referralAddress?: { value: string isValid: boolean + isActive?: boolean } - appDataHash?: string isNotificationClosed?: { [key: string]: boolean } - address?: string // this can be a ENS name or an address + address?: string // this can be an ENS name or an address } -export const initialState: AffiliateState = { - appDataHash: APP_DATA_HASH, -} +export const initialState: AffiliateState = {} export default createReducer(initialState, (builder) => builder .addCase(updateReferralAddress, (state, action) => { - state.referralAddress = action.payload ?? undefined + state.referralAddress = action.payload ? { ...state.referralAddress, ...action.payload } : undefined + }) + .addCase(setReferralAddressActive, (state, action) => { + if (state.referralAddress) { + state.referralAddress.isActive = action.payload + } }) .addCase(updateAddress, (state, action) => { state.address = action.payload }) - .addCase(updateAppDataHash, (state, action) => { - state.appDataHash = action.payload - }) .addCase(dismissNotification, (state, action) => { state.isNotificationClosed = state.isNotificationClosed ?? {} state.isNotificationClosed[action.payload] = true diff --git a/src/custom/state/affiliate/updater.tsx b/src/custom/state/affiliate/updater.tsx index eee2c5c78f..08c06ece2d 100644 --- a/src/custom/state/affiliate/updater.tsx +++ b/src/custom/state/affiliate/updater.tsx @@ -17,7 +17,7 @@ export function ReferralLinkUpdater() { } else if (referralAddressParam) { dispatch(updateReferralAddress(referralAddressParam)) } - }, [referralAddressParam, referralAddress, dispatch]) + }, [referralAddressParam, referralAddress?.isValid, dispatch]) return null } diff --git a/src/custom/state/appData/atoms.ts b/src/custom/state/appData/atoms.ts new file mode 100644 index 0000000000..70869a8eeb --- /dev/null +++ b/src/custom/state/appData/atoms.ts @@ -0,0 +1,87 @@ +import { atom } from 'jotai' +import { atomWithStorage } from 'jotai/utils' + +import { + AddAppDataToUploadQueueParams, + AppDataPendingToUpload, + AppDataInfo, + RemoveAppDataFromUploadQueueParams, + UpdateAppDataOnUploadQueueParams, +} from 'state/appData/types' +import { buildDocFilterFn, buildInverseDocFilterFn } from 'state/appData/utils' + +/** + * Base atom that store the current appDataInfo + */ +export const appDataInfoAtom = atom(null) + +/** + * Base atom that stores all appData pending to be uploaded + */ +export const appDataUploadQueueAtom = atomWithStorage( + 'appDataUploadQueue', // local storage key + [] +) + +/** + * Write only atom to add an appData to upload queue + */ +export const addAppDataToUploadQueueAtom = atom( + null, + (get, set, { chainId, orderId, appData }: AddAppDataToUploadQueueParams) => { + set(appDataUploadQueueAtom, () => { + const docs = get(appDataUploadQueueAtom) + + if (docs.some(buildDocFilterFn(chainId, orderId))) { + // Entry already in the queue, ignore + return docs + } + + return [...docs, { chainId, orderId, ...appData, uploading: false, failedAttempts: 0 }] + }) + } +) + +/** + * Write only atom to update upload status of an appData on upload queue + */ +export const updateAppDataOnUploadQueueAtom = atom( + null, + (get, set, { chainId, orderId, uploading, lastAttempt, failedAttempts }: UpdateAppDataOnUploadQueueParams) => { + set(appDataUploadQueueAtom, () => { + const docs = get(appDataUploadQueueAtom) + const existingDocIndex = docs.findIndex(buildDocFilterFn(chainId, orderId)) + + if (existingDocIndex === -1) { + // Entry doesn't exist in the queue, ignore + return docs + } + + // Create a copy of original docs + const updateDocs = docs.slice(0) + + // Using the index, get the value + const existingDoc = docs[existingDocIndex] + + // Replace existing doc at index with the updated version + updateDocs[existingDocIndex] = { + ...existingDoc, + uploading: uploading ?? existingDoc.uploading, + lastAttempt: lastAttempt ?? existingDoc.lastAttempt, + failedAttempts: failedAttempts ?? existingDoc.failedAttempts, + } + + return updateDocs + }) + } +) + +/** + * Write only atom to remove appData from upload queue + */ +export const removeAppDataFromUploadQueueAtom = atom( + null, + (get, set, { chainId, orderId }: RemoveAppDataFromUploadQueueParams) => { + set(appDataUploadQueueAtom, () => get(appDataUploadQueueAtom).filter(buildInverseDocFilterFn(chainId, orderId))) + } +) diff --git a/src/custom/state/appData/hooks.ts b/src/custom/state/appData/hooks.ts new file mode 100644 index 0000000000..cbf7b60148 --- /dev/null +++ b/src/custom/state/appData/hooks.ts @@ -0,0 +1,18 @@ +import { useCallback } from 'react' +import { useUpdateAtom } from 'jotai/utils' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { addAppDataToUploadQueueAtom } from 'state/appData/atoms' +import { AppDataInfo } from 'state/appData/types' + +export function useAddAppDataToUploadQueue(chainId: SupportedChainId | undefined, appData: AppDataInfo | null) { + const addAppDataToUploadQueue = useUpdateAtom(addAppDataToUploadQueueAtom) + + return useCallback( + (orderId: string) => { + if (!chainId || !appData) return + addAppDataToUploadQueue({ chainId, orderId, appData }) + }, + [appData, chainId, addAppDataToUploadQueue] + ) +} diff --git a/src/custom/state/appData/types.tsx b/src/custom/state/appData/types.tsx new file mode 100644 index 0000000000..55ce584320 --- /dev/null +++ b/src/custom/state/appData/types.tsx @@ -0,0 +1,27 @@ +import { AppDataDoc, SupportedChainId } from '@cowprotocol/cow-sdk' + +export type AppDataInfo = { + doc?: AppDataDoc // in case of default hash, there's no doc + hash: string +} + +type AppDataUploadStatus = { + lastAttempt?: number + failedAttempts: number + uploading: boolean +} + +export type AppDataKeyParams = { + chainId: SupportedChainId + orderId: string +} + +export type AppDataRecord = AppDataInfo & AppDataUploadStatus & AppDataKeyParams + +export type AppDataPendingToUpload = Array + +export type AddAppDataToUploadQueueParams = AppDataKeyParams & { + appData: AppDataInfo +} +export type UpdateAppDataOnUploadQueueParams = AppDataKeyParams & Partial +export type RemoveAppDataFromUploadQueueParams = AppDataKeyParams diff --git a/src/custom/state/appData/updater.ts b/src/custom/state/appData/updater.ts new file mode 100644 index 0000000000..e0bb64134d --- /dev/null +++ b/src/custom/state/appData/updater.ts @@ -0,0 +1,122 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { useEffect, useRef } from 'react' +import ms from 'ms.macro' +import { AppDataDoc } from '@cowprotocol/cow-sdk' + +import { COW_SDK } from 'constants/index' + +import { + appDataUploadQueueAtom, + removeAppDataFromUploadQueueAtom, + updateAppDataOnUploadQueueAtom, +} from 'state/appData/atoms' +import { AppDataKeyParams, AppDataRecord, UpdateAppDataOnUploadQueueParams } from 'state/appData/types' + +const UPLOAD_CHECK_INTERVAL = ms`1 minute` +const BASE_FOR_EXPONENTIAL_BACKOFF = 2 // in seconds, converted to milliseconds later +const ONE_SECOND = ms`1s` +const MAX_TIME_TO_WAIT = ms`5 minutes` + +export function UploadToIpfsUpdater(): null { + const toUpload = useAtomValue(appDataUploadQueueAtom) + const removePending = useSetAtom(removeAppDataFromUploadQueueAtom) + const updatePending = useSetAtom(updateAppDataOnUploadQueueAtom) + + // Storing a reference to avoid re-render on every update + const refToUpload = useRef(toUpload) + refToUpload.current = toUpload + + // Filtering only newly created and not yet attempted to upload docs + const newlyAdded = toUpload.filter(({ uploading, lastAttempt }) => !uploading && !lastAttempt) + + useEffect(() => { + // Try to upload new docs as soon as they are added + newlyAdded.forEach((appDataRecord) => _uploadToIpfs(appDataRecord, updatePending, removePending)) + }, [newlyAdded, removePending, updatePending]) + + useEffect(() => { + async function uploadPendingAppData() { + console.debug(`[UploadToIpfsUpdater] Iterating over ${refToUpload.current.length} appData on upload queue`) + refToUpload.current.forEach((appDataRecord) => _uploadToIpfs(appDataRecord, updatePending, removePending)) + } + + const intervalId = setInterval(uploadPendingAppData, UPLOAD_CHECK_INTERVAL) + + return () => { + clearInterval(intervalId) + } + }, [removePending, updatePending]) + + return null +} + +async function _uploadToIpfs( + appDataRecord: AppDataRecord, + updatePending: (params: UpdateAppDataOnUploadQueueParams) => void, + removePending: (params: AppDataKeyParams) => void +) { + const { doc, chainId, orderId, uploading, failedAttempts, lastAttempt } = appDataRecord + + if (!doc) { + // No actual doc to upload, nothing to do here + removePending({ chainId, orderId }) + } else if (_canUpload(uploading, failedAttempts, lastAttempt)) { + await _actuallyUploadToIpfs(appDataRecord, updatePending, removePending) + } else { + console.log(`[UploadToIpfsUpdater] Criteria not met, skipping ${chainId}-${orderId}`) + } +} + +function _canUpload(uploading: boolean, attempts: number, lastAttempt?: number): boolean { + if (uploading) { + return false + } + + if (lastAttempt) { + // Every attempt takes BASE_FOR_EXPONENTIAL_BACKOFF ˆ failedAttempts + const timeToWait = BASE_FOR_EXPONENTIAL_BACKOFF ** attempts * ONE_SECOND + // Don't wait more than MAX_TIME_TO_WAIT. + // Both are in milliseconds + const timeDelta = Math.min(timeToWait, MAX_TIME_TO_WAIT) + + return lastAttempt + timeDelta <= Date.now() + } + + return true +} + +async function _actuallyUploadToIpfs( + appDataRecord: AppDataRecord, + updatePending: (params: UpdateAppDataOnUploadQueueParams) => void, + removePending: (params: AppDataKeyParams) => void +) { + const { doc, chainId, orderId, failedAttempts, hash } = appDataRecord + + // Update state to prevent it to be uploaded by another process in the meantime + updatePending({ chainId, orderId, uploading: true }) + + try { + const sdk = COW_SDK[chainId] + + const actualHash = await sdk.metadataApi.uploadMetadataDocToIpfs(doc as AppDataDoc) + + removePending({ chainId, orderId }) + + if (hash !== actualHash) { + // TODO: add sentry error to track hard failure + console.error( + `[UploadToIpfsUpdater] Uploaded data hash (${actualHash}) differs from calculated (${hash}) for doc`, + JSON.stringify(doc) + ) + } else { + console.debug(`[UploadToIpfsUpdater] Uploaded doc with hash ${actualHash}`, JSON.stringify(doc)) + } + } catch (e) { + // TODO: add sentry error to track soft failure + console.warn( + `[UploadToIpfsUpdater] Failed to upload doc, will try again. Reason: ${e.message}`, + JSON.stringify(doc) + ) + updatePending({ chainId, orderId, uploading: false, failedAttempts: failedAttempts + 1, lastAttempt: Date.now() }) + } +} diff --git a/src/custom/state/appData/utils.ts b/src/custom/state/appData/utils.ts new file mode 100644 index 0000000000..3aac5f5bcb --- /dev/null +++ b/src/custom/state/appData/utils.ts @@ -0,0 +1,11 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { AppDataRecord } from 'state/appData/types' + +export function buildDocFilterFn(chainId: SupportedChainId, orderId: string) { + return (doc: AppDataRecord) => doc.chainId === chainId && doc.orderId === orderId +} + +export function buildInverseDocFilterFn(chainId: SupportedChainId, orderId: string) { + return (doc: AppDataRecord) => doc.chainId !== chainId && doc.orderId !== orderId +} diff --git a/src/custom/state/application/initialState.ts b/src/custom/state/application/initialState.ts new file mode 100644 index 0000000000..e55df35eea --- /dev/null +++ b/src/custom/state/application/initialState.ts @@ -0,0 +1,17 @@ +import { ApplicationState } from '@src/state/application/reducer' +import { localWarning } from 'state/application/localWarning' + +const popupList: ApplicationState['popupList'] = [] + +if (localWarning) { + popupList.push({ + key: 'localWarning', + show: true, + removeAfterMs: null, + content: { + warning: localWarning, + }, + }) +} + +export const initialState: ApplicationState = { chainId: null, openModal: null, popupList } diff --git a/src/custom/state/application/localWarning.ts b/src/custom/state/application/localWarning.ts new file mode 100644 index 0000000000..c0082420ae --- /dev/null +++ b/src/custom/state/application/localWarning.ts @@ -0,0 +1,12 @@ +import { PINATA_API_KEY, PINATA_SECRET_API_KEY } from 'constants/ipfs' +import { isLocal } from 'utils/environments' + +let warningMsg + +if ((!PINATA_SECRET_API_KEY || !PINATA_API_KEY) && isLocal) { + warningMsg = + "Pinata env vars not set. Order appData upload won't work! " + + 'Set REACT_APP_PINATA_API_KEY and REACT_APP_PINATA_SECRET_API_KEY' +} + +export const localWarning = warningMsg diff --git a/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts b/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts index d37f4e1fdb..e942d6ba69 100644 --- a/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts +++ b/src/custom/state/orders/updaters/UnfillableOrdersUpdater.ts @@ -74,7 +74,7 @@ export function UnfillableOrdersUpdater(): null { const isUpdating = useRef(false) // TODO: Implement using SWR or retry/cancellable promises const updateIsUnfillableFlag = useCallback( - (chainId: ChainId, order: Order, price: Required) => { + (chainId: ChainId, order: Order, price: Required>) => { const isUnfillable = isOrderUnfillable(order, price) // Only trigger state update if flag changed diff --git a/src/custom/state/orders/utils.ts b/src/custom/state/orders/utils.ts index 614fd262fa..a7861c9677 100644 --- a/src/custom/state/orders/utils.ts +++ b/src/custom/state/orders/utils.ts @@ -208,7 +208,7 @@ export function getOrderExecutedAmounts(order: OrderMetaData): { * @param order * @param price */ -export function isOrderUnfillable(order: Order, price: Required): boolean { +export function isOrderUnfillable(order: Order, price: Required>): boolean { // Build price object from stored order const orderPrice = new Price( order.inputToken, diff --git a/src/custom/state/swap/TradeGp.ts b/src/custom/state/swap/TradeGp.ts index 95a39a5386..5aded8fdf4 100644 --- a/src/custom/state/swap/TradeGp.ts +++ b/src/custom/state/swap/TradeGp.ts @@ -69,6 +69,7 @@ interface TradeGpConstructor { fee: FeeForTrade executionPrice: Price tradeType: TradeType + quoteId?: number } /** @@ -100,6 +101,13 @@ export default class TradeGp { */ readonly executionPrice: Price + /** + * The id returned by CowSwap's quote backend, if any + * + * Note that it won't be set for fast quotes, nor for quotes from other sources (paraswap, 0x, etc) + */ + readonly quoteId?: number + public constructor({ inputAmount, inputAmountWithFee, @@ -109,6 +117,7 @@ export default class TradeGp { fee, executionPrice, tradeType, + quoteId, }: TradeGpConstructor) { this.tradeType = tradeType this.inputAmount = inputAmount @@ -118,6 +127,7 @@ export default class TradeGp { this.outputAmount = outputAmount this.fee = fee this.executionPrice = executionPrice + this.quoteId = quoteId } /** * Get the minimum amount that must be received from this trade for the given slippage tolerance diff --git a/src/custom/state/swap/extension.ts b/src/custom/state/swap/extension.ts index 661f7f8c38..5c36473d27 100644 --- a/src/custom/state/swap/extension.ts +++ b/src/custom/state/swap/extension.ts @@ -77,6 +77,7 @@ export function useTradeExactInWithFee({ fee, executionPrice, tradeType: TradeType.EXACT_INPUT, + quoteId: quote.price.quoteId, }) } @@ -129,5 +130,6 @@ export function useTradeExactOutWithFee({ fee, executionPrice, tradeType: TradeType.EXACT_OUTPUT, + quoteId: quote.price.quoteId, }) } diff --git a/src/custom/utils/appData.ts b/src/custom/utils/appData.ts new file mode 100644 index 0000000000..bd07ea0b90 --- /dev/null +++ b/src/custom/utils/appData.ts @@ -0,0 +1,55 @@ +import { COW_SDK } from 'constants/index' +import { MetadataDoc, QuoteMetadata, ReferralMetadata, SupportedChainId } from '@cowprotocol/cow-sdk' +import { environmentName } from 'utils/environments' + +//TODO: move helper methods to SDK +const QUOTE_METADATA_VERSION = '0.2.0' +const REFERRER_METADATA_VERSION = '0.1.0' + +export type BuildAppDataParams = { + chainId: SupportedChainId + slippageBips: string + sellAmount?: string + buyAmount?: string + quoteId?: number + referrerAccount?: string + appCode: string +} + +export async function buildAppData({ + chainId, + slippageBips, + sellAmount, + buyAmount, + quoteId, + referrerAccount, + appCode, +}: BuildAppDataParams) { + const sdk = COW_SDK[chainId] + + // build quote metadata, not required in the schema but always present + const quoteMetadata = _buildQuoteMetadata(slippageBips) + const metadata: MetadataDoc = { quote: quoteMetadata } + + // build referrer metadata, optional + if (referrerAccount) { + metadata.referrer = _buildReferralMetadata(referrerAccount) + } + + const doc = sdk.metadataApi.generateAppDataDoc(metadata, { appCode, environment: environmentName }) + + const calculatedAppData = await sdk.metadataApi.calculateAppDataHash(doc) + + return { doc, calculatedAppData } +} + +function _buildQuoteMetadata(slippageBips: string): QuoteMetadata { + return { version: QUOTE_METADATA_VERSION, slippageBips } +} + +function _buildReferralMetadata(address: string): ReferralMetadata { + return { + address, + version: REFERRER_METADATA_VERSION, + } +} diff --git a/src/custom/utils/metadata.ts b/src/custom/utils/metadata.ts deleted file mode 100644 index 18e1748f24..0000000000 --- a/src/custom/utils/metadata.ts +++ /dev/null @@ -1,55 +0,0 @@ -import CID from 'cids' -import multihashes from 'multihashes' -import { pinJSONToIPFS } from 'api/ipfs' - -interface Metadata { - version: string -} - -export interface ReferralMetadata extends Metadata { - address: string -} - -export type MetadataDoc = { - referrer?: ReferralMetadata -} - -export type AppDataDoc = { - version: string - appCode?: string - metadata: MetadataDoc -} - -export const DEFAULT_APP_CODE = 'CowSwap' - -export function generateReferralMetadataDoc( - referralAddress: string, - appDataDoc: AppDataDoc = generateAppDataDoc() -): AppDataDoc { - return { - ...appDataDoc, - metadata: { - ...appDataDoc.metadata, - referrer: { - address: referralAddress, - version: '0.1.0', - }, - }, - } -} - -export function generateAppDataDoc(metadata: MetadataDoc = {}): AppDataDoc { - return { - version: '0.1.0', - appCode: DEFAULT_APP_CODE, - metadata: { - ...metadata, - }, - } -} - -export async function uploadMetadataDocToIpfs(appDataDoc: AppDataDoc): Promise { - const { IpfsHash } = await pinJSONToIPFS(appDataDoc) - const { digest } = multihashes.decode(new CID(IpfsHash).multihash) - return `0x${Buffer.from(digest).toString('hex')}` -} diff --git a/src/custom/utils/misc.ts b/src/custom/utils/misc.ts index eb781aad42..15f59279ef 100644 --- a/src/custom/utils/misc.ts +++ b/src/custom/utils/misc.ts @@ -1,6 +1,7 @@ import { SupportedChainId as ChainId } from 'constants/chains' import { Market } from 'types/index' import { OrderKind } from '@cowprotocol/contracts' +import { Percent } from '@uniswap/sdk-core' const PROVIDER_REJECT_REQUEST_CODE = 4001 // See https://eips.ethereum.org/EIPS/eip-1193 const PROVIDER_REJECT_REQUEST_ERROR_MESSAGES = ['User denied message signature', 'User rejected the transaction'] @@ -157,3 +158,11 @@ export function isRejectRequestProviderError(error: any) { return false } + +/** + * Helper function that transforms a Percent instance into the correspondent BIPS value as a string + * @param percent + */ +export function percentToBips(percent: Percent): string { + return percent.multiply('100').toSignificant() +} diff --git a/src/custom/utils/price.ts b/src/custom/utils/price.ts index 9d40639d17..fc359803b6 100644 --- a/src/custom/utils/price.ts +++ b/src/custom/utils/price.ts @@ -208,11 +208,12 @@ export async function getBestPrice( */ export async function getFullQuote({ quoteParams }: { quoteParams: LegacyFeeQuoteParams }): Promise { const { kind } = quoteParams - const { quote, expiration: expirationDate } = await getQuote(quoteParams) + const { quote, expiration: expirationDate, id: quoteId } = await getQuote(quoteParams) const price = { amount: kind === OrderKind.SELL ? quote.buyAmount : quote.sellAmount, token: kind === OrderKind.SELL ? quote.buyToken : quote.sellToken, + quoteId: quoteId ?? undefined, } const fee = { amount: quote.feeAmount, diff --git a/src/custom/utils/signatures.ts b/src/custom/utils/signatures.ts index c1f648de0a..185c79fe0a 100644 --- a/src/custom/utils/signatures.ts +++ b/src/custom/utils/signatures.ts @@ -48,6 +48,7 @@ export interface OrderCreation extends UnsignedOrder { // - Signature: EIP-712,ETHSIGN // - Owner address: for PRESIGN signature: string // 65 bytes encoded as hex without `0x` prefix. r + s + v from the spec + quoteId?: number | null // TODO: replace all of this with SDK. Next PR } export interface SingOrderCancellationParams { diff --git a/src/custom/utils/trade.ts b/src/custom/utils/trade.ts index 017038d6f3..083c0e4789 100644 --- a/src/custom/utils/trade.ts +++ b/src/custom/utils/trade.ts @@ -28,6 +28,7 @@ export interface PostOrderParams { recipientAddressOrName: string | null allowsOffchainSigning: boolean appDataHash: string + quoteId?: number } function _getSummary(params: PostOrderParams): string { @@ -72,6 +73,7 @@ export async function signAndPostOrder(params: PostOrderParams): Promise + ) } @@ -79,25 +82,27 @@ ReactDOM.render( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + , document.getElementById('root') diff --git a/src/state/application/reducer.ts b/src/state/application/reducer.ts index 5d91a9af13..9703abde9a 100644 --- a/src/state/application/reducer.ts +++ b/src/state/application/reducer.ts @@ -2,7 +2,8 @@ import { createSlice, nanoid } from '@reduxjs/toolkit' import { DEFAULT_TXN_DISMISS_MS } from 'constants/misc' import { SupportedChainId } from 'constants/chains' -import { FlattenInterpolation, ThemeProps, DefaultTheme } from 'styled-components/macro' // mod +import { FlattenInterpolation, ThemeProps, DefaultTheme } from 'styled-components/macro' +import { initialState } from 'state/application/initialState' // mod type BasePopupContent = // | { @@ -18,7 +19,7 @@ type BasePopupContent = } // MOD: Modified PopupContent. The mod happened directly in the src file, to avoid redefining the state/hoos/etc -export type PopupContent = (TxPopupContent | MetaTxPopupContent | BasePopupContent) & { +export type PopupContent = (TxPopupContent | MetaTxPopupContent | BasePopupContent | WarningPopupContent) & { // mod: custom styles styles?: FlattenInterpolation> } @@ -39,6 +40,8 @@ export interface MetaTxPopupContent { } } +export type WarningPopupContent = { warning: string } + export enum ApplicationModal { WALLET, SETTINGS, @@ -67,11 +70,11 @@ export interface ApplicationState { readonly popupList: PopupList } -const initialState: ApplicationState = { +/* const initialState: ApplicationState = { chainId: null, openModal: null, popupList: [], -} +} */ const applicationSlice = createSlice({ name: 'application', diff --git a/yarn.lock b/yarn.lock index 8be02cce79..994233646c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1198,10 +1198,10 @@ resolved "https://registry.yarnpkg.com/@cowprotocol/cow-runner-game/-/cow-runner-game-0.2.9.tgz#3f94b3f370bd114f77db8b1d238cba3ef4e9d644" integrity sha512-rX7HnoV+HYEEkBaqVUsAkGGo0oBrExi+d6Io+8nQZYwZk+IYLmS9jdcIObsLviM2h4YX8+iin6NuKl35AaiHmg== -"@cowprotocol/cow-sdk@^0.0.14": - version "0.0.14" - resolved "https://registry.yarnpkg.com/@cowprotocol/cow-sdk/-/cow-sdk-0.0.14.tgz#03a04ae3b9dce130368bf9876e500a1a7a2c8e40" - integrity sha512-mNrKR0FIifrjpczNlpHXkbNY9reYrotZ+79j58nYk2Qr+CpsQiGv6vc9736uFTmWOCMFr8qGQae5eApJGotYmw== +"@cowprotocol/cow-sdk@^0.0.15-RC.0": + version "0.0.15-RC.0" + resolved "https://registry.yarnpkg.com/@cowprotocol/cow-sdk/-/cow-sdk-0.0.15-RC.0.tgz#b4403ea4c4b57df79dcb466177e9d22b0006b427" + integrity sha512-lJSXk5/QnKasVEFlHScVaug5LBkjxdKmYDfrIvSc7eps5Kuoly2FxwIvIW2pQ80Qp34nYt1yGHmcawlKyWBTfA== dependencies: "@cowprotocol/contracts" "^1.3.1" ajv "^8.8.2"