diff --git a/.eslintignore b/.eslintignore index 679839c..a72b3c4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,5 +2,6 @@ node_modules dist build coverage +postcss.config.js next.config.js tailwind.config.js \ No newline at end of file diff --git a/README.md b/README.md index 0859afb..b9453bd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ An interchain web app for bridging tokens with Hyperlane. # Install dependencies yarn -# Build source and generate types +# Build Next project yarn build ``` @@ -22,11 +22,25 @@ yarn dev ## Test ```sh -# Run all unit tests -yarn test - # Lint check code yarn lint + +# Check code types +yarn typecheck +``` + +## Format + +```sh +# Format code using Prettier +yarn prettier +``` + +## Clean / Reset + +```sh +# Delete build artifacts to start fresh +yarn clean ``` ## Learn more diff --git a/next.config.js b/next.config.js index 63933fb..3c9da16 100755 --- a/next.config.js +++ b/next.config.js @@ -21,6 +21,7 @@ const securityHeaders = [ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin', }, + // Note, causes a problem for firefox: https://github.com/MetaMask/metamask-extension/issues/3133 { key: 'Content-Security-Policy', value: `default-src 'self'; script-src 'self'${ diff --git a/public/background-texture2.png b/public/background-texture2.png deleted file mode 100644 index 4fe9aa0..0000000 Binary files a/public/background-texture2.png and /dev/null differ diff --git a/public/background-texture3.png b/public/background-texture3.png deleted file mode 100644 index 8ec8e34..0000000 Binary files a/public/background-texture3.png and /dev/null differ diff --git a/src/components/animation/Fade.tsx b/src/components/animation/Fade.tsx deleted file mode 100644 index 466355b..0000000 --- a/src/components/animation/Fade.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { PropsWithChildren, useEffect, useState } from 'react'; - -export function Fade(props: PropsWithChildren<{ show: boolean }>) { - const { show, children } = props; - const [render, setRender] = useState(show); - - useEffect(() => { - if (show) setRender(true); - }, [show]); - - const onAnimationEnd = () => { - if (!show) setRender(false); - }; - - return render ? ( -
- {children} -
- ) : null; -} diff --git a/src/components/buttons/BackButton.tsx b/src/components/buttons/BackButton.tsx deleted file mode 100644 index c1c4694..0000000 --- a/src/components/buttons/BackButton.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import LeftArrow from '../../images/icons/arrow-left-circle.svg'; - -import { IconButton, IconButtonProps } from './IconButton'; - -export function BackButton(props: IconButtonProps) { - return ; -} diff --git a/src/components/buttons/BorderedButton.tsx b/src/components/buttons/BorderedButton.tsx deleted file mode 100644 index 3a5633d..0000000 --- a/src/components/buttons/BorderedButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { PropsWithChildren, ReactElement } from 'react'; - -interface ButtonProps { - type?: 'submit' | 'reset' | 'button'; - onClick?: () => void; - classes?: string; - bold?: boolean; - disabled?: boolean; - icon?: ReactElement; - title?: string; -} - -export function BorderedButton(props: PropsWithChildren) { - const { type, onClick, classes, bold, icon, disabled, title } = props; - - const base = 'border border-black rounded transition-all'; - const onHover = 'hover:border-gray-500 hover:text-gray-500'; - const onDisabled = 'disabled:border-gray-300 disabled:text-gray-300'; - const onActive = 'active:border-gray-400 active:text-gray-400'; - const weight = bold ? 'font-semibold' : ''; - const allClasses = `${base} ${onHover} ${onDisabled} ${onActive} ${weight} ${classes}`; - - return ( - - ); -} diff --git a/src/components/buttons/TextButton.tsx b/src/components/buttons/TextButton.tsx deleted file mode 100644 index 56409dd..0000000 --- a/src/components/buttons/TextButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { PropsWithChildren } from 'react'; - -export interface TextButtonProps { - classes?: string; - onClick?: () => void; - disabled?: boolean; - type?: 'button' | 'submit'; - passThruProps?: any; -} - -export function TextButton(props: PropsWithChildren) { - const { classes, onClick, disabled, type, children, passThruProps } = props; - - const base = 'flex place-content-center transition-all'; - const onHover = 'hover:opacity-70'; - const onDisabled = 'disabled:opacity-50'; - const onActive = 'active:opacity-60'; - const allClasses = `${base} ${onHover} ${onDisabled} ${onActive} ${classes}`; - - return ( - - ); -} diff --git a/src/components/layout/HrDivider.tsx b/src/components/layout/HrDivider.tsx deleted file mode 100644 index 3808253..0000000 --- a/src/components/layout/HrDivider.tsx +++ /dev/null @@ -1,8 +0,0 @@ -interface Props { - classes?: string; -} - -export function HrDivider(props: Props) { - const { classes } = props; - return
; -} diff --git a/src/features/tokens/useTokenBalance.tsx b/src/features/tokens/useTokenBalance.tsx index c05ec3c..ab759bb 100644 --- a/src/features/tokens/useTokenBalance.tsx +++ b/src/features/tokens/useTokenBalance.tsx @@ -1,10 +1,19 @@ -import { useQuery } from '@tanstack/react-query'; +import { QueryClient, useQuery } from '@tanstack/react-query'; import { useAccount } from 'wagmi'; import { logger } from '../../utils/logger'; import { getErc20Contract } from '../contracts/erc20'; import { getProvider } from '../providers'; +export function getTokenBalanceKey( + chainId: number, + tokenAddress: Address, + isConnected: boolean, + accountAddress?: Address, +) { + return ['tokenBalance', chainId, tokenAddress, accountAddress, isConnected]; +} + export function useTokenBalance(chainId: number, tokenAddress: Address) { const { address: accountAddress, isConnected } = useAccount(); @@ -13,7 +22,7 @@ export function useTokenBalance(chainId: number, tokenAddress: Address) { isError: hasError, data: balance, } = useQuery( - ['tokenBalance', chainId, tokenAddress, accountAddress, isConnected], + getTokenBalanceKey(chainId, tokenAddress, isConnected, accountAddress), () => { if (!chainId || !tokenAddress || !accountAddress || !isConnected) return null; return fetchTokenBalance(chainId, tokenAddress, accountAddress); @@ -24,6 +33,18 @@ export function useTokenBalance(chainId: number, tokenAddress: Address) { return { isFetching, hasError, balance }; } +export function getCachedTokenBalance( + queryClient: QueryClient, + chainId: number, + tokenAddress: Address, + isConnected: boolean, + accountAddress?: Address, +) { + return queryClient.getQueryData( + getTokenBalanceKey(chainId, tokenAddress, isConnected, accountAddress), + ) as string | undefined; +} + async function fetchTokenBalance(chainId: number, tokenAddress: Address, accountAddress: Address) { logger.debug( `Fetching balance for account ${accountAddress} token ${tokenAddress} on chain ${chainId}`, diff --git a/src/features/transfer/TransferTokenForm.tsx b/src/features/transfer/TransferTokenForm.tsx index b35247a..f89cab7 100644 --- a/src/features/transfer/TransferTokenForm.tsx +++ b/src/features/transfer/TransferTokenForm.tsx @@ -1,5 +1,7 @@ +import { useQueryClient } from '@tanstack/react-query'; import { Form, Formik, useFormikContext } from 'formik'; import { useState } from 'react'; +import { useAccount } from 'wagmi'; import { chainIdToMetadata, chainMetadata } from '@hyperlane-xyz/sdk'; @@ -10,6 +12,7 @@ import { ChevronIcon } from '../../components/icons/Chevron'; import { HyperlaneChevron, HyperlaneWideChevron } from '../../components/icons/HyperlaneChevron'; import { TextField } from '../../components/input/TextField'; import { Card } from '../../components/layout/Card'; +import { config } from '../../consts/config'; import GearIcon from '../../images/icons/gear.svg'; import SwapIcon from '../../images/icons/swap.svg'; import { Color } from '../../styles/Color'; @@ -19,7 +22,7 @@ import { getChainDisplayName, getChainEnvironment } from '../../utils/chains'; import { logger } from '../../utils/logger'; import { ChainSelectField } from '../chains/ChainSelectField'; import { TokenSelectField } from '../tokens/TokenSelectField'; -import { useTokenBalance } from '../tokens/useTokenBalance'; +import { getCachedTokenBalance, useTokenBalance } from '../tokens/useTokenBalance'; import { TransferTransactionsModal } from './TransferTransactionsModal'; import { TransferFormValues } from './types'; @@ -47,6 +50,8 @@ export function TransferTokenForm() { setIsReview(false); }; + const queryClient = useQueryClient(); + const { address: accountAddress, isConnected } = useAccount(); const validateForm = ({ sourceChainId, destinationChainId, @@ -64,11 +69,6 @@ export function TransferTokenForm() { if (getChainEnvironment(sourceChainId) !== getChainEnvironment(destinationChainId)) { return { destinationChainId: 'Invalid chain combination' }; } - // TODO check balance and check non-zero - const parsedAmount = tryParseAmount(amount); - if (!parsedAmount || parsedAmount.lte(0)) { - return { amount: 'Invalid amount' }; - } if (!isValidAddress(recipientAddress)) { return { recipientAddress: 'Invalid recipient' }; } @@ -78,6 +78,20 @@ export function TransferTokenForm() { if (!isValidAddress(hypCollateralAddress)) { return { tokenAddress: 'Invalid collateral token' }; } + const parsedAmount = tryParseAmount(amount); + if (!parsedAmount || parsedAmount.lte(0)) { + return { amount: 'Invalid amount' }; + } + const cachedBalance = getCachedTokenBalance( + queryClient, + sourceChainId, + tokenAddress, + isConnected, + accountAddress, + ); + if (cachedBalance && parsedAmount.gt(cachedBalance) && !config.debug) { + return { amount: 'Insufficient balance' }; + } return {}; }; @@ -110,59 +124,56 @@ export function TransferTokenForm() { validateOnBlur={false} > {({ values }) => ( - <> -
-
- -
-
- - - -
- + +
+ +
+
+ + +
- +
-
-
-
+
+
+ + +
+
+
+ - +
-
-
- - -
+
+
-
- +
+
+ +
+
- - {!isReview ? ( - - ) : ( -
- } - > - Edit - - triggerTransactions(values)} - classes="flex-1 px-3 py-1.5" - > - {`Send to ${getChainDisplayName(values.destinationChainId)}`} - -
- )} - - - +
+ + {!isReview ? ( + + ) : ( +
+ } + > + Edit + + triggerTransactions(values)} + classes="flex-1 px-3 py-1.5" + > + {`Send to ${getChainDisplayName(values.destinationChainId)}`} + +
+ )} + )} + ); } @@ -243,7 +258,14 @@ function SwapChainsButton({ disabled }: { disabled?: boolean }) { ); } -function TokenBalance({ disabled }: { disabled?: boolean }) { +function TokenBalance() { + const { values } = useFormikContext(); + const { balance } = useTokenBalance(values.sourceChainId, values.tokenAddress); + const rounded = fromWeiRounded(balance); + return
{`Balance: ${rounded}`}
; +} + +function MaxButton({ disabled }: { disabled?: boolean }) { const { values, setFieldValue } = useFormikContext(); const { balance } = useTokenBalance(values.sourceChainId, values.tokenAddress); const rounded = fromWeiRounded(balance); @@ -251,13 +273,34 @@ function TokenBalance({ disabled }: { disabled?: boolean }) { if (balance && !disabled) setFieldValue('amount', rounded); }; return ( - + color="gray" + disabled={disabled} + classes="text-xs rounded-sm absolute right-0.5 top-2 bottom-0.5 px-2" + > + MAX + + ); +} + +function SelfButton({ disabled }: { disabled?: boolean }) { + const { address } = useAccount(); + const { setFieldValue } = useFormikContext(); + const onClick = () => { + if (address && !disabled) setFieldValue('recipientAddress', address); + }; + return ( + + SELF + ); } diff --git a/src/images/icons/arrow-left-circle.svg b/src/images/icons/arrow-left-circle.svg deleted file mode 100644 index b66a93c..0000000 --- a/src/images/icons/arrow-left-circle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/arrow-right-short.svg b/src/images/icons/arrow-right-short.svg deleted file mode 100644 index fd489f4..0000000 --- a/src/images/icons/arrow-right-short.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/arrow-right.svg b/src/images/icons/arrow-right.svg deleted file mode 100644 index 3ec6b87..0000000 --- a/src/images/icons/arrow-right.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/asterisk.svg b/src/images/icons/asterisk.svg deleted file mode 100644 index 8b0a9da..0000000 --- a/src/images/icons/asterisk.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/book.svg b/src/images/icons/book.svg deleted file mode 100644 index 76589a5..0000000 --- a/src/images/icons/book.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/briefcase.svg b/src/images/icons/briefcase.svg deleted file mode 100644 index bc6150d..0000000 --- a/src/images/icons/briefcase.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/images/icons/bug.svg b/src/images/icons/bug.svg deleted file mode 100644 index 3be5a00..0000000 --- a/src/images/icons/bug.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/images/icons/clipboard-plus.svg b/src/images/icons/clipboard-plus.svg deleted file mode 100644 index 8a79efc..0000000 --- a/src/images/icons/clipboard-plus.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/images/icons/funnel.svg b/src/images/icons/funnel.svg deleted file mode 100644 index 5f16f16..0000000 --- a/src/images/icons/funnel.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/house.svg b/src/images/icons/house.svg deleted file mode 100644 index 7d80bf8..0000000 --- a/src/images/icons/house.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/icons/hub.svg b/src/images/icons/hub.svg deleted file mode 100644 index a65c580..0000000 --- a/src/images/icons/hub.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/icons/key.svg b/src/images/icons/key.svg deleted file mode 100644 index 25a6d45..0000000 --- a/src/images/icons/key.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/lock.svg b/src/images/icons/lock.svg deleted file mode 100644 index 04ae18b..0000000 --- a/src/images/icons/lock.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/icons/paper-airplane.svg b/src/images/icons/paper-airplane.svg deleted file mode 100644 index c91293b..0000000 --- a/src/images/icons/paper-airplane.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/search-off.svg b/src/images/icons/search-off.svg deleted file mode 100644 index 6808786..0000000 --- a/src/images/icons/search-off.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/icons/search.svg b/src/images/icons/search.svg deleted file mode 100644 index aa4dfa9..0000000 --- a/src/images/icons/search.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/icons/shield-check.svg b/src/images/icons/shield-check.svg deleted file mode 100644 index facd23b..0000000 --- a/src/images/icons/shield-check.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/shrug.svg b/src/images/icons/shrug.svg deleted file mode 100644 index 64a5a1f..0000000 --- a/src/images/icons/shrug.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/icons/sliders.svg b/src/images/icons/sliders.svg deleted file mode 100644 index 89c6b41..0000000 --- a/src/images/icons/sliders.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/logos/discord-alt.svg b/src/images/logos/discord-alt.svg deleted file mode 100644 index 69b452f..0000000 --- a/src/images/logos/discord-alt.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/logos/hyperlane-chevron-single-blue.svg b/src/images/logos/hyperlane-chevron-single-blue.svg deleted file mode 100644 index e4aef8a..0000000 --- a/src/images/logos/hyperlane-chevron-single-blue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/images/logos/hyperlane-chevron.svg b/src/images/logos/hyperlane-chevron.svg deleted file mode 100644 index 048e7d8..0000000 --- a/src/images/logos/hyperlane-chevron.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 655da3c..fa2340a 100755 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -10,7 +10,7 @@ import XCircle from '../images/icons/x-circle.svg'; const Home: NextPage = () => { return ( -
+
@@ -25,8 +25,8 @@ function InfoCard() {

Bridge Tokens Permissionlessly with Hyperlane!

- Send tokens across chains in seconds or make tokens interchain-ready with just a few - clicks. + Send tokens across chains in seconds or make tokens interchain-ready with just a few lines + of code.

[value, key])); -} - -// Get the subset of the object from key list -export function pick(obj: Record, keys: K[]) { - const ret: Partial> = {}; - for (const key of keys) { - ret[key] = obj[key]; - } - return ret as Record; -} - -// Remove a particular key from an object if it exists -export function omit(obj: Record, key: K) { - const ret: Partial> = {}; - for (const k of Object.keys(obj)) { - if (k === key) continue; - ret[k] = obj[k]; - } - return ret as Record; -} - -// Returns an object with the keys as values from an array and value set to true -export function arrayToObject(keys: Array, val = true) { - return keys.reduce((result, k) => { - result[k] = val; - return result; - }, {}); -} diff --git a/src/utils/queryParams.ts b/src/utils/queryParams.ts deleted file mode 100644 index 056798d..0000000 --- a/src/utils/queryParams.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ParsedUrlQuery } from 'querystring'; - -import { logger } from './logger'; - -// To make Next's awkward query param typing more convenient -export function getQueryParamString(query: ParsedUrlQuery, key: string, defaultVal = '') { - if (!query) return defaultVal; - const val = query[key]; - if (val && typeof val === 'string') return val; - else return defaultVal; -} - -// Circumventing Next's router.replace method here because -// it's async and causes race conditions btwn components. -// This will only modify the url but not trigger any routing -export function replacePathParam(key: string, val: string) { - try { - const url = new URL(window.location.href); - if (val) { - url.searchParams.set(key, val); - } else { - url.searchParams.delete(key); - } - window.history.replaceState('', '', url); - } catch (error) { - logger.error('Error replacing path param', error); - } -} diff --git a/src/utils/string.ts b/src/utils/string.ts index 9bec440..c36a9fc 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -4,7 +4,6 @@ export function toTitleCase(str: string) { }); } -// TODO add unit tests // Only allows letters and numbers const alphanumericRgex = /[^a-zA-Z0-9]/gi; export function sanitizeString(str: string) { diff --git a/src/utils/time.ts b/src/utils/time.ts deleted file mode 100644 index 117393a..0000000 --- a/src/utils/time.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Inspired by https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site -export function getHumanReadableTimeString(timestamp: number) { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds <= 1) { - return 'Just now'; - } - if (seconds <= 60) { - return `${seconds} seconds ago`; - } - const minutes = Math.floor(seconds / 60); - if (minutes <= 1) { - return '1 minute ago'; - } - if (minutes < 60) { - return `${minutes} minutes ago`; - } - const hours = Math.floor(minutes / 60); - if (hours <= 1) { - return '1 hour ago'; - } - if (hours < 24) { - return `${hours} hours ago`; - } - - const date = new Date(timestamp); - return date.toLocaleDateString(); -} - -export function getHumanReadableDuration(ms: number, minSec?: number) { - let seconds = Math.round(ms / 1000); - - if (minSec) { - seconds = Math.max(seconds, minSec); - } - - if (seconds <= 60) { - return `${seconds} sec`; - } - const minutes = Math.floor(seconds / 60); - if (minutes < 60) { - return `${minutes} min`; - } - const hours = Math.floor(minutes / 60); - return `${hours} hr`; -} - -export function getDateTimeString(timestamp: number) { - const date = new Date(timestamp); - return `${date.toLocaleTimeString()} ${date.toLocaleDateString()}`; -} - -// Adjusts a timestamp forward/backward based on -// the local time's for the timezone offset -export function adjustToUtcTime(timestamp: number) { - const offsetMs = new Date().getTimezoneOffset() * 60_000; - const adjusted = new Date(timestamp + offsetMs); - return adjusted.toISOString(); -} diff --git a/tailwind.config.js b/tailwind.config.js index 2659bac..83af836 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -31,17 +31,6 @@ module.exports = { 800: '#002440', 900: '#001220', }, - beige: { - 100: '#F6F4F1', - 200: '#F5F2EF', - 300: '#F3F0ED', - 400: '#F2EEEB', - 500: '#F1EDE9', - 600: '#D8D5D1', - 700: '#C0BDBA', - 800: '#A8A5A3', - 900: '#908E8B', - }, red: { 100: '#EBBAB8', 200: '#DF8D8A',