diff --git a/package.json b/package.json index 0581309b..097a7ace 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "zkbob-ui", - "version": "3.1.1", + "version": "3.2.0", "private": true, "dependencies": { "@dicebear/avatars": "^4.10.2", "@dicebear/avatars-bottts-sprites": "^4.10.2", "@dicebear/avatars-identicon-sprites": "^4.10.2", "@dicebear/pixel-art": "^4.10.2", + "@lifi/sdk": "^2.2.3", "@lifi/widget": "^2.1.2", "@sentry/react": "^7.16.0", "@sentry/tracing": "^7.16.0", diff --git a/public/451.html b/public/451.html index a20d724b..2285c39a 100644 --- a/public/451.html +++ b/public/451.html @@ -3,14 +3,14 @@ - zkBob - Private Stable Transfers - - - - - - - + zkBob - Your web3 wallet with privacy option for everyday use + + + + + + + diff --git a/public/index.html b/public/index.html index d3f15488..1186797c 100644 --- a/public/index.html +++ b/public/index.html @@ -12,14 +12,14 @@ - zkBob - Private Stable Transfers - - - - - - - + zkBob - Your web3 wallet with privacy option for everyday use + + + + + + + diff --git a/public/manifest.json b/public/manifest.json index 06827cc6..007d9bc6 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,6 +1,6 @@ { "short_name": "zkBob", - "name": "zkBob - Private Stable Transfers", + "name": "zkBob - Your web3 wallet with privacy option for everyday use", "icons": [ { "src": "favicon.ico", diff --git a/src/App.js b/src/App.js index b145bd4e..ff626f5d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,7 +1,5 @@ import { createGlobalStyle } from 'styled-components'; -import ContextsProvider from 'contexts'; - import ThemeProvider from 'providers/ThemeProvider'; import Web3Provider from 'providers/Web3Provider'; @@ -35,6 +33,9 @@ const GlobalStyle = createGlobalStyle` src: url(${GilroyExtraBold}) format('woff'); font-weight: 800; } + html { + -webkit-tap-highlight-color: transparent; + } body { margin: 0; font-family: 'Gilroy'; @@ -65,9 +66,7 @@ export default () => ( - - - + ); diff --git a/src/assets/polygon.svg b/src/assets/polygon.svg index f64ba0e3..1a7dc2f5 100644 --- a/src/assets/polygon.svg +++ b/src/assets/polygon.svg @@ -1,4 +1,10 @@ - - - + + + + + + + + + diff --git a/src/assets/try-zkbob-banner.svg b/src/assets/try-zkbob-banner.svg new file mode 100644 index 00000000..ea4d5860 --- /dev/null +++ b/src/assets/try-zkbob-banner.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Dropdown/index.js b/src/components/Dropdown/index.js index 57d3e394..a15b9312 100644 --- a/src/components/Dropdown/index.js +++ b/src/components/Dropdown/index.js @@ -1,37 +1,99 @@ -import { useState } from 'react'; +import styled, { createGlobalStyle } from 'styled-components'; import Tooltip from 'rc-tooltip'; import 'rc-tooltip/assets/bootstrap.css'; -export default ({ children, content, disabled, width, placement, style = {}, ...props }) => { - const [isVisible, setIsVisible] = useState(false); +import { ReactComponent as CrossIconDefault } from 'assets/cross.svg'; + +const GlobalDropdownStyle = createGlobalStyle` + .rc-tooltip { + opacity: 1; + } + .dropdown-fullscreen { + @media only screen and (max-width: 560px) { + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + padding: 0 !important; + height: 100dvh !important; + + .rc-tooltip-content { + height: 100% !important; + } + + .rc-tooltip-inner { + width: 100% !important; + height: 100% !important; + border-radius: 0 !important; + overflow-y: scroll !important; + padding-top: 50px !important; + + & > div { + min-height: 100% !important; + justify-content: center !important; + } + } + } + } +`; + +const GlobalBodyFullscreenStyle = createGlobalStyle` + body { + overflow: hidden; + position: fixed; + width: 100%; + height: 100%; + } +`; + +export default ({ + children, content, disabled, width, placement, + style = {}, isOpen, open, close, fullscreen = true, ...props +}) => { return ( - { - setIsVisible(true); - }} - overlayInnerStyle={{ - minHeight: 0, - padding: '26px 24px', - borderRadius: '16px', - backgroundColor: '#FFFFFF', - width: width || 370, - maxWidth: 'calc(100vw - 10px)', - boxSizing: 'border-box', - boxShadow: '4px 10px 20px rgba(0, 0, 0, 0.1)', - ...style, - }} - overlayStyle={{ - opacity: isVisible ? 1 : 0, - }} - destroyTooltipOnHide={{ keepParent: true }} - {...props} - > - {children} - + <> + + {fullscreen && isOpen && } + ( + <> + {fullscreen && } + {content()} + + )} + showArrow={false} + overlayInnerStyle={{ + minHeight: 0, + padding: '26px 24px', + borderRadius: '16px', + backgroundColor: '#FFFFFF', + width: width || 370, + boxSizing: 'border-box', + boxShadow: '4px 10px 20px rgba(0, 0, 0, 0.1)', + ...style, + }} + destroyTooltipOnHide={{ keepParent: true }} + visible={isOpen} + onVisibleChange={visible => visible ? open() : close()} + {...props} + > + {children} + + ); } + +const CrossIcon = styled(CrossIconDefault)` + display: none; + position: absolute; + top: 11px; + right: 11px; + cursor: pointer; + padding: 10px; + @media only screen and (max-width: 560px) { + display: block; + } +`; diff --git a/src/components/Header/index.js b/src/components/Header/index.js index 20242f33..fddfa8a1 100644 --- a/src/components/Header/index.js +++ b/src/components/Header/index.js @@ -1,6 +1,7 @@ -import React, { useRef } from 'react'; +import React, { useContext, useCallback } from 'react'; import styled from 'styled-components'; import { ethers } from 'ethers'; +import { useAccount } from 'wagmi'; import ButtonDefault from 'components/Button'; import { ZkAvatar } from 'components/ZkAccountIdentifier'; @@ -20,6 +21,11 @@ import { shortAddress, formatNumber } from 'utils'; import { NETWORKS, CONNECTORS_ICONS, TOKENS_ICONS } from 'constants'; import { useWindowDimensions } from 'hooks'; +import { + ZkAccountContext, ModalContext, + TokenBalanceContext, PoolContext, +} from 'contexts'; + const { parseUnits } = ethers.utils; const formatBalance = (balance, tokenDecimals, isMobile) => { @@ -34,18 +40,22 @@ const BalanceSkeleton = isMobile => ( /> ); -export default ({ - openWalletModal, connector, isLoadingZkAccount, empty, - openAccountSetUpModal, account, zkAccount, openConfirmLogoutModal, - balance, nativeBalance, poolBalance, refresh, isLoadingBalance, getSeed, - openSwapModal, generateAddress, openChangePasswordModal, - openSeedPhraseModal, isDemo, disconnect, isLoadingState, openDisablePasswordModal, - switchToPool, currentPool, initializeGiftCard, isPoolSwitching, -}) => { - const walletButtonRef = useRef(null); - const zkAccountButtonRef = useRef(null); - const networkButtonRef = useRef(null); - const moreButtonRef = useRef(null); +export default ({ empty }) => { + const { address: account, connector } = useAccount(); + const { balance, nativeBalance, updateBalance, isLoadingBalance } = useContext(TokenBalanceContext); + const { + zkAccount, isLoadingZkAccount, balance: poolBalance, + updatePoolData, isPoolSwitching, isLoadingState, + } = useContext(ZkAccountContext); + const { openWalletModal, openAccountSetUpModal, openSwapModal } = useContext(ModalContext); + const { currentPool } = useContext(PoolContext); + + const refresh = useCallback(e => { + e.stopPropagation(); + updateBalance(); + updatePoolData(); + }, [updateBalance, updatePoolData]); + const { width } = useWindowDimensions(); const isMobile = width <= 800; @@ -61,13 +71,8 @@ export default ({ } const networkDropdown = ( - - + + @@ -81,18 +86,8 @@ export default ({ ); const walletDropdown = account ? ( - - + + {connector && }
{shortAddress(account)}
@@ -121,23 +116,8 @@ export default ({ ); const zkAccountDropdown = zkAccount ? ( - - + +
zkAccount
@@ -173,41 +153,35 @@ export default ({ - - {networkDropdown} - + {!isMobile && networkDropdown} Get {currentPool.tokenSymbol} - - {walletDropdown} - - - {zkAccountDropdown} - - {zkAccount && ( - - - {(isLoadingBalance || isLoadingState) ? ( - - ) : ( - - )} - - + {!isMobile && walletDropdown} + {!isMobile && zkAccountDropdown} + {(zkAccount && !isMobile) && ( + + {(isLoadingBalance || isLoadingState) ? ( + + ) : ( + + )} + )} - - + +
- - {networkDropdown} - {walletDropdown} - {zkAccountDropdown} - + {isMobile && ( + + {networkDropdown} + {walletDropdown} + {zkAccountDropdown} + + )} ); } @@ -219,12 +193,6 @@ const Row = styled.div` position: relative; `; -const OnlyDesktop = styled.div` - @media only screen and (max-width: 800px) { - display: none; - } -`; - const OnlyMobile = styled.div` display: none; position: fixed; @@ -235,19 +203,17 @@ const OnlyMobile = styled.div` padding: 0 7px; background: #fff; z-index: 1; - @media only screen and (max-width: 800px) { - display: flex; - align-items: center; - justify-content: space-between; - & > * { - margin-right: 2px; - margin-left: 2px; - &:last-child { - margin-right: 0; - } - &:first-child { - margin-left: 0; - } + display: flex; + align-items: center; + justify-content: space-between; + & > * { + margin-right: 2px; + margin-left: 2px; + &:last-child { + margin-right: 0; + } + &:first-child { + margin-left: 0; } } `; diff --git a/src/components/HistoryItem/index.js b/src/components/HistoryItem/index.js index cb6a865b..7fcc1e84 100644 --- a/src/components/HistoryItem/index.js +++ b/src/components/HistoryItem/index.js @@ -129,7 +129,8 @@ export default ({ item, zkAccount, currentPool }) => { }, []); const isPending = [0, 1].includes(item.state); - const isDirectDepositLabelShown = item.type === DirectDeposit && !currentPool.isNative; + // const isDirectDepositLabelShown = item.type === DirectDeposit && !currentPool.isNative; + const isDirectDepositLabelShown = false; return ( diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js new file mode 100644 index 00000000..91d9febd --- /dev/null +++ b/src/components/Layout/index.js @@ -0,0 +1,53 @@ +import React from 'react'; +import styled from 'styled-components'; + +export default ({ header, footer, children }) => ( + <> + + + {header} + + {children} + + {footer} + + +); + +const Layout = styled.div` + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: 14px 40px 40px; + background: linear-gradient(180deg, #FBEED0 0%, #FAFAF9 78.71%); + @media only screen and (max-width: 560px) { + padding: 21px 7px 28px; + } + @media only screen and (max-width: 800px) { + padding-bottom: 80px; +`; + +const PageContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + flex: 1; + margin: 80px 0; + position: relative; + @media only screen and (max-width: 560px) { + margin: 30px 0; + } +`; + +const Gradient = styled.div` + position: absolute; + width: 544px; + height: 585.08px; + left: calc(50% - 270px); + top: 100px; + background: linear-gradient(211.28deg, #F7C23B 19.66%, rgba(232, 110, 255, 0.5) 57.48%, rgba(255, 255, 255, 0.5) 97.74%); + background: -moz-linear-gradient(231.28deg, rgba(247, 194, 59, 0.2) 19.66%, rgba(232, 110, 255, 0.2) 57.48%, rgba(255, 255, 255, 0.5) 97.74%); + filter: blur(250px); + transform: rotate(27.74deg) translate3d(0,0,0); +`; diff --git a/src/components/Limits/index.js b/src/components/Limits/index.js index 0dbda353..1c20f8bf 100644 --- a/src/components/Limits/index.js +++ b/src/components/Limits/index.js @@ -8,7 +8,7 @@ import { ReactComponent as InfoIconDefault } from 'assets/info.svg'; import { formatNumber } from 'utils'; -const Limit = ({ value, loading, currentPool }) => { +const Limit = ({ value, loading, currentPool, qty }) => { if (!value || loading) { return } @@ -24,7 +24,7 @@ const Limit = ({ value, loading, currentPool }) => { ) : ( - + 1 ? 23 : 0 }}> {formatNumber(value, currentPool.tokenDecimals)} {currentPool.tokenSymbol} ); @@ -39,7 +39,7 @@ export default ({ limits, loading, currentPool }) => { {prefix}{' '} {suffix} - +
))} @@ -86,6 +86,7 @@ const Value = styled.span` color: ${props => props.theme.text.color.primary}; font-weight: ${props => props.theme.text.weight.bold}; font-size: 14px; + margin-left: 10px; `; const InfoIcon = styled(InfoIconDefault)` diff --git a/src/components/Modal/index.js b/src/components/Modal/index.js index 9d53fe66..e3355bd2 100644 --- a/src/components/Modal/index.js +++ b/src/components/Modal/index.js @@ -1,49 +1,61 @@ import React from 'react'; -import styled, { useTheme } from 'styled-components'; +import styled, { createGlobalStyle } from 'styled-components'; import Modal from 'react-modal'; import { ReactComponent as CrossIconDefault } from 'assets/cross.svg'; import { ReactComponent as BackIconDefault } from 'assets/back.svg'; +const GlobalStyle = createGlobalStyle` + .ReactModal__Content { + top: 50% !important; + left: 50% !important; + right: auto !important; + bottom: auto !important; + margin-right: -50% !important; + transform: translate(-50%, -50%) !important; + padding: 0 !important; + border: 0 !important; + border-radius: 24px !important; + background: ${({ theme }) => theme.modal.background} !important; + opacity: 1 !important; + @media only screen and (max-width: 560px) { + width: 100% !important; + height: 100% !important; + border-radius: 0 !important; + } + } + .ReactModal__Overlay { + background: ${({ theme }) => theme.modal.overlay} !important; + z-index: 1 !important; + } + .ReactModal__Body--open { + overflow: hidden; + position: fixed; + width: 100%; + height: 100%; + } +`; + export default ({ children, isOpen, onClose, title, onBack, width, style, containerStyle }) => { - const theme = useTheme(); - const customStyles = { - content: { - top: '50%', - left: '50%', - right: 'auto', - bottom: 'auto', - marginRight: '-50%', - transform: 'translate(-50%, -50%)', - padding: '0', - border: '0', - borderRadius: '24px', - background: theme.modal.background, - opacity: '1', - maxWidth: 'calc(100% - 14px)', - maxHeight: 'calc(100% - 14px)', - }, - overlay: { - background: theme.modal.overlay, - zIndex: '1', - ...containerStyle, - }, - }; + const customStyles = { overlay: { ...containerStyle } }; return ( - - - {title} - {onBack && } - {onClose && } - {children} - - + <> + + + + {title} + {onBack && } + {onClose && } + {children} + + + ); }; @@ -56,8 +68,12 @@ const ModalContent = styled.div` padding: 26px; box-sizing: border-box; position: relative; - @media only screen and (max-width: 420px) { + @media only screen and (max-width: 560px) { padding: 26px 13px; + width: 100%; + height: 100%; + max-height: 100%; + justify-content: center; } `; @@ -72,9 +88,13 @@ const Title = styled.span` const CrossIcon = styled(CrossIconDefault)` position: absolute; - top: 31px; - right: 21px; + top: 21px; + right: 11px; cursor: pointer; + padding: 10px; + @media only screen and (max-width: 560px) { + top: 11px; + } `; const BackIcon = styled(BackIconDefault)` @@ -83,4 +103,7 @@ const BackIcon = styled(BackIconDefault)` left: 11px; cursor: pointer; padding: 10px; + @media only screen and (max-width: 560px) { + top: 11px; + } `; diff --git a/src/components/MoreDropdown/index.js b/src/components/MoreDropdown/index.js index 63aaade4..2170223c 100644 --- a/src/components/MoreDropdown/index.js +++ b/src/components/MoreDropdown/index.js @@ -1,15 +1,18 @@ +import { useContext } from 'react'; import styled from 'styled-components'; import Dropdown from 'components/Dropdown'; import OptionButton from 'components/OptionButton'; +import { ModalContext } from 'contexts'; + const links = [ { name: 'Dune Analytics', href: 'https://dune.com/projects/zkBob' }, { name: 'Documentation', href: 'https://docs.zkbob.com/' }, { name: 'Linktree', href: 'https://linktr.ee/zkbob' }, ]; -const Content = ({ buttonRef }) => ( +const Content = ({ close }) => ( More about zkBob {links.map((link, index) => @@ -17,7 +20,7 @@ const Content = ({ buttonRef }) => ( key={index} type="link" href={link.href} - onClick={() => buttonRef.current.click()} + onClick={close} > {link.name} @@ -25,15 +28,19 @@ const Content = ({ buttonRef }) => ( ); -export default ({ buttonRef, openSwapModal, children, currentPool }) => ( - - - } - > - {children} - -); +export default ({ children }) => { + const { isMoreDropdownOpen, openMoreDropdown, closeMoreDropdown } = useContext(ModalContext); + return ( + } + > + {children} + + ); +}; const Container = styled.div` display: flex; diff --git a/src/components/NetworkDropdown/index.js b/src/components/NetworkDropdown/index.js index 9d14719f..624e0126 100644 --- a/src/components/NetworkDropdown/index.js +++ b/src/components/NetworkDropdown/index.js @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useContext } from 'react'; import styled from 'styled-components'; import Dropdown from 'components/Dropdown'; @@ -11,6 +11,8 @@ import { NETWORKS, TOKENS_ICONS } from 'constants'; import config from 'config'; +import { ZkAccountContext, ModalContext, PoolContext } from 'contexts'; + const chainIds = Object.keys(config.chains).map(chainId => Number(chainId)); const poolsWithAliases = Object.values(config.pools).map((pool, index) => ({ ...pool, @@ -23,7 +25,7 @@ const poolsByChainId = chainIds.map(chainId => { }; }); -const Content = ({ switchToPool, currentPool, buttonRef }) => { +const Content = ({ switchToPool, currentPool, close }) => { const [openedChainId, setOpenedChainId] = useState(currentPool.chainId); const showPools = useCallback(chainId => { @@ -35,9 +37,9 @@ const Content = ({ switchToPool, currentPool, buttonRef }) => { }, [openedChainId]); const onSwitchPool = useCallback(poolId => { - buttonRef.current.click(); + close(); switchToPool(poolId); - }, [switchToPool, buttonRef]); + }, [switchToPool, close]); return ( @@ -88,18 +90,30 @@ const Content = ({ switchToPool, currentPool, buttonRef }) => { ); }; -export default ({ disabled, switchToPool, currentPool, buttonRef, children }) => ( - ( - - )} - > - {children} - -); +export default ({ children }) => { + const { isPoolSwitching, isLoadingState, switchToPool } = useContext(ZkAccountContext); + const { isNetworkDropdownOpen, openNetworkDropdown, closeNetworkDropdown } = useContext(ModalContext); + const { currentPool } = useContext(PoolContext); + return ( + ( + + )} + > + {children} + + ); +}; const Container = styled.div` display: flex; diff --git a/src/components/PaymentLinkModal/index.js b/src/components/PaymentLinkModal/index.js new file mode 100644 index 00000000..af953884 --- /dev/null +++ b/src/components/PaymentLinkModal/index.js @@ -0,0 +1,139 @@ +import React, { useContext, useCallback, useState, useEffect } from 'react'; +import styled from 'styled-components'; +import { CopyToClipboard } from 'react-copy-to-clipboard'; + +import Modal from 'components/Modal'; +import Tooltip from 'components/Tooltip'; +import Link from 'components/Link'; + +import { ReactComponent as CopyIconDefault } from 'assets/copy.svg'; +import { ReactComponent as CheckIcon } from 'assets/check.svg'; + +import { ModalContext, ZkAccountContext, PoolContext } from 'contexts'; + +export default () => { + const { currentPool } = useContext(PoolContext); + const { isPaymentLinkModalOpen, closePaymentLinkModal } = useContext(ModalContext); + const { generateAddress } = useContext(ZkAccountContext); + + const [address, setAddress] = useState(''); + const [isCopied, setIsCopied] = useState(false); + const link = `${window.location.origin}/payment/${address}`; + + useEffect(() => { + async function updateAddress() { + const address = await generateAddress(); + setAddress(address); + } + updateAddress(); + }, [generateAddress, currentPool]); + + const onCopy = useCallback((text, result) => { + if (result) { + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } + }, []); + + return ( + + + + Share this link to request a private payment.

+ The sender will have the option to select and send any token.{' '} + Tokens will be converted to {currentPool.tokenSymbol} and deposited to your zkAccount.

+ Note: Private payment links work on the same network where they are generated. + + Copy and share your payment link + + + + {link} + + + {isCopied ? : } + + + + + Get more info about payment links + + + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + box-sizing: border-box; + & > * { + margin-bottom: 16px; + &:last-child { + margin-bottom: 0; + } + } +`; + +const Description = styled.span` + font-size: 16px; + color: ${({ theme }) => theme.text.color.secondary}; + line-height: 24px; + text-align: center; + & > b { + font-weight: ${({ theme }) => theme.text.weight.bold}; + } +`; + +const CopyIcon = styled(CopyIconDefault)``; + +const PaymentLinkContainer = styled.div` + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + overflow: hidden; + border: 1px solid ${props => props.theme.input.border.color.default}; + border-radius: 16px; + background: ${props => props.theme.input.background.secondary}; + color: ${props => props.theme.text.color.primary}; + font-size: 16px; + font-weight: 400; + height: 60px; + box-sizing: border-box; + padding: 0 24px; + outline: none; + cursor: pointer; + &::placeholder { + color: ${props => props.theme.text.color.secondary}; + } + &:hover ${CopyIcon} { + path { + fill: ${props => props.theme.color.purple}; + } + } +`; + +const LinkText = styled.span` + flex: 1; + max-width: 100%; + padding-right: 10px; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + white-space: nowrap; +`; + +const InputLabel = styled.span` + font-size: 16px; + color: ${({ theme }) => theme.text.color.primary}; + font-weight: ${({ theme }) => theme.text.weight.bold}; + margin-bottom: 10px; + margin-top: 10px; +`; diff --git a/src/components/TokenListModal/index.js b/src/components/TokenListModal/index.js new file mode 100644 index 00000000..d9166f58 --- /dev/null +++ b/src/components/TokenListModal/index.js @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; + +import Modal from 'components/Modal'; +import Input from 'components/Input'; +import { useMemo } from 'react'; + +const ListItem = ({ token, onClick }) => ( + + + + + {token.symbol} + {token.name} + + + +); + +export default ({ isOpen, onClose, tokens, onSelect }) => { + const [search, setSearch] = useState(''); + + const filteredTokens = useMemo(() => + tokens.filter(token => + token.symbol.toLowerCase().includes(search.toLowerCase()) || + token.name.toLowerCase().includes(search.toLowerCase()) + ), + [tokens, search] + ); + + const handleClose = () => { + setSearch(''); + onClose(); + }; + + const handleSelect = token => { + setSearch(''); + onSelect(token); + }; + + return ( + + + setSearch(e.target.value)} + style={{ height: 50 }} + /> + + {filteredTokens.map((token, index) => ( + handleSelect(token)} /> + ))} + + {!filteredTokens.length && ( + No tokens found + )} + + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + max-height: 390px; + box-sizing: border-box; + & > * { + margin-bottom: 16px; + &:last-child { + margin-bottom: 0; + } + } + @media only screen and (max-width: 560px) { + max-height: calc(100% - 30px); + } +`; + +const TokenIcon = styled.img` + width: 30px; + height: 30px; + border-radius: 50%; + margin-right: 16px; +`; + +const ItemInnerContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + padding: 7px 10px; +`; + +const ItemContainer = styled.div` + padding: 7px 0; + cursor: pointer; + &:hover ${ItemInnerContainer} { + background-color: ${({ theme }) => theme.walletConnectorOption.background.hover}; + border-radius: 16px; + } +`; + +const Column = styled.div` + display: flex; + flex-direction: column; +`; + +const List = styled.div` + display: flex; + flex-direction: column; + overflow: scroll; + margin: 0 -10px; +`; + +const TokenSymbol = styled.span` + font-size: 16px; + line-height: 20px; + color: ${props => props.theme.text.color.primary}; + font-weight: ${props => props.theme.text.weight.bold}; +`; + +const TokenName = styled.span` + font-size: 14px; + color: ${props => props.theme.text.color.secondary}; + font-weight: ${props => props.theme.text.weight.normal}; +`; + +const Text = styled.span` + font-size: 16px; + color: ${props => props.theme.text.color.secondary}; + line-height: 20px; + text-align: center; +`; diff --git a/src/components/TransactionModal/index.js b/src/components/TransactionModal/index.js index e21e390b..51252c4a 100644 --- a/src/components/TransactionModal/index.js +++ b/src/components/TransactionModal/index.js @@ -4,6 +4,7 @@ import styled from 'styled-components'; import Modal from 'components/Modal'; import Spinner from 'components/Spinner'; import Button from 'components/Button'; +import Link from 'components/Link'; import { TX_STATUSES, NETWORKS } from 'constants'; @@ -30,6 +31,8 @@ const titles = { [TX_STATUSES.SUSPICIOUS_ACCOUNT_WITHDRAWAL]: 'Suspicious recipient address', [TX_STATUSES.WRONG_NETWORK]: 'Wrong network', [TX_STATUSES.SWITCH_NETWORK]: 'Please switch the network', + [TX_STATUSES.SENT]: 'Your payment was sent', + [TX_STATUSES.PREPARING_TRANSACTION]: 'Preparing transaction', }; const descriptions = { @@ -82,6 +85,14 @@ const descriptions = { Your approval was successful. Now you can deposit your tokens. ), + [TX_STATUSES.SENT]: ({ currentPool, txHash }) => ( + + Your payment will be proceed during 10 minutes.
+ + View the transaction + +
+ ), }; const SUCCESS_STATUSES = [ @@ -90,6 +101,7 @@ const SUCCESS_STATUSES = [ TX_STATUSES.TRANSFERRED_MULTI, TX_STATUSES.WITHDRAWN, TX_STATUSES.APPROVED, + TX_STATUSES.SENT, ]; const FAILURE_STATUSES = [ TX_STATUSES.REJECTED, @@ -101,7 +113,7 @@ const SUSPICIOUS_ACCOUNT_STATUSES = [ TX_STATUSES.SUSPICIOUS_ACCOUNT_WITHDRAWAL, ]; -export default ({ isOpen, onClose, status, amount, error, supportId, currentPool }) => { +export default ({ isOpen, onClose, status, amount, error, supportId, currentPool, txHash }) => { return ( ; })()} {descriptions[status] && ( - {descriptions[status]({ amount, currentPool })} + {descriptions[status]({ amount, currentPool, txHash })} )} {(status === TX_STATUSES.REJECTED && error) && ( {error} diff --git a/src/components/TransferInput/Select/index.js b/src/components/TransferInput/Select/index.js index d8ba811a..bfc9d49d 100644 --- a/src/components/TransferInput/Select/index.js +++ b/src/components/TransferInput/Select/index.js @@ -1,4 +1,4 @@ -import React, { useRef, useCallback } from 'react'; +import React, { useCallback, useContext } from 'react'; import styled from 'styled-components'; import OptionButtonDefault from 'components/OptionButton'; @@ -8,13 +8,15 @@ import { ReactComponent as DropdownIconDefault } from 'assets/dropdown.svg'; import { TOKENS_ICONS } from 'constants'; +import { ModalContext } from 'contexts'; + const getTokenSymbol = (tokenSymbol, isNative) => (isNative ? '' : 'W') + tokenSymbol; -const Content = ({ tokenSymbol, isNativeSelected, onTokenSelect, buttonRef }) => { +const Content = ({ tokenSymbol, isNativeSelected, onTokenSelect, close }) => { const onSelect = useCallback(isNative => { onTokenSelect(isNative); - buttonRef.current.click(); - }, [onTokenSelect, buttonRef]); + close(); + }, [onTokenSelect, close]); return ( @@ -38,21 +40,25 @@ const Content = ({ tokenSymbol, isNativeSelected, onTokenSelect, buttonRef }) => }; export default ({ tokenSymbol, isNativeSelected, onTokenSelect }) => { - const buttonRef = useRef(null); + const { isTokenSelectorOpen, openTokenSelector, closeTokenSelector } = useContext(ModalContext); return ( ( )} > - + {getTokenSymbol(tokenSymbol, isNativeSelected)} diff --git a/src/components/WalletDropdown/index.js b/src/components/WalletDropdown/index.js index f0a7b3d4..b2eb0f57 100644 --- a/src/components/WalletDropdown/index.js +++ b/src/components/WalletDropdown/index.js @@ -1,6 +1,7 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useContext } from 'react'; import styled from 'styled-components'; import { CopyToClipboard } from 'react-copy-to-clipboard'; +import { useAccount, useDisconnect } from 'wagmi'; import Dropdown from 'components/Dropdown'; import Tooltip from 'components/Tooltip'; @@ -14,6 +15,8 @@ import { formatNumber } from 'utils'; import { CONNECTORS_ICONS, NETWORKS, TOKENS_ICONS } from 'constants'; +import { ModalContext, TokenBalanceContext, PoolContext } from 'contexts'; + const Balance = ({ tokenSymbol, balance, isWrapped, isNative, tokenDecimals }) => ( @@ -31,7 +34,7 @@ const Balance = ({ tokenSymbol, balance, isWrapped, isNative, tokenDecimals }) = const Content = ({ address, balance, nativeBalance, connector, changeWallet, - disconnect, buttonRef, currentPool, + disconnect, close, currentPool, }) => { const [isCopied, setIsCopied] = useState(false); @@ -43,38 +46,40 @@ const Content = ({ }, []); const onChangeWallet = useCallback(() => { - buttonRef.current.click(); + close(); changeWallet(); - }, [changeWallet, buttonRef]); + }, [changeWallet, close]); const onDisconnect = useCallback(() => { - buttonRef.current.click(); + close(); disconnect(); - }, [disconnect, buttonRef]); + }, [disconnect, close]); return ( Wallet - - {currentPool.isNative && ( - <> - - + - - )} - - + {currentPool && ( + + {currentPool.isNative && ( + <> + + + + + )} + + + )} @@ -85,41 +90,52 @@ const Content = ({ - - View in Explorer - + {currentPool && ( + + View in Explorer + + )} Change wallet Disconnect ); }; -export default ({ - address, balance, nativeBalance, connector, changeWallet, - disconnect, buttonRef, children, disabled, - currentPool, -}) => ( - ( - - )} - > - {children} - -); +export default ({ children }) => { + const { address, connector } = useAccount(); + const { disconnect } = useDisconnect(); + const { balance, nativeBalance, isLoadingBalance } = useContext(TokenBalanceContext); + const { + openWalletModal, isWalletDropdownOpen, + openWalletDropdown, closeWalletDropdown, + } = useContext(ModalContext); + const { currentPool } = useContext(PoolContext); + return ( + ( + + )} + > + {children} + + ); +}; const Container = styled.div` display: flex; diff --git a/src/components/WalletModal/index.js b/src/components/WalletModal/index.js index a2edb13a..ad2c33a9 100644 --- a/src/components/WalletModal/index.js +++ b/src/components/WalletModal/index.js @@ -8,11 +8,13 @@ import WalletConnectors from 'components/WalletConnectors'; export default ({ isOpen, close, currentPool }) => { return ( - - Connect your wallet to deposit {currentPool.tokenSymbol} into your zkAccount.{' '} - If you are creating a new zkAccount, your wallet is used{' '} - to derive a private encryption key for the zkBob application. - + {currentPool && ( + + Connect your wallet to deposit {currentPool.tokenSymbol} into your zkAccount.{' '} + If you are creating a new zkAccount, your wallet is used{' '} + to derive a private encryption key for the zkBob application. + + )} By connecting a wallet, you agree to zkBob
diff --git a/src/components/ZkAccountDropdown/index.js b/src/components/ZkAccountDropdown/index.js index c385d3bf..f6892220 100644 --- a/src/components/ZkAccountDropdown/index.js +++ b/src/components/ZkAccountDropdown/index.js @@ -1,7 +1,6 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useContext } from 'react'; import styled from 'styled-components'; import QRCode from 'react-qr-code'; -import { isMobile } from 'react-device-detect'; import Dropdown from 'components/Dropdown'; import Tooltip from 'components/Tooltip'; @@ -16,11 +15,13 @@ import { formatNumber } from 'utils'; import { TOKENS_ICONS } from 'constants'; +import { ZkAccountContext, ModalContext, PoolContext } from 'contexts'; const Content = ({ balance, generateAddress, getSeed, setPassword, - removePassword, logout, buttonRef, showSeedPhrase, + removePassword, logout, close, showSeedPhrase, isLoadingState, initializeGiftCard, currentPool, + generatePaymentLink, }) => { const [privateAddress, setPrivateAddress] = useState(null); const [showQRCode, setShowQRCode] = useState(false); @@ -48,16 +49,16 @@ const Content = ({ const queryParams = new URLSearchParams(paramsString); const code = queryParams.get('gift-code'); await initializeGiftCard(code); - buttonRef.current.click(); + close(); } catch (error) { console.log(error); } - }, [initializeGiftCard, buttonRef]); + }, [initializeGiftCard, close]); const handleOptionClick = useCallback(action => { - buttonRef.current.click(); + close(); action(); - }, [buttonRef]); + }, [close]); const settingsOptions = [ { text: 'Show secret phrase', action: showSeedPhrase }, @@ -107,11 +108,9 @@ const Content = ({ {currentPool.tokenSymbol}
- {isMobile && - - } + {privateAddress ? ( {privateAddress} ) : ( @@ -124,6 +123,11 @@ const Content = ({ You create a new address each time you connect.{' '} Receive tokens to this address or a previously generated address. + {currentPool.paymentContractAddress && ( + handleOptionClick(generatePaymentLink)}> + Get payment link + + )} Redeem gift card @@ -137,34 +141,46 @@ const Content = ({ ); }; -export default ({ - balance, generateAddress, switchAccount, showSeedPhrase, disabled, - logout, buttonRef, children, isDemo, isLoadingState, currentPool, - initializeGiftCard, getSeed, setPassword, removePassword, -}) => ( - ( - - )} - > - {children} - -); +export default ({ children }) => { + const { + balance: poolBalance, generateAddress, isDemo, + isLoadingState, initializeGiftCard, getSeed, + } = useContext(ZkAccountContext); + const { + openSeedPhraseModal, openAccountSetUpModal, openChangePasswordModal, + openConfirmLogoutModal, openDisablePasswordModal, openPaymentLinkModal, + isZkAccountDropdownOpen, openZkAccountDropdown, closeZkAccountDropdown, + } = useContext(ModalContext); + const { currentPool } = useContext(PoolContext); + return ( + ( + + )} + > + {children} + + ); +}; const Container = styled.div` display: flex; @@ -214,6 +230,9 @@ const BackIcon = styled(BackIconDefault)` left: 11px; cursor: pointer; padding: 10px; + @media only screen and (max-width: 560px) { + top: 11px; + } `; const Title = styled.span` diff --git a/src/config/index.js b/src/config/index.js index d1d27121..55244b5f 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -23,6 +23,8 @@ const config = { timestamp: 1689689468, prevTokenSymbol: 'BOB', }, + addressPrefix: 'zkbob_polygon', + paymentContractAddress: '0x76a911E76fC78F39e73cE0c532F8866ac28Dfe43', }, 'BOB-optimism': { chainId: 10, @@ -36,6 +38,7 @@ const config = { feeDecimals: 2, depositScheme: 'permit', ddSubgraph: 'zkbob-bob-optimism', + addressPrefix: 'zkbob_optimism', }, 'WETH-optimism': { chainId: 10, @@ -54,6 +57,7 @@ const config = { depositScheme: 'permit2', minTxAmount: 1000000n, // 0.001 ETH ddSubgraph: 'zkbob-eth-optimism', + addressPrefix: 'zkbob_optimism_eth', }, }, chains: { @@ -87,6 +91,7 @@ const config = { tokenDecimals: 18, feeDecimals: 2, depositScheme: 'permit', + addressPrefix: 'zkbob_sepolia', }, 'BOB2USDC-goerli': { chainId: 5, @@ -104,6 +109,7 @@ const config = { timestamp: 1688651376, prevTokenSymbol: 'BOB', }, + addressPrefix: 'zkbob_goerli', }, 'USDC-goerli': { chainId: 5, @@ -117,6 +123,7 @@ const config = { feeDecimals: 2, depositScheme: 'usdc-polygon', minTxAmount: 50000n, // 0.05 USDC + addressPrefix: 'zkbob_goerli_usdc', }, 'BOB-op-goerli': { chainId: 420, @@ -129,6 +136,7 @@ const config = { tokenDecimals: 18, feeDecimals: 2, depositScheme: 'permit', + addressPrefix: 'zkbob_goerli_optimism', }, 'WETH-goerli': { chainId: 5, @@ -143,6 +151,7 @@ const config = { depositScheme: 'permit2', minTxAmount: 1000000n, // 0.001 ETH ddSubgraph: 'zkbob-eth-goerli', + addressPrefix: 'zkbob_goerli_eth', }, }, chains: { diff --git a/src/constants/index.js b/src/constants/index.js index e291a92b..d8096949 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -15,6 +15,8 @@ export const TX_STATUSES = { SUSPICIOUS_ACCOUNT_WITHDRAWAL: 'suspicious_account_withdrawal', WRONG_NETWORK: 'wrong_network', SWITCH_NETWORK: 'switch_network', + SENT: 'sent', + PREPARING_TRANSACTION: 'preparing_transaction', }; export const NETWORKS = { @@ -79,3 +81,5 @@ export const INCREASED_LIMITS_STATUSES = { INACTIVE: 'inactive', RESYNC: 'resync', }; + +export const PERMIT2_CONTRACT_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; diff --git a/src/containers/Header/index.js b/src/containers/Header/index.js deleted file mode 100644 index 744f3761..00000000 --- a/src/containers/Header/index.js +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useContext, useCallback } from 'react'; -import { useAccount, useDisconnect } from 'wagmi'; -import Header from 'components/Header'; - -import { - ZkAccountContext, ModalContext, - TokenBalanceContext, PoolContext, -} from 'contexts'; - -export default ({ empty }) => { - const { address, connector } = useAccount(); - const { disconnect } = useDisconnect(); - const { balance, nativeBalance, updateBalance, isLoadingBalance } = useContext(TokenBalanceContext); - const { - zkAccount, isLoadingZkAccount, balance: poolBalance, - updatePoolData, generateAddress, isDemo, isPoolSwitching, - isLoadingState, switchToPool, initializeGiftCard, getSeed, - } = useContext(ZkAccountContext); - const { - openWalletModal, openSeedPhraseModal, - openAccountSetUpModal, openSwapModal, - openChangePasswordModal, openConfirmLogoutModal, - openDisablePasswordModal, - } = useContext(ModalContext); - const { currentPool } = useContext(PoolContext); - - const refresh = useCallback(e => { - e.stopPropagation(); - updateBalance(); - updatePoolData(); - }, [updateBalance, updatePoolData]); - - return ( - <> -
- - ); -}; diff --git a/src/contexts/ModalContext/index.js b/src/contexts/ModalContext/index.js index 6c1d718e..26063f79 100644 --- a/src/contexts/ModalContext/index.js +++ b/src/contexts/ModalContext/index.js @@ -49,6 +49,34 @@ export const ModalContextProvider = ({ children }) => { const openDisablePasswordModal = () => setIsDisablePasswordModalOpen(true); const closeDisablePasswordModal = () => setIsDisablePasswordModalOpen(false); + const [isNetworkDropdownOpen, setIsNetworkDropdownOpen] = useState(false); + const openNetworkDropdown = () => setIsNetworkDropdownOpen(true); + const closeNetworkDropdown = () => setIsNetworkDropdownOpen(false); + + const [isWalletDropdownOpen, setIsWalletDropdownOpen] = useState(false); + const openWalletDropdown = () => setIsWalletDropdownOpen(true); + const closeWalletDropdown = () => setIsWalletDropdownOpen(false); + + const [isZkAccountDropdownOpen, setIsZkAccountDropdownOpen] = useState(false); + const openZkAccountDropdown = () => setIsZkAccountDropdownOpen(true); + const closeZkAccountDropdown = () => setIsZkAccountDropdownOpen(false); + + const [isMoreDropdownOpen, setIsMoreDropdownOpen] = useState(false); + const openMoreDropdown = () => setIsMoreDropdownOpen(true); + const closeMoreDropdown = () => setIsMoreDropdownOpen(false); + + const [isTokenSelectorOpen, setIsTokenSelectorOpen] = useState(false); + const openTokenSelector = () => setIsTokenSelectorOpen(true); + const closeTokenSelector = () => setIsTokenSelectorOpen(false); + + const [isTokenListModalOpen, setIsTokenListModalOpen] = useState(false); + const openTokenListModal = () => setIsTokenListModalOpen(true); + const closeTokenListModal = () => setIsTokenListModalOpen(false); + + const [isPaymentLinkModalOpen, setIsPaymentLinkModalOpen] = useState(false); + const openPaymentLinkModal = () => setIsPaymentLinkModalOpen(true); + const closePaymentLinkModal = () => setIsPaymentLinkModalOpen(false); + const closeAllModals = () => { closeWalletModal(); closeAccountSetUpModal(); @@ -57,6 +85,12 @@ export const ModalContextProvider = ({ children }) => { closeConfirmLogoutModal(); closeSeedPhraseModal(); closeIncreasedLimitsModal(); + closeNetworkDropdown(); + closeWalletDropdown(); + closeZkAccountDropdown(); + closeMoreDropdown(); + closeTokenSelector(); + closePaymentLinkModal(); }; return ( @@ -73,6 +107,13 @@ export const ModalContextProvider = ({ children }) => { isIncreasedLimitsModalOpen, openIncreasedLimitsModal, closeIncreasedLimitsModal, isRedeemGiftCardModalOpen, openRedeemGiftCardModal, closeRedeemGiftCardModal, isDisablePasswordModalOpen, openDisablePasswordModal, closeDisablePasswordModal, + isNetworkDropdownOpen, openNetworkDropdown, closeNetworkDropdown, + isWalletDropdownOpen, openWalletDropdown, closeWalletDropdown, + isZkAccountDropdownOpen, openZkAccountDropdown, closeZkAccountDropdown, + isMoreDropdownOpen, openMoreDropdown, closeMoreDropdown, + isTokenSelectorOpen, openTokenSelector, closeTokenSelector, + isTokenListModalOpen, openTokenListModal, closeTokenListModal, + isPaymentLinkModalOpen, openPaymentLinkModal, closePaymentLinkModal, closeAllModals, }} > diff --git a/src/contexts/TransactionModalContext/index.js b/src/contexts/TransactionModalContext/index.js index 758fd383..e1be5274 100644 --- a/src/contexts/TransactionModalContext/index.js +++ b/src/contexts/TransactionModalContext/index.js @@ -9,6 +9,7 @@ export const TransactionModalContextProvider = ({ children }) => { const [txStatus, setTxStatus] = useState(null); const [isTxModalOpen, setIsTxModalOpen] = useState(false); const [txAmount, setTxAmount] = useState(ethers.constants.Zero); + const [txHash, setTxHash] = useState(null); const [txError, setTxError] = useState(null); const openTxModal = useCallback(() => { setIsTxModalOpen(true); @@ -18,6 +19,7 @@ export const TransactionModalContextProvider = ({ children }) => { setTxStatus(null); setTxAmount(ethers.constants.Zero); setTxError(null); + setTxHash(null); }, []); return ( { isTxModalOpen, openTxModal, closeTxModal, txAmount, setTxAmount, txError, setTxError, + txHash, setTxHash, }} > {children} diff --git a/src/hooks/index.js b/src/hooks/index.js index 6b7da6af..69c06226 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -7,6 +7,7 @@ import useWindowDimensions from 'hooks/useWindowDimensions'; import usePrevious from 'hooks/usePrevious'; import useDisplayedFee from 'hooks/useDisplayedFee'; import useMaxTransferable from './useMaxTransferable'; +import useApproval from './useApproval'; export { useDateFromNow, @@ -18,4 +19,5 @@ export { usePrevious, useDisplayedFee, useMaxTransferable, + useApproval, }; diff --git a/src/hooks/useApproval.js b/src/hooks/useApproval.js new file mode 100644 index 00000000..2b5a3641 --- /dev/null +++ b/src/hooks/useApproval.js @@ -0,0 +1,82 @@ +import { useState, useEffect, useContext, useCallback } from 'react'; +import { ethers } from 'ethers'; +import * as Sentry from '@sentry/react'; +import { useAccount, useSigner, useNetwork, useSwitchNetwork, useProvider } from 'wagmi'; + +import { TransactionModalContext } from 'contexts'; + +import { TX_STATUSES, PERMIT2_CONTRACT_ADDRESS } from 'constants'; +import { useMemo } from 'react'; + + +const TOKEN_ABI = [ + 'function allowance(address, address) pure returns (uint256)', + 'function approve(address, uint256) returns (bool)', +]; + +export default (chainId, tokenAddress, amount, balance) => { + const { openTxModal, closeTxModal, setTxStatus, setTxError } = useContext(TransactionModalContext); + const { address: account } = useAccount(); + const { chain } = useNetwork(); + const { data: signer } = useSigner({ chainId }); + const provider = useProvider({ chainId }); + const { switchNetworkAsync } = useSwitchNetwork({ + chainId, + throwForSwitchChainNotSupported: true, + }); + const [allowance, setAllowance] = useState(ethers.constants.Zero); + + const isApproved = useMemo(() => allowance.gte(amount), [allowance, amount]); + + const updateAllowance = useCallback(async () => { + if (!account || !tokenAddress || tokenAddress === ethers.constants.AddressZero) { + setAllowance(ethers.constants.Zero); + return; + } + const token = new ethers.Contract(tokenAddress, TOKEN_ABI, provider); + token.allowance(account, PERMIT2_CONTRACT_ADDRESS).then(allowance => { + setAllowance(allowance); + }); + }, [account, provider, tokenAddress]); + + useEffect(() => { + updateAllowance(); + }, [updateAllowance, balance]); + + const approve = useCallback(async () => { + try { + openTxModal(); + if (chain.id !== chainId) { + setTxStatus(TX_STATUSES.SWITCH_NETWORK); + try { + await switchNetworkAsync(); + } catch (error) { + console.error(error); + Sentry.captureException(error, { tags: { method: 'hooks.useApproval.approve.switchNetwork' } }); + setTxStatus(TX_STATUSES.WRONG_NETWORK); + return; + } + } + setTxStatus(TX_STATUSES.APPROVE_TOKENS); + const token = new ethers.Contract(tokenAddress, TOKEN_ABI, signer); + const tx = await token.approve(PERMIT2_CONTRACT_ADDRESS, ethers.constants.MaxUint256); + setTxStatus(TX_STATUSES.WAITING_FOR_TRANSACTION); + await tx.wait(); + closeTxModal(); + updateAllowance(); + } catch (error) { + console.error(error); + Sentry.captureException(error, { tags: { method: 'hooks.useApproval.approve' } }); + const message = error.message.includes('user rejected transaction') + ? 'User denied transaction signature' + : error.message; + setTxError(message); + setTxStatus(TX_STATUSES.REJECTED); + } + }, [ + openTxModal, setTxStatus, setTxError, switchNetworkAsync, chain, + signer, updateAllowance, chainId, tokenAddress, closeTxModal, + ]); + + return { isApproved, approve, updateAllowance }; +} diff --git a/src/pages/Deposit/hooks.js b/src/pages/Deposit/hooks.js index 87bc9a1a..115f0062 100644 --- a/src/pages/Deposit/hooks.js +++ b/src/pages/Deposit/hooks.js @@ -1,13 +1,8 @@ -import { useState, useEffect, useContext, useCallback } from 'react'; +import { useState, useEffect } from 'react'; import { ethers } from 'ethers'; import * as Sentry from '@sentry/react'; -import { useAccount, useSigner, useNetwork, useSwitchNetwork, useProvider } from 'wagmi'; - -import { PoolContext, TransactionModalContext } from 'contexts'; import { minBigNumber } from 'utils'; -import { TX_STATUSES } from 'constants'; -import { useMemo } from 'react'; export const useDepositLimit = (limits, isNative) => { const [depositLimit, setDepositLimit] = useState(ethers.constants.Zero); @@ -43,71 +38,3 @@ export const useMaxAmountExceeded = (amount, balance, fee, limit) => { return maxAmountExceeded; }; - -const TOKEN_ABI = [ - 'function allowance(address, address) pure returns (uint256)', - 'function approve(address, uint256) returns (bool)', -]; -const PERMIT2_CONTRACT_ADDRESS = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; - -export const useApproval = (amount, balance) => { - const { openTxModal, setTxStatus, setTxError } = useContext(TransactionModalContext); - const { currentPool } = useContext(PoolContext); - const { address: account } = useAccount(); - const { chain } = useNetwork(); - const { data: signer } = useSigner({ chainId: currentPool.chainId }); - const provider = useProvider({ chainId: currentPool.chainId }); - const { switchNetworkAsync } = useSwitchNetwork({ - chainId: currentPool.chainId, - throwForSwitchChainNotSupported: true, - }); - const [allowance, setAllowance] = useState(ethers.constants.Zero); - - const isApproved = useMemo(() => allowance.gte(amount), [allowance, amount]); - - const updateAllowance = useCallback(async () => { - if (!account) return; - const token = new ethers.Contract(currentPool.tokenAddress, TOKEN_ABI, provider); - token.allowance(account, PERMIT2_CONTRACT_ADDRESS).then(allowance => { - setAllowance(allowance); - }); - }, [account, provider, currentPool.tokenAddress]); - - useEffect(() => { - updateAllowance(); - }, [updateAllowance, balance]); - - const approve = useCallback(async () => { - try { - openTxModal(); - if (chain.id !== currentPool.chainId) { - setTxStatus(TX_STATUSES.SWITCH_NETWORK); - try { - await switchNetworkAsync(); - } catch (error) { - console.error(error); - Sentry.captureException(error, { tags: { method: 'Deposit.useApproval.approve.switchNetwork' } }); - setTxStatus(TX_STATUSES.WRONG_NETWORK); - return; - } - } - setTxStatus(TX_STATUSES.APPROVE_TOKENS); - const token = new ethers.Contract(currentPool.tokenAddress, TOKEN_ABI, signer); - const tx = await token.approve(PERMIT2_CONTRACT_ADDRESS, ethers.constants.MaxUint256); - setTxStatus(TX_STATUSES.WAITING_FOR_TRANSACTION); - await tx.wait(); - setTxStatus(TX_STATUSES.APPROVED); - updateAllowance(); - } catch (error) { - console.error(error); - Sentry.captureException(error, { tags: { method: 'Deposit.useApproval.approve' } }); - const message = error.message.includes('user rejected transaction') - ? 'User denied transaction signature' - : error.message; - setTxError(message); - setTxStatus(TX_STATUSES.REJECTED); - } - }, [openTxModal, setTxStatus, setTxError, switchNetworkAsync, chain, currentPool, signer, updateAllowance]); - - return { isApproved, approve, updateAllowance }; -} diff --git a/src/pages/Deposit/index.js b/src/pages/Deposit/index.js index cb6483a4..37c0e8a3 100644 --- a/src/pages/Deposit/index.js +++ b/src/pages/Deposit/index.js @@ -22,8 +22,8 @@ import DefaultLink from 'components/Link'; import { ReactComponent as WargingIcon } from 'assets/warning.svg'; -import { useFee, useParsedAmount, useLatestAction } from 'hooks'; -import { useDepositLimit, useMaxAmountExceeded, useApproval } from './hooks'; +import { useFee, useParsedAmount, useLatestAction, useApproval } from 'hooks'; +import { useDepositLimit, useMaxAmountExceeded } from './hooks'; import { formatNumber, minBigNumber } from 'utils'; @@ -55,7 +55,7 @@ export default () => { () => isNativeTokenUsed ? directDepositFee : fee, [isNativeTokenUsed, directDepositFee, fee], ); - const { isApproved, approve } = useApproval(amount.add(fee), balance); + const { isApproved, approve } = useApproval(currentPool.chainId, currentPool.tokenAddress, amount.add(fee), balance); const depositLimit = useDepositLimit(limits, isNativeTokenUsed); const maxAmountExceeded = useMaxAmountExceeded(amount, usedBalance, usedFee, depositLimit); diff --git a/src/pages/Payment/Header/index.js b/src/pages/Payment/Header/index.js new file mode 100644 index 00000000..de30f79d --- /dev/null +++ b/src/pages/Payment/Header/index.js @@ -0,0 +1,110 @@ +import React, { useContext } from 'react'; +import styled from 'styled-components'; +import { useAccount } from 'wagmi'; + +import Button from 'components/Button'; +import WalletDropdown from 'components/WalletDropdown'; + +import { ReactComponent as Logo } from 'assets/logo-beta.svg'; +import { ReactComponent as DropdownIconDefault } from 'assets/dropdown.svg'; + +import { shortAddress } from 'utils'; + +import { CONNECTORS_ICONS, NETWORKS } from 'constants'; + +import ModalContext from 'contexts/ModalContext'; + +export default () => { + const { address: account, connector } = useAccount(); + const { openWalletModal } = useContext(ModalContext); + return ( +
+ + + + + {NETWORKS[137].name} + + {account ? ( + + + + {connector && } +
{shortAddress(account)}
+ +
+
+
+ ) : ( + + )} +
+
+ ); +} + +const Row = styled.div` + display: flex; + align-items: center; + justify-content: center; +`; + +const Header = styled(Row)` + justify-content: space-between; +`; + +const NetworkLabel = styled(Row)` + background-color: #FFF; + color: ${props => props.theme.text.color.primary}; + font-weight: ${props => props.theme.text.weight.normal}; + padding: 0 12px; + border-radius: 18px; + min-height: 36px; + box-sizing: border-box; + margin-right: 10px; + cursor: default; + &:last-child { + margin-right: 0; + } +`; + +const DropdownIcon = styled(DropdownIconDefault)` + width: 16px !important; + height: 16px; +`; + +const AccountDropdownButton = styled(NetworkLabel)` + overflow: hidden; + cursor: pointer; + border: 1px solid ${props => props.theme.button.primary.text.color.contrast}; + &:hover { + border-color: ${props => props.theme.button.link.text.color}; + & span { + color: ${props => props.theme.button.link.text.color}; + } + & path { + stroke: ${props => props.theme.button.link.text.color}; + } + } +`; + +const Icon = styled.img` + width: 18px; + height: 16px; +`; + +const Address = styled.span` + margin-left: 8px; + margin-right: 8px; + // @media only screen and (max-width: 1100px) { + // display: none; + // } +`; + +const NetworkIcon = styled.img` + width: 24px; + height: 24px; + margin-right: 5px; +`; diff --git a/src/pages/Payment/Input/index.js b/src/pages/Payment/Input/index.js new file mode 100644 index 00000000..20cda83c --- /dev/null +++ b/src/pages/Payment/Input/index.js @@ -0,0 +1,79 @@ +import React, { useCallback, useRef, useState, useEffect } from 'react'; +import styled from 'styled-components'; + +export default ({ value, onChange }) => { + const inputRef = useRef(); + const spanRef = useRef(); + const [width, setWidth] = useState(0); + + const handleChange = useCallback(value => { + if (!value || /^\d*(?:[.]\d*)?$/.test(value)) { + onChange(value); + } + }, [onChange]); + + useEffect(() => { + setWidth(spanRef.current.offsetWidth); + }, [value]); + + return ( + inputRef.current.focus()}> + {value} + handleChange(e.target.value)} + style={{ width }} + /> + USD + + ); +}; + +const Container = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + height: 60px; + background: ${props => props.theme.input.background.secondary}; + border: 1px solid ${props => props.theme.input.border.color.default}; + border-radius: 16px; + box-sizing: border-box; + padding: 0px 24px; + transition : border-color 100ms ease-out; + &:focus-within { + border-color: ${props => props.theme.input.border.color.focus}; + } + margin-bottom: 20px; +`; + +const Input = styled.input` + border: 0; + background: transparent; + font-size: 24px; + color: ${props => props.theme.transferInput.text.color.default}; + font-weight: ${props => props.theme.transferInput.text.weight.default}; + min-width: 16px; + outline: none; + padding: 0; + z-index: 1; + &::placeholder { + color: ${props => props.theme.transferInput.text.color.placeholder}; + } +`; + +const Currency = styled.span` + font-size: 24px; + color: ${props => props.theme.text.color.primary}; + font-weight: ${props => props.theme.transferInput.text.weight.default}; + margin-left: 8px; +`; + +const HiddenText = styled.span` + position: absolute; + opacity: 0; + font-size: 24px; + font-weight: ${props => props.theme.transferInput.text.weight.default}; +`; diff --git a/src/pages/Payment/PseudoInput/index.js b/src/pages/Payment/PseudoInput/index.js new file mode 100644 index 00000000..dea09b17 --- /dev/null +++ b/src/pages/Payment/PseudoInput/index.js @@ -0,0 +1,120 @@ +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import { ethers } from 'ethers'; + +import Skeleton from 'components/Skeleton'; + +import { ReactComponent as DropdownIconDefault } from 'assets/dropdown.svg'; +import { formatNumber } from 'utils'; + +export default ({ value, token, onSelect, isLoading, balance, isLoadingBalance }) => { + const usdBalance = useMemo(() => { + if (token?.priceUSD) { + const priceBN = ethers.utils.parseEther(token.priceUSD); + return balance.mul(priceBN).div(ethers.constants.WeiPerEther); + } + return ethers.constants.Zero; + }, [balance, token]); + return ( + + + + {isLoading ? ( + + ) : ( + + {formatNumber(value, token?.decimals || 18, 6)} + + )} + {token?.symbol} + + + + + Balance: + {isLoadingBalance ? ( + + ) : ( + + {formatNumber(balance, token?.decimals || 18)} {token?.symbol}{' '} + {`($${formatNumber(usdBalance, token?.decimals || 18)})`} + + )} + + + ); +}; + +const Row = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + overflow: hidden; +`; + +const RowSpaceBetween = styled(Row)` + justify-content: space-between; + width: 100%; + margin-bottom: 8px; +`; + +const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + height: 60px; + background: ${props => props.theme.input.background.primary}; + border-radius: 16px; + padding: 10px 24px; + transition : border-color 100ms ease-out; + &:focus-within { + border-color: ${props => props.theme.input.border.color.focus}; + } + margin-bottom: 13px; +`; + +const TokenSymbol = styled.span` + font-size: 24px; + color: ${props => props.theme.text.color.primary}; + font-weight: ${props => props.theme.transferInput.text.weight.default}; + margin-left: 8px; + white-space: nowrap; +`; + +const Value = styled(TokenSymbol)` + margin: 0; + font-size: 24px; + color: ${props => props.theme.transferInput.text.color[props.children !== '0' ? 'default' : 'placeholder']}; + min-width: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ItemIcon = styled.img` + width: 24px; + height: 24px; + border-radius: 50%; +`; + +const Select = styled.div` + display: flex; + align-items: center; + cursor: pointer; + position: relative; + margin-left: 15px; +`; + +const DropdownIcon = styled(DropdownIconDefault)` + margin-left: 4px; + margin-top: 1px; +`; + +const Balance = styled.span` + font-size: 14px; + color: ${props => props.theme.text.color.secondary}; +`; diff --git a/src/pages/Payment/hooks.js b/src/pages/Payment/hooks.js new file mode 100644 index 00000000..6f8f94cc --- /dev/null +++ b/src/pages/Payment/hooks.js @@ -0,0 +1,330 @@ +import { useEffect, useState, useContext, useCallback } from 'react'; +import * as Sentry from '@sentry/react'; +import { ethers, BigNumber } from 'ethers'; +import { useAccount, useSigner, useNetwork, useSwitchNetwork, useProvider } from 'wagmi'; +import { LiFi } from '@lifi/sdk'; + +import SupportIdContext from 'contexts/SupportIdContext'; +import TransactionModalContext from 'contexts/TransactionModalContext'; + +import zp from 'contexts/ZkAccountContext/zp'; + +import { TX_STATUSES } from 'constants'; + +import { createPermitSignature, getPermitType, getNullifier } from './utils'; + +const lifi = new LiFi({ + integrator: 'zkBob', +}); + +const MULTIPLIER = BigNumber.from('1000000'); // 100% +const MIN_DIFF = BigNumber.from('1000'); // 0.1% +const TARGET_DIFF = BigNumber.from('4000'); // 0.4% +const MAX_DIFF = BigNumber.from('10000'); // 1.0% + +export function useTokenList(pool) { + const [tokenList, setTokenList] = useState([]); + + useEffect(() => { + async function getTokenList() { + try { + const lifiUrl = `https://li.quest/v1/tokens?chains[]=${pool.chainId}`; + const oneInchUrl = `https://tokens.1inch.io/v1.2/${pool.chainId}`; + const [lifiData, oneInchData] = await Promise.all( + [lifiUrl, oneInchUrl].map(url => fetch(url).then(res => res.json())) + ); + let tokens = lifiData.tokens[pool.chainId]; + tokens = tokens.map(token => ({ ...token, eip2612: oneInchData[token.address]?.eip2612 })); + const index = tokens.findIndex(token => token.address === pool.tokenAddress); + if (index > 0) { + tokens.unshift(tokens.splice(index, 1)[0]); + } + setTokenList(tokens); + } catch (error) { + console.error(error); + Sentry.captureException(error, { tags: { method: 'Payment.useTokenList' } }); + } + } + getTokenList(); + }, [pool]); + + return tokenList; +} + +export function useTokenAmount(pool, fromToken, enteredAmount, fee) { + const [amount, setAmount] = useState(ethers.constants.Zero); + const [liFiRoute, setLiFiRoute] = useState(null); + const [isTokenAmountLoading, setIsTokenAmountLoading] = useState(false); + + useEffect(() => { + const handler = setTimeout(() => setAmount(enteredAmount), 500); + return () => clearTimeout(handler); + }, [enteredAmount, pool]); + + useEffect(() => { + if (!pool || !fromToken) return; + if (amount.isZero()) { + setLiFiRoute(null); + return; + } + + const amountWithFee = amount.add(fee); + + if (pool.tokenAddress.toLowerCase() === fromToken.toLowerCase()) { + setLiFiRoute({ estimate: { fromAmount: amountWithFee } }); + return; + } + async function getSwapDetails() { + setIsTokenAmountLoading(true); + try { + const minAmount = amountWithFee.mul(MIN_DIFF.add(MULTIPLIER)).div((MULTIPLIER)); + const targetAmount = amountWithFee.mul(TARGET_DIFF.add(MULTIPLIER)).div((MULTIPLIER)); + const maxAmount = amountWithFee.mul(MAX_DIFF.add(MULTIPLIER)).div((MULTIPLIER)); + + const opts = { + fromChainId: pool.chainId, + fromAmount: targetAmount.toString(), + fromTokenAddress: pool.tokenAddress, + fromAddress: pool.paymentContractAddress, + toChainId: pool.chainId, + toTokenAddress: fromToken, + toAddress: pool.paymentContractAddress, + }; + const routes = await lifi.getRoutes(opts); + if (routes.routes.length === 0) { + throw new Error("no routes found"); + } + const estimatedTokenAmount = BigNumber.from(routes.routes[0].toAmount); + + [opts.fromTokenAddress, opts.toTokenAddress] = [opts.toTokenAddress, opts.fromTokenAddress]; + + async function findLiFiRoute(tokenAmount, attempt = 0) { + if (attempt > 10) throw new Error('Too many attempts'); + opts.fromAmount = tokenAmount.toString(); + const routes = await lifi.getRoutes(opts); + if (routes.routes.length === 0) { + throw new Error("no routes found"); + } + const receivedAmount = BigNumber.from(routes.routes[0].toAmount); + + if (receivedAmount.gte(minAmount) && receivedAmount.lte(maxAmount)) { + return routes.routes[0].steps[0]; + } + return findLiFiRoute(tokenAmount.mul(targetAmount).div(receivedAmount), attempt + 1); + } + const liFiRoute = await findLiFiRoute(estimatedTokenAmount); + setLiFiRoute(liFiRoute); + } catch (error) { + setLiFiRoute(null); + console.error(error); + Sentry.captureException(error, { tags: { method: 'Payment.useSwapDetails' } }); + } + setIsTokenAmountLoading(false); + } + getSwapDetails(); + const intervalId = setInterval(getSwapDetails, 20000); // 20 seconds + return () => clearInterval(intervalId); + }, [pool, fromToken, amount, fee]); + + return { tokenAmount: BigNumber.from(liFiRoute?.estimate?.fromAmount || '0'), liFiRoute, isTokenAmountLoading }; +} + +export function useLimitsAndFees(pool) { + const { supportId } = useContext(SupportIdContext); + const [zkClient, setZkClient] = useState(null); + const [limit, setLimit] = useState(ethers.constants.Zero); + const [isLoadingLimit, setIsLoadingLimit] = useState(true); + const [fee, setFee] = useState(ethers.constants.Zero); + const [isLoadingFee, setIsLoadingFee] = useState(true); + + useEffect(() => { + if (!supportId || !pool || zkClient) return; + async function create() { + const client = await zp.createClient(pool.alias, supportId); + setZkClient(client); + } + create(); + }, [supportId, pool, zkClient]); + + const updateLimit = useCallback(async () => { + setIsLoadingLimit(true); + let limit = ethers.constants.Zero; + try { + const data = await zkClient.getLimits(); + const wei = await zkClient.shieldedAmountToWei(data.dd.components.singleOperation); + limit = BigNumber.from(wei); + } catch (error) { + console.error(error); + Sentry.captureException(error, { tags: { method: 'Payment.useZkClient.updateLimit' } }); + } + setLimit(limit); + setIsLoadingLimit(false); + }, [zkClient]); + + const updateFee = useCallback(async () => { + setIsLoadingFee(true); + let fee = ethers.constants.Zero; + try { + // const data = await zkClient.directDepositFee(); + // const wei = await zkClient.shieldedAmountToWei(data); + const wei = '100000'; + fee = BigNumber.from(wei); + } catch (error) { + console.error(error); + Sentry.captureException(error, { tags: { method: 'Payment.useZkClient.updateFee' } }); + } + setFee(fee); + setIsLoadingFee(false); + }, [/*zkClient*/]); + + useEffect(() => { + if (!zkClient) return; + updateLimit(); + updateFee(); + }, [zkClient, updateLimit, updateFee]); + + return { limit, isLoadingLimit, fee, isLoadingFee }; +} + +export function usePayment(token, tokenAmount, amount, fee, pool, zkAddress, liFiRoute) { + const { openTxModal, setTxStatus, setTxHash, setTxError } = useContext(TransactionModalContext); + const { address: account } = useAccount(); + const { chain } = useNetwork(); + const { data: signer } = useSigner({ chainId: pool.chainId }); + const provider = useProvider({ chainId: pool.chainId }); + const { switchNetworkAsync } = useSwitchNetwork({ + chainId: pool.chainId, + throwForSwitchChainNotSupported: true, + }); + + const send = useCallback(async () => { + openTxModal(); + try { + if (chain.id !== pool.chainId) { + setTxStatus(TX_STATUSES.SWITCH_NETWORK); + try { + await switchNetworkAsync(); + } catch (error) { + console.error(error); + Sentry.captureException(error, { tags: { method: 'ZkAccountContext.deposit.switchNetwork' } }); + setTxStatus(TX_STATUSES.WRONG_NETWORK); + return; + } + } + + const isNative = token.address === ethers.constants.AddressZero; + let permitSignature = '0x'; + if (!isNative) { + setTxStatus(TX_STATUSES.SIGN_MESSAGE); + const permitType = getPermitType(token, pool.chainId); + const deadline = Math.floor((Date.now() + 1000 * 60 * 60 * 24 * 365) / 1000); // 1 year + const nullifier = getNullifier(permitType); + const rawSignature = await createPermitSignature( + permitType, + pool.chainId, + token.address, + provider, + signer, + account, + pool.paymentContractAddress, + tokenAmount, + deadline, + nullifier, + ); + const compactSignature = ethers.utils.splitSignature(rawSignature).compact; + permitSignature = ethers.utils.solidityPack(['uint256','uint256','bytes'], [nullifier, deadline, compactSignature]); + } + + setTxStatus(TX_STATUSES.PREPARING_TRANSACTION); + let router ='0x0000000000000000000000000000000000000000'; + let routerData ='0x'; + if (token.address.toLowerCase() !== pool.tokenAddress.toLowerCase()) { + let liFiTx; + try { + liFiTx = await lifi.getStepTransaction(liFiRoute); + router = liFiTx.transactionRequest.to; + routerData = liFiTx.transactionRequest.data; + } catch (error) { + throw new Error('Error getting exchange data. Please try again.'); + } + if (BigNumber.from(liFiTx.estimate.toAmount).lt(amount.add(fee))) { + throw new Error('The exchange rate was changed. Please try again.'); + } + } + + setTxStatus(TX_STATUSES.CONFIRM_TRANSACTION); + const paymentABI = ['function pay(bytes,address,uint256,uint256,bytes,address,bytes,bytes) external payable']; + const paymentContractInstance = new ethers.Contract(pool.paymentContractAddress, paymentABI, signer); + const note = '0x'; + const decodedZkAddress = ethers.utils.hexlify(ethers.utils.base58.decode(zkAddress.split(':')[1])); + const tx = await paymentContractInstance.pay( + decodedZkAddress, + isNative ? ethers.constants.AddressZero : token.address, + tokenAmount, + amount.add(fee), + permitSignature, + router, + routerData, + note, + { + value: isNative ? tokenAmount : ethers.constants.Zero, + gasLimit: 2000000, + }, + ); + setTxStatus(TX_STATUSES.WAITING_FOR_TRANSACTION); + await tx.wait(); + setTxHash(tx.hash); + setTxStatus(TX_STATUSES.SENT); + } catch (error) { + let message = error?.message; + if (message?.includes('user rejected transaction')) { + message = 'User rejected transaction.'; + } + console.error(error); + Sentry.captureException(error, { tags: { method: 'Payment.usePayment.send' } }); + setTxError(message); + setTxStatus(TX_STATUSES.REJECTED); + } + }, [ + chain, pool, token, tokenAmount, account, provider, signer, + openTxModal, setTxStatus, setTxError, switchNetworkAsync, + zkAddress, fee, amount, setTxHash, liFiRoute, + ]); + + return { send }; +} + +const TOKEN_ABI = ['function balanceOf(address) pure returns (uint256)']; + +export function useTokenBalance(chainId, selectedToken) { + const { address: account } = useAccount(); + const provider = useProvider({ chainId }); + const [balance, setBalance] = useState(ethers.constants.Zero); + const [isLoadingBalance, setIsLoadingBalance] = useState(true); + + const updateBalance = useCallback(async () => { + setIsLoadingBalance(true); + let balance = ethers.constants.Zero; + if (account && selectedToken) { + try { + if (selectedToken.address === ethers.constants.AddressZero) { + balance = await provider.getBalance(account); + } else { + const token = new ethers.Contract(selectedToken.address, TOKEN_ABI, provider); + balance = await token.balanceOf(account); + } + } catch (error) { + console.error(error); + Sentry.captureException(error, { tags: { method: 'Payment.useTokenBalace' } }); + } + } + setBalance(balance); + setIsLoadingBalance(false); + }, [selectedToken, account, provider]); + + useEffect(() => { + updateBalance(); + }, [updateBalance]); + + return { balance, isLoadingBalance }; +} diff --git a/src/pages/Payment/index.js b/src/pages/Payment/index.js new file mode 100644 index 00000000..f2411435 --- /dev/null +++ b/src/pages/Payment/index.js @@ -0,0 +1,232 @@ +import React, { useState, useContext, useEffect, useMemo } from 'react'; +import styled from 'styled-components'; +import { useHistory, useParams } from 'react-router-dom'; +import { useAccount } from 'wagmi'; +import { ethers } from 'ethers'; + +import Layout from 'components/Layout'; +import Card from 'components/Card'; +import Button from 'components/Button'; +import Limits from 'components/Limits'; +// import Tooltip from 'components/Tooltip'; +import TokenListModal from 'components/TokenListModal'; +import Skeleton from 'components/Skeleton'; +import TransactionModal from 'components/TransactionModal'; + +import WalletModal from 'containers/WalletModal'; + +import Header from './Header'; +import Input from './Input'; +import PseudoInput from './PseudoInput'; + +import { ReactComponent as TryZkBobBannerImageDefault } from 'assets/try-zkbob-banner.svg'; +// import { ReactComponent as InfoIconDefault } from 'assets/info.svg'; + +import ModalContext, { ModalContextProvider } from 'contexts/ModalContext'; +import SupportIdContext, { SupportIdContextProvider } from 'contexts/SupportIdContext'; +import TransactionModalContext, { TransactionModalContextProvider } from 'contexts/TransactionModalContext'; + +import config from 'config'; + +import { formatNumber } from 'utils'; +import { useApproval } from 'hooks'; +import { useTokenList, useTokenAmount, useLimitsAndFees, useTokenBalance, usePayment } from './hooks'; +import { getPermitType } from './utils'; + +const pools = Object.values(config.pools).map((pool, index) => + ({ ...pool, alias: Object.keys(config.pools)[index] }) +); + +const Payment = () => { + const { supportId } = useContext(SupportIdContext); + const history = useHistory(); + const params = useParams(); + const addressPrefix = params.address.split(':')[0]; + const pool = Object.values(pools).find(pool => pool.addressPrefix === addressPrefix); + if (!pool.paymentContractAddress) { + history.push('/'); + } + + const { address: account } = useAccount(); + const [displayedAmount, setDisplayedAmount] = useState(''); + const amount = useMemo(() => ethers.utils.parseUnits(displayedAmount || '0', pool?.tokenDecimals), [displayedAmount, pool]); + const [selectedToken, setSelectedToken] = useState(null); + + const { limit, isLoadingLimit, fee, isLoadingFee } = useLimitsAndFees(pool); + const { balance, isLoadingBalance } = useTokenBalance(pool?.chainId, selectedToken); + const { tokenAmount, liFiRoute, isTokenAmountLoading } = useTokenAmount(pool, selectedToken?.address, amount, fee); + const { isApproved, approve } = useApproval(pool?.chainId, selectedToken?.address, tokenAmount); + const permitType = useMemo(() => getPermitType(selectedToken, pool?.chainId), [pool, selectedToken]); + const { send } = usePayment(selectedToken, tokenAmount, amount, fee, pool, params.address, liFiRoute); + + const { txStatus, isTxModalOpen, closeTxModal, txAmount, txHash, txError } = useContext(TransactionModalContext); + const { isTokenListModalOpen, openTokenListModal, closeTokenListModal, openWalletModal } = useContext(ModalContext); + const tokenList = useTokenList(pool); + + useEffect(() => { + if (tokenList.length) { + const defaultToken = + tokenList.find(token => token.symbol === pool.tokenSymbol) || + tokenList.find(token => token.address === ethers.constants.AddressZero); + setSelectedToken(defaultToken); + } + }, [tokenList, pool]); + + const onSend = () => { + setDisplayedAmount(''); + send(); + }; + + return ( + <> + }> + Enter USD value and choose a token + + The amount you'd like to send + + Transfer amount + + + + The recipient will get payment in {pool.tokenSymbol} + + + Fee: + {isLoadingFee + ? + : {formatNumber(fee, pool.tokenDecimals)} USD + } + {/* + Deposit fee: 0.2 USD + Convert fee: 0.8 USD + Fee: 0.4 USD + + } + placement="right" + delay={0} + > + + */} + + + {(() => { + if (!account) + return + else if (tokenAmount.isZero()) + return + else if (tokenAmount.gt(balance)) + return + else if (amount.gt(limit)) + return + else if (selectedToken?.address !== ethers.constants.AddressZero && permitType === 'permit2' && !isApproved) + return + else + return ; + })()} + + + history.push('/')} /> + + + { + setSelectedToken(token); + closeTokenListModal(); + }} + /> + + + ); +} + +export default () => ( + + + + + + + +); + +const Row = styled.div` + display: flex; + align-items: center; +`; + +const RowSpaceBetween = styled(Row)` + justify-content: space-between; + padding: 0 12px; + flex-wrap: wrap; +`; + +const InputLabel = styled.span` + font-size: 16px; + line-height: 24px; + color: ${props => props.theme.text.color.primary}; + font-weight: ${props => props.theme.text.weight.normal}; + padding: 0 12px; + margin-bottom: 8px; +`; + +const Text = styled.span` + font-size: 14px; + line-height: 20px; + color: ${props => props.theme.text.color.secondary}; + font-weight: ${props => props.theme.text.weight.normal}; +`; + +const Title = styled.span` + font-size: 24px; + line-height: 32px; + color: ${props => props.theme.text.color.primary}; + font-weight: ${props => props.theme.text.weight.bold}; + margin-bottom: 24px; + @media only screen and (max-width: 560px) { + display: none; + } +`; + +const TryZkBobBannerImage = styled(TryZkBobBannerImageDefault)` + max-width: 100%; + margin-top: 20px; + cursor: pointer; +`; + +// const InfoIcon = styled(InfoIconDefault)` +// margin-left: 5px; +// &:hover { +// & > path { +// fill: ${props => props.theme.color.purple}; +// } +// } +// `; diff --git a/src/pages/Payment/utils.js b/src/pages/Payment/utils.js new file mode 100644 index 00000000..fd0acf31 --- /dev/null +++ b/src/pages/Payment/utils.js @@ -0,0 +1,173 @@ +import { ethers, BigNumber } from 'ethers'; + +import { PERMIT2_CONTRACT_ADDRESS } from 'constants'; + +export function getPermitType(token, chainId) { + if (token?.symbol === 'USDC') return chainId === 137 ? 'permit-usdc-polygon' : 'permit-usdc'; + if (token?.eip2612) return 'permit'; + return 'permit2'; +} + +export function getNullifier(permitType) { + if (permitType === 'permit') return ethers.constants.Zero; + let min, max; + if (permitType === 'permit2') { + min = BigNumber.from(2).pow(248); + max = BigNumber.from(2).pow(249).sub(1); + } else { + min = BigNumber.from(1); + max = BigNumber.from(2).pow(248).sub(1); + } + const random = ethers.BigNumber.from(ethers.utils.randomBytes(32)); + return random.mod(max.sub(min)).add(min); +} + +async function getNameAndNonce(tokenAddress, ownerAddress, provider) { + const tokenABI = [ + 'function name() view returns (string)', + 'function nonces(address) view returns (uint256)', + ]; + const tokenContractInstance = new ethers.Contract(tokenAddress, tokenABI, provider); + const [name, nonce] = await Promise.all([ + tokenContractInstance.name(), + tokenContractInstance.nonces(ownerAddress), + ]); + return { name, nonce }; +} + +async function permit({ tokenAddress, chainId, ownerAddress, spenderAddress, value, deadline, provider }) { + const { name, nonce } = await getNameAndNonce(tokenAddress, ownerAddress, provider); + + const domain = { + name, + version: '1', + chainId, + verifyingContract: tokenAddress, + }; + + const types = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + + const message = { + owner: ownerAddress, + spender: spenderAddress, + value: value.toString(), + nonce: nonce.toString(), + deadline: deadline.toString(), + }; + + return { domain, types, message }; +} + +async function permit2({ tokenAddress, chainId, spenderAddress, value, deadline, nullifier }) { + const domain = { + name: 'Permit2', + chainId, + verifyingContract: PERMIT2_CONTRACT_ADDRESS, + }; + + const types = { + TokenPermissions: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + PermitTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + + const message = { + permitted: { + token: tokenAddress, + amount: value.toString() + }, + spender: spenderAddress, + nonce: nullifier.toString(), + deadline: deadline.toString(), + }; + + return { domain, types, message }; +} + +async function permitUSDC({ tokenAddress, chainId, ownerAddress, spenderAddress, value, deadline, provider, nullifier }) { + const { name } = await getNameAndNonce(tokenAddress, ownerAddress, provider); + + const domain = { + name, + version: '2', + chainId, + verifyingContract: tokenAddress, + }; + + const types = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], + }; + + const message = { + from: ownerAddress, + to: spenderAddress, + value: value.toString(), + validAfter: '0', + validBefore: deadline.toString(), + nonce: ethers.utils.hexZeroPad(ethers.utils.hexlify(nullifier), 32), + }; + + return { domain, types, message }; +} + +async function permitUSDCPolygon(data) { + const { domain, types, message } = await permitUSDC(data); + delete domain.chainId; + domain.version = '1'; + domain.salt = ethers.utils.hexZeroPad(ethers.utils.hexlify(data.chainId), 32); + return { domain, types, message }; +} + +const permits = { + permit, + permit2, + 'permit-usdc': permitUSDC, + 'permit-usdc-polygon': permitUSDCPolygon, +}; + +export async function createPermitSignature( + type, chainId, tokenAddress, provider, signer, + ownerAddress, spenderAddress, value, deadline, nullifier, +) { + let signature; + try { + const values = { tokenAddress, chainId, ownerAddress, spenderAddress, value, deadline, provider, nullifier }; + const { domain, types, message } = await permits[type](values); + signature = await signer._signTypedData(domain, types, message); + } catch (error) { + console.error(error); + throw Error('User denied message signature.'); + } + if (typeof signature !== 'string') throw Error('Something went wrong.'); + + // Metamask with ledger returns V=0/1 here too, we need to adjust it to be ethereum's valid value (27 or 28) + const MIN_VALID_V_VALUE = 27; + let sigV = parseInt(signature.slice(-2), 16); + if (sigV < MIN_VALID_V_VALUE) { + sigV += MIN_VALID_V_VALUE + } + + return signature.slice(0, -2) + sigV.toString(16); +} diff --git a/src/pages/index.js b/src/pages/index.js index 49d224cc..27522d6a 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -6,7 +6,6 @@ import * as Sentry from "@sentry/react"; import { BrowserTracing } from "@sentry/tracing"; import { useIdleTimer } from 'react-idle-timer'; -import Header from 'containers/Header'; import Tabs from 'containers/Tabs'; import TransactionModal from 'containers/TransactionModal'; import WalletModal from 'containers/WalletModal'; @@ -18,18 +17,22 @@ import SeedPhraseModal from 'containers/SeedPhraseModal'; import IncreasedLimitsModal from 'containers/IncreasedLimitsModal'; import RedeemGiftCardModal from 'containers/RedeemGiftCardModal'; +import Header from 'components/Header'; import ChangePasswordModal from 'components/ChangePasswordModal'; import DisablePasswordModal from 'components/DisablePasswordModal'; import ToastContainer from 'components/ToastContainer'; import Footer from 'components/Footer'; import DemoBanner from 'components/DemoBanner'; import RestrictionModal from 'components/RestrictionModal'; +import Layout from 'components/Layout'; +import PaymentLinkModal from 'components/PaymentLinkModal'; import Welcome from 'pages/Welcome'; import Deposit from 'pages/Deposit'; import Transfer from 'pages/Transfer'; import Withdraw from 'pages/Withdraw'; import History from 'pages/History'; +import Payment from 'pages/Payment'; import aliceImage from 'assets/alice.webp'; import bobImage from 'assets/bob.webp'; @@ -37,7 +40,7 @@ import robot1Image from 'assets/robot-1.webp'; import robot2Image from 'assets/robot-2.webp'; import robot3Image from 'assets/robot-3.webp'; -import { ZkAccountContext } from 'contexts'; +import ContextsProvider, { ZkAccountContext } from 'contexts'; import { useRestriction } from 'hooks'; @@ -103,7 +106,7 @@ const Routes = ({ showWelcome, params }) => ( ); -const Content = () => { +const MainApp = () => { const { zkAccount, isLoadingZkAccount, isDemo, lockAccount } = useContext(ZkAccountContext); const location = useLocation(); const showWelcome = (!zkAccount && !isLoadingZkAccount && !window.localStorage.getItem('seed')) || isDemo; @@ -115,15 +118,9 @@ const Content = () => { if (isRestricted) { return ( - <> - - -
- - - - - + }> + + ); } return ( @@ -135,76 +132,43 @@ const Content = () => { - {isDemo && } - -
- - - - -