diff --git a/apps/web/src/components/BridgeModal/BridgeForm.schema.ts b/apps/web/src/components/BridgeModal/BridgeForm.schema.ts new file mode 100644 index 000000000..4381ef6cf --- /dev/null +++ b/apps/web/src/components/BridgeModal/BridgeForm.schema.ts @@ -0,0 +1,20 @@ +import * as yup from 'yup' + +export interface BridgeFormValues { + amount?: number +} + +const bridgeFormSchema = (userL1Balance: number) => + yup.object({ + amount: yup + .number() + .required() + .max(userL1Balance, 'Must bridge less than L1 balance.') + .test( + 'is-greater-than-0', + 'Must bridge more than 0 ETH', + (value) => !!value && value > 0 + ), + }) + +export default bridgeFormSchema diff --git a/apps/web/src/components/BridgeModal/BridgeForm.styles.css.ts b/apps/web/src/components/BridgeModal/BridgeForm.styles.css.ts new file mode 100644 index 000000000..cb9a7ed88 --- /dev/null +++ b/apps/web/src/components/BridgeModal/BridgeForm.styles.css.ts @@ -0,0 +1,11 @@ +import { style } from '@vanilla-extract/css' +import { vars } from '@zoralabs/zord' + +export const chainPopUpButton = style({ + backgroundColor: 'white', + selectors: { + '&:hover': { + backgroundColor: vars.color.background2, + }, + }, +}) diff --git a/apps/web/src/components/BridgeModal/BridgeForm.tsx b/apps/web/src/components/BridgeModal/BridgeForm.tsx new file mode 100644 index 000000000..575595e09 --- /dev/null +++ b/apps/web/src/components/BridgeModal/BridgeForm.tsx @@ -0,0 +1,239 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { sendTransaction } from '@wagmi/core' +import { Box, Button, Flex, Heading, Text } from '@zoralabs/zord' +import { parseEther } from 'ethers/lib/utils.js' +import { Formik } from 'formik' +import Image from 'next/image' +import Link from 'next/link' +import { useState } from 'react' +import { useAccount, useBalance, useNetwork, useSwitchNetwork } from 'wagmi' + +import Input from 'src/components/Input/Input' +import { L2ChainType, PUBLIC_L1_BRIDGE_ADDRESS } from 'src/constants/addresses' +import { PUBLIC_DEFAULT_CHAINS } from 'src/constants/defaultChains' +import { useBridgeModal } from 'src/hooks/useBridgeModal' +import { useIsContract } from 'src/hooks/useIsContract' +import { useChainStore } from 'src/stores/useChainStore' +import { formatCryptoVal } from 'src/utils/numbers' + +import { Icon } from '../Icon' +import bridgeFormSchema, { BridgeFormValues } from './BridgeForm.schema' +import { NetworkSelector } from './NetworkSelector' + +export const BridgeForm = () => { + const { address } = useAccount() + const { chain: userChain } = useNetwork() + const { switchNetwork } = useSwitchNetwork() + const { closeBridgeModal } = useBridgeModal() + const { openConnectModal } = useConnectModal() + const { data: isContractWallet } = useIsContract({ address }) + const [loading, setLoading] = useState(false) + + const { chain: appChain } = useChainStore() + + const l1Chain = PUBLIC_DEFAULT_CHAINS[0] + const [l2Chain, setL2Chain] = useState( + appChain.id !== l1Chain.id ? appChain : PUBLIC_DEFAULT_CHAINS[1] + ) + + const isWalletOnL1 = userChain?.id === l1Chain.id + + const { data: userL1Balance } = useBalance({ + address, + chainId: l1Chain.id, + }) + + const { data: userL2Balance } = useBalance({ + address, + chainId: l2Chain.id, + }) + + const initialValues: BridgeFormValues = { + amount: 0, + } + + const handleSubmit = async (values: BridgeFormValues) => { + const bridge = PUBLIC_L1_BRIDGE_ADDRESS[l2Chain.id as L2ChainType] + + if (!values.amount || !bridge) return + + setLoading(true) + try { + const { wait } = await sendTransaction({ + request: { + to: PUBLIC_L1_BRIDGE_ADDRESS[l2Chain.id as L2ChainType], + value: parseEther(values.amount.toString()), + }, + mode: 'recklesslyUnprepared', + }) + await wait() + } catch (err) { + console.log('err', err) + } finally { + setLoading(false) + } + } + + const formattedL1Balance = userL1Balance ? parseFloat(userL1Balance.formatted) : 0 + const formattedL2Balance = userL2Balance ? parseFloat(userL2Balance.formatted) : 0 + + const getButtonText = (isAmountInvalid: boolean) => { + if (isContractWallet) return 'Contract wallets are not supported' + if (loading) return 'Bridging...' + if (isAmountInvalid) return 'Insufficent ETH balance' + return 'Bridge' + } + + return ( + + + + + + Bridge + + + + {({ errors, touched, isValid, submitForm }) => { + const isAmountInvalid = !!errors.amount && touched.amount + + return ( + + + + + From + + + L1 Chain + + {l1Chain.name} + + } + secondaryLabel={'ETH'} + autoComplete={'off'} + type={'number'} + placeholder={0} + min={0} + max={userL1Balance?.formatted} + step={'any'} + /> + + Balance: {formatCryptoVal(formattedL1Balance)} ETH + + + + + + + To + + + + } + secondaryLabel={'ETH'} + autoComplete={'off'} + type={'number'} + placeholder={0} + min={0} + max={userL2Balance?.formatted} + step={'any'} + /> + + Balance: {formatCryptoVal(formattedL2Balance)} ETH + + + + {!address ? ( + + ) : isWalletOnL1 ? ( + + ) : ( + + )} + + + By proceeding, you agree to Nouns Builder's{' '} + + terms + + . THIS BRIDGE IS DEPOSIT ONLY. YOU MUST USE ANOTHER BRIDGE TO WITHDRAW. + Learn more about{' '} + + bridging + + . + + + ) + }} + + + ) +} diff --git a/apps/web/src/components/BridgeModal/BridgeModal.tsx b/apps/web/src/components/BridgeModal/BridgeModal.tsx new file mode 100644 index 000000000..034a8fdc0 --- /dev/null +++ b/apps/web/src/components/BridgeModal/BridgeModal.tsx @@ -0,0 +1,21 @@ +import { useRouter } from 'next/router' +import { useEffect } from 'react' + +import AnimatedModal from '../Modal/AnimatedModal' +import { BridgeForm } from './BridgeForm' + +export const BridgeModal = () => { + const { + query: { bridge }, + } = useRouter() + + useEffect(() => { + document.body.style.overflow = !!bridge ? 'hidden' : 'unset' + }, [bridge]) + + return ( + + + + ) +} diff --git a/apps/web/src/components/BridgeModal/NetworkSelector.tsx b/apps/web/src/components/BridgeModal/NetworkSelector.tsx new file mode 100644 index 000000000..f9de46197 --- /dev/null +++ b/apps/web/src/components/BridgeModal/NetworkSelector.tsx @@ -0,0 +1,112 @@ +import { Box, Flex, PopUp, Stack, Text } from '@zoralabs/zord' +import Image from 'next/image' +import React from 'react' + +import { PUBLIC_DEFAULT_CHAINS } from 'src/constants/defaultChains' +import { CHAIN_ID, Chain } from 'src/typings' + +import { Icon } from '../Icon' +import { chainPopUpButton } from './BridgeForm.styles.css' + +export interface NetworkSelectorProps { + selectedChain: Chain + setSelectedChain: (value: Chain) => void +} + +export const NetworkSelector: React.FC = ({ + selectedChain, + setSelectedChain, +}) => { + const [isOpenChainMenu, setIsOpenChainMenu] = React.useState(false) + + const onChainChange = (chainId: number) => { + const selected = PUBLIC_DEFAULT_CHAINS.find((x) => x.id === chainId) + if (selected) setSelectedChain(selected) + } + + const isSelectedChain = (chainId: CHAIN_ID) => selectedChain.id === chainId + + return ( + { + setIsOpenChainMenu((bool) => !bool) + }} + > + { + setIsOpenChainMenu(open) + }} + trigger={ + + + + {selectedChain.name} + + + {selectedChain.name} + + + + + + + } + > + + {PUBLIC_DEFAULT_CHAINS.slice(1).map((chain, i, chains) => ( + onChainChange(chain.id)} + cursor={isSelectedChain(chain.id) ? undefined : 'pointer'} + height={'x10'} + px="x4" + mb={i !== chains.length - 1 ? 'x2' : undefined} + align={'center'} + justify={'space-between'} + > + + + {chain.name} + + {chain.name} + + + + ))} + + + + ) +} diff --git a/apps/web/src/components/ContractButton.tsx b/apps/web/src/components/ContractButton.tsx index 6f3c75304..a77b78c58 100644 --- a/apps/web/src/components/ContractButton.tsx +++ b/apps/web/src/components/ContractButton.tsx @@ -1,7 +1,8 @@ import { useConnectModal } from '@rainbow-me/rainbowkit' import { Button, ButtonProps } from '@zoralabs/zord' -import { useAccount, useNetwork, useSwitchNetwork } from 'wagmi' +import { useAccount, useBalance, useNetwork, useSwitchNetwork } from 'wagmi' +import { useBridgeModal } from 'src/hooks/useBridgeModal' import { useChainStore } from 'src/stores/useChainStore' interface ContractButtonProps extends ButtonProps { @@ -16,23 +17,26 @@ export const ContractButton = ({ const { address: userAddress } = useAccount() const { chain: userChain } = useNetwork() const appChain = useChainStore((x) => x.chain) + const { canUserBridge, openBridgeModal } = useBridgeModal() + const { data: userBalance } = useBalance({ + address: userAddress, + chainId: appChain.id, + }) const { openConnectModal } = useConnectModal() const { switchNetwork } = useSwitchNetwork() const handleSwitchNetwork = () => switchNetwork?.(appChain.id) + const handleClickWithValidation = () => { + if (!userAddress) return openConnectModal?.() + if (canUserBridge && userBalance?.value.eq(0)) return openBridgeModal() + if (userChain?.id !== appChain.id) return handleSwitchNetwork() + handleClick() + } + return ( - ) diff --git a/apps/web/src/constants/addresses.ts b/apps/web/src/constants/addresses.ts index 70b8c84cd..484487287 100644 --- a/apps/web/src/constants/addresses.ts +++ b/apps/web/src/constants/addresses.ts @@ -1,5 +1,13 @@ import { AddressType, CHAIN_ID } from 'src/typings' +export type L2ChainType = + | CHAIN_ID.OPTIMISM + | CHAIN_ID.OPTIMISM_GOERLI + | CHAIN_ID.BASE + | CHAIN_ID.BASE_GOERLI + | CHAIN_ID.ZORA + | CHAIN_ID.ZORA_GOERLI + export const PUBLIC_MANAGER_ADDRESS = { [CHAIN_ID.ETHEREUM]: '0xd310a3041dfcf14def5ccbc508668974b5da7174' as AddressType, [CHAIN_ID.OPTIMISM]: '0x3ac0E64Fe2931f8e082C6Bb29283540DE9b5371C' as AddressType, @@ -12,6 +20,15 @@ export const PUBLIC_MANAGER_ADDRESS = { [CHAIN_ID.FOUNDRY]: '0xd310a3041dfcf14def5ccbc508668974b5da7174' as AddressType, } +export const PUBLIC_L1_BRIDGE_ADDRESS = { + [CHAIN_ID.OPTIMISM]: '0xbEb5Fc579115071764c7423A4f12eDde41f106Ed' as AddressType, + [CHAIN_ID.OPTIMISM_GOERLI]: '0x5b47E1A08Ea6d985D6649300584e6722Ec4B1383' as AddressType, + [CHAIN_ID.BASE]: '0x49048044D57e1C92A77f79988d21Fa8fAF74E97e' as AddressType, + [CHAIN_ID.BASE_GOERLI]: '0xe93c8cD0D409341205A592f8c4Ac1A5fe5585cfA' as AddressType, + [CHAIN_ID.ZORA]: '0x1a0ad011913A150f69f6A19DF447A0CfD9551054' as AddressType, + [CHAIN_ID.ZORA_GOERLI]: '0xDb9F51790365e7dc196e7D072728df39Be958ACe' as AddressType, +} + export const PUBLIC_BUILDER_ADDRESS = { [CHAIN_ID.ETHEREUM]: '0xDC9b96Ea4966d063Dd5c8dbaf08fe59062091B6D' as AddressType, // builder treasury address [CHAIN_ID.GOERLI]: '0xc2fff40D3e3468fD85dca6B09e41961edd9381cD' as AddressType, diff --git a/apps/web/src/hooks/useBridgeModal.ts b/apps/web/src/hooks/useBridgeModal.ts new file mode 100644 index 000000000..288342fa0 --- /dev/null +++ b/apps/web/src/hooks/useBridgeModal.ts @@ -0,0 +1,43 @@ +import { omit } from 'lodash' +import { useRouter } from 'next/router' +import { useAccount } from 'wagmi' + +import { useIsContract } from './useIsContract' + +export const useBridgeModal = () => { + const router = useRouter() + + const { address } = useAccount() + const { data: isContractWallet } = useIsContract({ address }) + + const openBridgeModal = () => { + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + bridge: true, + }, + }, + undefined, + { shallow: true } + ) + } + + const closeBridgeModal = () => { + router.push( + { + pathname: router.pathname, + query: omit(router.query, 'bridge'), + }, + undefined, + { shallow: true } + ) + } + + return { + canUserBridge: !isContractWallet, + openBridgeModal, + closeBridgeModal, + } +} diff --git a/apps/web/src/hooks/useIsContract.ts b/apps/web/src/hooks/useIsContract.ts new file mode 100644 index 000000000..c2feff588 --- /dev/null +++ b/apps/web/src/hooks/useIsContract.ts @@ -0,0 +1,18 @@ +import { ethers } from 'ethers' +import useSWRImmutable from 'swr/immutable' + +import { RPC_URL } from 'src/constants/rpc' +import { AddressType, CHAIN_ID } from 'src/typings' + +export const useIsContract = ({ + address, + chainId = CHAIN_ID.ETHEREUM, +}: { + address?: AddressType + chainId?: CHAIN_ID +}) => { + return useSWRImmutable(address ? [address, chainId] : undefined, async (address) => { + const provider = new ethers.providers.JsonRpcProvider(RPC_URL[chainId]) + return await provider.getCode(address).then((x) => x !== '0x') + }) +} diff --git a/apps/web/src/layouts/DefaultLayout/Nav.tsx b/apps/web/src/layouts/DefaultLayout/Nav.tsx index 3674afd68..e07782711 100644 --- a/apps/web/src/layouts/DefaultLayout/Nav.tsx +++ b/apps/web/src/layouts/DefaultLayout/Nav.tsx @@ -1,9 +1,11 @@ -import { Flex, Label, Stack, atoms } from '@zoralabs/zord' +import { Box, Flex, Label, Stack, atoms } from '@zoralabs/zord' import Link from 'next/link' import React from 'react' +import { BridgeModal } from 'src/components/BridgeModal/BridgeModal' import { NetworkController } from 'src/components/NetworkController' import { PUBLIC_IS_TESTNET } from 'src/constants/defaultChains' +import { useBridgeModal } from 'src/hooks/useBridgeModal' import { useScrollDirection } from 'src/hooks/useScrollDirection' import NogglesLogo from '../assets/builder-framed.svg' @@ -13,6 +15,7 @@ import { NavMenu } from './NavMenu' export const Nav = () => { const scrollDirection = useScrollDirection() + const { canUserBridge, openBridgeModal } = useBridgeModal() return ( { }} className={NavContainer} > + @@ -60,9 +64,15 @@ export const Nav = () => { - - - + {canUserBridge ? ( + + + + ) : ( + + + + )} diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu.tsx index dca0e3abd..10ad49c49 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu.tsx @@ -15,6 +15,7 @@ import { NetworkController } from 'src/components/NetworkController' import { PUBLIC_DEFAULT_CHAINS } from 'src/constants/defaultChains' import SWR_KEYS from 'src/constants/swrKeys' import { MyDaosResponse } from 'src/data/subgraph/requests/daoQuery' +import { useBridgeModal } from 'src/hooks/useBridgeModal' import { useEnsData } from 'src/hooks/useEnsData' import { useLayoutStore } from 'src/stores' import { useChainStore } from 'src/stores/useChainStore' @@ -47,6 +48,8 @@ export const NavMenu = () => { const { chain: selectedChain, setChain } = useChainStore() const { address } = useAccount() + const { canUserBridge, openBridgeModal } = useBridgeModal() + const { displayName, ensAvatar } = useEnsData(address as string) const { data: balance } = useBalance({ address: address as `0x${string}`, @@ -392,13 +395,23 @@ export const NavMenu = () => { - - - - Bridge - - - + {canUserBridge ? ( + + + + Bridge + + + + ) : ( + + + + Bridge + + + + )}