diff --git a/.eslintignore b/.eslintignore index f06235c46..bfe0ccf2b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ node_modules dist +*.generated.ts \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 9559167ba..2aa46d632 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -18,7 +18,6 @@ "@nx/enforce-module-boundaries": [ "error", { - "enforceBuildableLibDependency": true, "allow": [], "depConstraints": [ { diff --git a/apps/oeth/src/components/Topnav.tsx b/apps/oeth/src/components/Topnav.tsx index 703d55a8f..c3f407eb1 100644 --- a/apps/oeth/src/components/Topnav.tsx +++ b/apps/oeth/src/components/Topnav.tsx @@ -10,7 +10,7 @@ import { useMediaQuery, useTheme, } from '@mui/material'; -import { AccountPopover } from '@origin/oeth/shared'; +import { AccountPopover, ActivityButton } from '@origin/oeth/shared'; import { OpenAccountModalButton } from '@origin/shared/providers'; import { useIntl } from 'react-intl'; import { Link, useLocation, useNavigate } from 'react-router-dom'; @@ -198,6 +198,16 @@ export function Topnav(props: BoxProps) { anchor={accountModalAnchor} setAnchor={setAccountModalAnchor} /> + , diff --git a/libs/oeth/history/src/queries.generated.ts b/libs/oeth/history/src/queries.generated.ts index eb3e0b9ca..cc8d19dcb 100644 --- a/libs/oeth/history/src/queries.generated.ts +++ b/libs/oeth/history/src/queries.generated.ts @@ -1,40 +1,21 @@ -import { graphqlClient } from '@origin/oeth/shared'; -import { useQuery } from '@tanstack/react-query'; +import * as Types from '@origin/oeth/shared'; -import type * as Types from '@origin/oeth/shared'; -import type { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { graphqlClient } from '@origin/oeth/shared'; export type HistoryPageQueryVariables = Types.Exact<{ address: Types.Scalars['String']['input']; offset: Types.Scalars['Int']['input']; filters?: Types.InputMaybe | Types.HistoryType>; }>; -export type HistoryPageQuery = { - __typename?: 'Query'; - addresses: Array<{ - __typename?: 'Address'; - balance: string; - earned: string; - isContract: boolean; - rebasingOption: Types.RebasingOption; - lastUpdated: string; - history: Array<{ - __typename?: 'History'; - type: Types.HistoryType; - value: string; - txHash: string; - timestamp: string; - balance: string; - }>; - }>; -}; -export type HistoryApyQueryVariables = Types.Exact<{ [key: string]: never }>; +export type HistoryPageQuery = { __typename?: 'Query', addresses: Array<{ __typename?: 'Address', balance: string, earned: string, isContract: boolean, rebasingOption: Types.RebasingOption, lastUpdated: string, history: Array<{ __typename?: 'History', type: Types.HistoryType, value: string, txHash: string, timestamp: string, balance: string }> }> }; + +export type HistoryApyQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type HistoryApyQuery = { __typename?: 'Query', apies: Array<{ __typename?: 'APY', apy7DayAvg: number, apy30DayAvg: number }> }; -export type HistoryApyQuery = { - __typename?: 'Query'; - apies: Array<{ __typename?: 'APY'; apy7DayAvg: number; apy30DayAvg: number }>; -}; export const HistoryPageDocument = ` query HistoryPage($address: String!, $offset: Int!, $filters: [HistoryType!]) { @@ -59,32 +40,23 @@ export const HistoryPageDocument = ` } } `; -export const useHistoryPageQuery = ( - variables: HistoryPageQueryVariables, - options?: UseQueryOptions, -) => - useQuery( - ['HistoryPage', variables], - graphqlClient( - HistoryPageDocument, - variables, - ), - options, - ); +export const useHistoryPageQuery = < + TData = HistoryPageQuery, + TError = unknown + >( + variables: HistoryPageQueryVariables, + options?: UseQueryOptions + ) => + useQuery( + ['HistoryPage', variables], + graphqlClient(HistoryPageDocument, variables), + options + ); + +useHistoryPageQuery.getKey = (variables: HistoryPageQueryVariables) => ['HistoryPage', variables]; +; -useHistoryPageQuery.getKey = (variables: HistoryPageQueryVariables) => [ - 'HistoryPage', - variables, -]; -useHistoryPageQuery.fetcher = ( - variables: HistoryPageQueryVariables, - options?: RequestInit['headers'], -) => - graphqlClient( - HistoryPageDocument, - variables, - options, - ); +useHistoryPageQuery.fetcher = (variables: HistoryPageQueryVariables, options?: RequestInit['headers']) => graphqlClient(HistoryPageDocument, variables, options); export const HistoryApyDocument = ` query HistoryApy { apies(limit: 1, orderBy: timestamp_DESC) { @@ -93,27 +65,20 @@ export const HistoryApyDocument = ` } } `; -export const useHistoryApyQuery = ( - variables?: HistoryApyQueryVariables, - options?: UseQueryOptions, -) => - useQuery( - variables === undefined ? ['HistoryApy'] : ['HistoryApy', variables], - graphqlClient( - HistoryApyDocument, - variables, - ), - options, - ); +export const useHistoryApyQuery = < + TData = HistoryApyQuery, + TError = unknown + >( + variables?: HistoryApyQueryVariables, + options?: UseQueryOptions + ) => + useQuery( + variables === undefined ? ['HistoryApy'] : ['HistoryApy', variables], + graphqlClient(HistoryApyDocument, variables), + options + ); + +useHistoryApyQuery.getKey = (variables?: HistoryApyQueryVariables) => variables === undefined ? ['HistoryApy'] : ['HistoryApy', variables]; +; -useHistoryApyQuery.getKey = (variables?: HistoryApyQueryVariables) => - variables === undefined ? ['HistoryApy'] : ['HistoryApy', variables]; -useHistoryApyQuery.fetcher = ( - variables?: HistoryApyQueryVariables, - options?: RequestInit['headers'], -) => - graphqlClient( - HistoryApyDocument, - variables, - options, - ); +useHistoryApyQuery.fetcher = (variables?: HistoryApyQueryVariables, options?: RequestInit['headers']) => graphqlClient(HistoryApyDocument, variables, options); \ No newline at end of file diff --git a/libs/oeth/redeem/src/hooks.tsx b/libs/oeth/redeem/src/hooks.tsx index 58ff0c4e7..6f822edee 100644 --- a/libs/oeth/redeem/src/hooks.tsx +++ b/libs/oeth/redeem/src/hooks.tsx @@ -1,12 +1,16 @@ import { useCallback } from 'react'; -import { contracts } from '@origin/shared/contracts'; +import { Box } from '@mui/material'; import { - BlockExplorerLink, - usePushNotification, - useSlippage, -} from '@origin/shared/providers'; -import { isNilOrEmpty } from '@origin/shared/utils'; + RedeemNotification, + useDeleteActivity, + usePushActivity, + useUpdateActivity, +} from '@origin/oeth/shared'; +import { NotificationSnack } from '@origin/shared/components'; +import { contracts, tokens } from '@origin/shared/contracts'; +import { usePushNotification, useSlippage } from '@origin/shared/providers'; +import { isNilOrEmpty, isUserRejected } from '@origin/shared/utils'; import { prepareWriteContract, waitForTransaction, @@ -40,6 +44,9 @@ export const useHandleRedeem = () => { const intl = useIntl(); const { value: slippage } = useSlippage(); const pushNotification = usePushNotification(); + const pushActivity = usePushActivity(); + const updateActivity = useUpdateActivity(); + const deleteActivity = useDeleteActivity(); const { address } = useAccount(); const [{ amountIn, amountOut }, setRedeemState] = useRedeemState(); const wagmiClient = useQueryClient(); @@ -49,6 +56,23 @@ export const useHandleRedeem = () => { return; } + const minAmountOut = parseUnits( + ( + +formatUnits(amountOut, MIX_TOKEN.decimals) - + +formatUnits(amountOut, MIX_TOKEN.decimals) * slippage + ).toString(), + MIX_TOKEN.decimals, + ); + + const activity = pushActivity({ + type: 'redeem', + status: 'pending', + tokenIn: tokens.mainnet.OETH, + tokenOut: MIX_TOKEN, + amountIn, + amountOut, + }); + setRedeemState( produce((draft) => { draft.isRedeemLoading = true; @@ -56,14 +80,6 @@ export const useHandleRedeem = () => { ); try { - const minAmountOut = parseUnits( - ( - +formatUnits(amountOut, MIX_TOKEN.decimals) - - +formatUnits(amountOut, MIX_TOKEN.decimals) * slippage - ).toString(), - MIX_TOKEN.decimals, - ); - const { request } = await prepareWriteContract({ address: contracts.mainnet.OETHVaultCore.address, abi: contracts.mainnet.OETHVaultCore.abi, @@ -71,43 +87,67 @@ export const useHandleRedeem = () => { args: [amountIn, minAmountOut], }); const { hash } = await writeContract(request); + setRedeemState( + produce((draft) => { + draft.isRedeemLoading = false; + }), + ); const txReceipt = await waitForTransaction({ hash }); - - console.log('redeem vault done!'); wagmiClient.invalidateQueries({ queryKey: ['redeem_balance'] }); + updateActivity({ ...activity, status: 'success', txReceipt }); pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Redeem complete' }), - severity: 'success', - content: , + content: ( + + ), }); - } catch (e) { - console.error(`redeem vault error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED') { + } catch (error) { + if (isUserRejected(error)) { + deleteActivity(activity.id); pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Redeem vault' }), - severity: 'info', + content: ( + } + title={intl.formatMessage({ + defaultMessage: 'Operation Cancelled', + })} + subtitle={intl.formatMessage({ + defaultMessage: 'User rejected operation', + })} + /> + ), }); } else { + updateActivity({ + ...activity, + status: 'error', + error: error?.shortMessage ?? error.message, + }); pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Redeem vault' }), - severity: 'error', + content: ( + + ), }); } } - - setRedeemState( - produce((draft) => { - draft.isRedeemLoading = false; - }), - ); }, [ address, amountIn, amountOut, + deleteActivity, intl, + pushActivity, pushNotification, setRedeemState, slippage, + updateActivity, wagmiClient, ]); }; diff --git a/libs/oeth/redeem/src/state.ts b/libs/oeth/redeem/src/state.ts index a15ae9d57..f428a44ee 100644 --- a/libs/oeth/redeem/src/state.ts +++ b/libs/oeth/redeem/src/state.ts @@ -77,7 +77,7 @@ export const { Provider: RedeemProvider, useTracked: useRedeemState } = try { splitEstimates = await queryClient.fetchQuery({ queryKey: ['splitEstimates', state.amountIn.toString()], - queryFn: () => + queryFn: async () => readContract({ address: contracts.mainnet.OETHVaultCore.address, abi: contracts.mainnet.OETHVaultCore.abi, @@ -85,8 +85,8 @@ export const { Provider: RedeemProvider, useTracked: useRedeemState } = args: [state.amountIn], }), }); - } catch (e) { - console.error(`redeem vault estimate amount error.\n${e.message}`); + } catch (error) { + console.error(`Fail to estimate redeem operation.\n${error.message}`); setState( produce((draft) => { draft.amountIn = 0n; @@ -99,7 +99,7 @@ export const { Provider: RedeemProvider, useTracked: useRedeemState } = title: intl.formatMessage({ defaultMessage: 'Error while estimating', }), - message: e.shortMessage, + message: error?.shortMessage ?? error.message, severity: 'error', }); @@ -145,10 +145,8 @@ export const { Provider: RedeemProvider, useTracked: useRedeemState } = account: whales.mainnet.OETH, }), }); - } catch (e) { - console.error( - `redeem vault estimate gas error. Using default!\n${e.message}`, - ); + } catch (error) { + console.log(`Redeem uses fix gas estimate: 1500000`); gasEstimate = 1500000n; } diff --git a/libs/oeth/redeem/src/views/RedeemView.tsx b/libs/oeth/redeem/src/views/RedeemView.tsx index 382796cac..26fd8c606 100644 --- a/libs/oeth/redeem/src/views/RedeemView.tsx +++ b/libs/oeth/redeem/src/views/RedeemView.tsx @@ -8,7 +8,7 @@ import { Stack, Typography, } from '@mui/material'; -import { GasPopover } from '@origin/oeth/shared'; +import { ApyHeader, GasPopover } from '@origin/oeth/shared'; import { TokenInput } from '@origin/shared/components'; import { tokens } from '@origin/shared/contracts'; import { @@ -87,54 +87,63 @@ function RedeemViewWrapped() { amountIn === 0n; return ( - - - - {intl.formatMessage({ defaultMessage: 'Redeem' })} - - - - } - /> - - - `linear-gradient(${theme.palette.grey[900]}, ${ - theme.palette.grey[900] - }) padding-box, + <> + + + + + {intl.formatMessage({ defaultMessage: 'Redeem' })} + + theme.spacing(-0.75), + svg: { width: 16, height: 16 }, + }, + }} + /> + + } + /> + + + `linear-gradient(${theme.palette.grey[900]}, ${ + theme.palette.grey[900] + }) padding-box, linear-gradient(90deg, ${alpha( theme.palette.primary.main, 0.4, @@ -142,41 +151,42 @@ function RedeemViewWrapped() { theme.palette.primary.dark, 0.4, )} 100%) border-box;`, - }, - '&:focus-within': { - background: (theme) => - `linear-gradient(${theme.palette.grey[900]}, ${theme.palette.grey[900]}) padding-box, + }, + '&:focus-within': { + background: (theme) => + `linear-gradient(${theme.palette.grey[900]}, ${theme.palette.grey[900]}) padding-box, linear-gradient(90deg, var(--mui-palette-primary-main) 0%, var(--mui-palette-primary-dark) 100%) border-box;`, - }, - }} - /> - - - - - - {isEstimateLoading ? ( - - ) : isRedeemLoading ? ( - intl.formatMessage({ defaultMessage: 'Waiting for signature' }) - ) : ( - redeemButtonLabel - )} - - - + }, + }} + /> + + + + + + {isEstimateLoading ? ( + + ) : isRedeemLoading ? ( + intl.formatMessage({ defaultMessage: 'Waiting for signature' }) + ) : ( + redeemButtonLabel + )} + + + + ); } diff --git a/libs/oeth/shared/codegen.ts b/libs/oeth/shared/codegen.ts index 144a7e360..4b67d5611 100644 --- a/libs/oeth/shared/codegen.ts +++ b/libs/oeth/shared/codegen.ts @@ -6,7 +6,6 @@ const config: CodegenConfig = { schema: process.env.VITE_SUBSQUID_URL, documents: ['**/src/**/*.graphql'], plugins: ['typescript'], - hooks: { afterOneFileWrite: ['prettier --write', 'eslint --fix'] }, config: { scalars: { BigInt: 'string', @@ -22,7 +21,6 @@ const config: CodegenConfig = { extension: '.generated.ts', baseTypesPath: '~@origin/oeth/shared', }, - hooks: { afterOneFileWrite: ['prettier --write', 'eslint --fix'] }, plugins: ['typescript-operations', 'typescript-react-query'], config: { exposeFetcher: true, diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/ActivityButton.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityButton.tsx new file mode 100644 index 000000000..1b7dcdc57 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityButton.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; + +import { IconButton } from '@mui/material'; +import { ActivityIcon } from '@origin/shared/components'; + +import { useGlobalStatus } from '../hooks'; +import { ActivityPopover } from './ActivityPopover'; + +import type { IconButtonProps } from '@mui/material'; + +export const ActivityButton = ( + props: Omit, +) => { + const [anchorEl, setAnchorEl] = useState(null); + const status = useGlobalStatus(); + + return ( + <> + setAnchorEl(e.currentTarget)} + > + + + + + ); +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/ActivityPopover.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityPopover.tsx new file mode 100644 index 000000000..c4229d51d --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/ActivityPopover.tsx @@ -0,0 +1,145 @@ +import { + Button, + Divider, + Popover, + Stack, + Typography, + useTheme, +} from '@mui/material'; +import { isNilOrEmpty } from '@origin/shared/utils'; +import { produce } from 'immer'; +import { descend, pipe, prop, sort, take } from 'ramda'; +import { useIntl } from 'react-intl'; + +import { useActivityState } from '../state'; +import { ApprovalNotification } from './ApprovalNotification'; +import { RedeemNotification } from './RedeemNotification'; +import { SwapNotification } from './SwapNotification'; + +import type { StackProps } from '@mui/material'; + +import type { Activity } from '../types'; + +export type AcitivityPopoverProps = { + anchor: HTMLElement | null; + setAnchor: (value: HTMLButtonElement | null) => void; +}; + +export const ActivityPopover = ({ + anchor, + setAnchor, +}: AcitivityPopoverProps) => { + const intl = useIntl(); + const theme = useTheme(); + const [{ activities, maxVisible }, setActivityState] = useActivityState(); + + const handleClose = () => { + setAnchor(null); + }; + + const handleClearAll = () => { + setActivityState( + produce((state) => { + state.activities = []; + }), + ); + }; + + const sortedActivities = pipe( + sort(descend(prop('createdOn'))), + take(maxVisible), + )(activities) as Activity[]; + + return ( + ({ + xs: '90vw', + md: `min(${theme.typography.pxToRem(400)}, 90vw)`, + }), + [theme.breakpoints.down('md')]: { + left: '0 !important', + right: 0, + marginInline: 'auto', + }, + }, + }} + > + + + + {intl.formatMessage({ defaultMessage: 'Recent Activity' })} + + + + + + }> + {isNilOrEmpty(sortedActivities) ? ( + + ) : ( + sortedActivities.map( + (a) => + ({ + approval: ( + + ), + redeem: ( + + ), + swap: ( + + ), + })[a.type], + ) + )} + + + + ); +}; + +function EmptyActivity(props: StackProps) { + const intl = useIntl(); + + return ( + + + {intl.formatMessage({ defaultMessage: 'No Activity' })} + + + ); +} diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/ApprovalNotification.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/ApprovalNotification.tsx new file mode 100644 index 000000000..95074e067 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/ApprovalNotification.tsx @@ -0,0 +1,78 @@ +import { Box, Typography } from '@mui/material'; +import { ActivityIcon, NotificationSnack } from '@origin/shared/components'; +import { isNilOrEmpty } from '@origin/shared/utils'; +import { defineMessage, useIntl } from 'react-intl'; +import { formatUnits } from 'viem'; + +import type { StackProps } from '@mui/material'; +import type { Token } from '@origin/shared/contracts'; +import type { MessageDescriptor } from 'react-intl'; +import type { TransactionReceipt } from 'viem'; + +import type { GlobalActivityStatus } from '../types'; + +type ApprovalNotificationProps = { + status: GlobalActivityStatus; + tokenIn: Token; + amountIn?: bigint; + txReceipt?: TransactionReceipt; + error?: string; +} & StackProps; + +const title: Record = { + pending: defineMessage({ defaultMessage: 'Approving' }), + success: defineMessage({ defaultMessage: 'Approved' }), + error: defineMessage({ defaultMessage: 'Error while approving' }), + idle: defineMessage({ defaultMessage: 'Approve' }), +}; + +export const ApprovalNotification = ({ + status, + tokenIn, + amountIn, + txReceipt, + error, + ...rest +}: ApprovalNotificationProps) => { + const intl = useIntl(); + + return ( + } + title={intl.formatMessage(title[status])} + href={ + isNilOrEmpty(txReceipt?.transactionHash) + ? null + : `https://etherscan.io/tx/${txReceipt.transactionHash}` + } + subtitle={ + isNilOrEmpty(error) ? ( + + {intl.formatMessage( + { + defaultMessage: '{amountIn} {symbolIn}', + }, + { + amountIn: intl.formatNumber( + +formatUnits(amountIn, tokenIn.decimals), + { minimumFractionDigits: 4, maximumFractionDigits: 4 }, + ), + symbolIn: tokenIn.symbol, + }, + )} + + ) : ( + {error} + ) + } + endIcon={ + + } + /> + ); +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/RedeemNotification.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/RedeemNotification.tsx new file mode 100644 index 000000000..df96c0add --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/RedeemNotification.tsx @@ -0,0 +1,106 @@ +import { Box, Stack, Typography } from '@mui/material'; +import { + ActivityIcon, + Mix, + NotificationSnack, +} from '@origin/shared/components'; +import { isNilOrEmpty } from '@origin/shared/utils'; +import { defineMessage, useIntl } from 'react-intl'; +import { formatUnits } from 'viem'; + +import type { StackProps } from '@mui/material'; +import type { Token } from '@origin/shared/contracts'; +import type { MessageDescriptor } from 'react-intl'; +import type { TransactionReceipt } from 'viem'; + +import type { GlobalActivityStatus } from '../types'; + +type RedeemNotificationProps = { + status: GlobalActivityStatus; + tokenIn: Token; + tokenOut: Token; + amountIn?: bigint; + amountOut?: bigint; + txReceipt?: TransactionReceipt; + error?: string; +} & StackProps; + +const title: Record = { + pending: defineMessage({ defaultMessage: 'Redeeming' }), + success: defineMessage({ defaultMessage: 'Redeemed' }), + error: defineMessage({ defaultMessage: 'Error while redeeming' }), + idle: defineMessage({ defaultMessage: 'Redeem' }), +}; + +export const RedeemNotification = ({ + status, + tokenIn, + tokenOut, + amountIn, + amountOut, + txReceipt, + error, + ...rest +}: RedeemNotificationProps) => { + const intl = useIntl(); + + return ( + } + title={intl.formatMessage(title[status])} + href={ + isNilOrEmpty(txReceipt?.transactionHash) + ? null + : `https://etherscan.io/tx/${txReceipt.transactionHash}` + } + subtitle={ + isNilOrEmpty(error) ? ( + + {intl.formatMessage( + { + defaultMessage: '{amountIn} {symbolIn}', + }, + { + amountIn: intl.formatNumber( + +formatUnits(amountIn, tokenIn.decimals), + { minimumFractionDigits: 4, maximumFractionDigits: 4 }, + ), + symbolIn: tokenIn.symbol, + amountOut: intl.formatNumber( + +formatUnits(amountOut, tokenOut.decimals), + { minimumFractionDigits: 4, maximumFractionDigits: 4 }, + ), + }, + )} + + ) : ( + {error} + ) + } + endIcon={ + + + + + + } + /> + ); +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/SwapNotification.tsx b/libs/oeth/shared/src/components/ActivityProvider/components/SwapNotification.tsx new file mode 100644 index 000000000..423bb9aee --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/SwapNotification.tsx @@ -0,0 +1,100 @@ +import { Box, Stack, Typography } from '@mui/material'; +import { ActivityIcon, NotificationSnack } from '@origin/shared/components'; +import { isNilOrEmpty } from '@origin/shared/utils'; +import { defineMessage, useIntl } from 'react-intl'; +import { formatUnits } from 'viem'; + +import type { StackProps } from '@mui/material'; +import type { Token } from '@origin/shared/contracts'; +import type { MessageDescriptor } from 'react-intl'; +import type { TransactionReceipt } from 'viem'; + +import type { GlobalActivityStatus } from '../types'; + +type SwapNotificationProps = { + status: GlobalActivityStatus; + tokenIn: Token; + tokenOut: Token; + amountIn?: bigint; + amountOut?: bigint; + txReceipt?: TransactionReceipt; + error?: string; +} & StackProps; + +const title: Record = { + pending: defineMessage({ defaultMessage: 'Swapping' }), + success: defineMessage({ defaultMessage: 'Swapped' }), + error: defineMessage({ defaultMessage: 'Error while swapping' }), + idle: defineMessage({ defaultMessage: 'Swap' }), +}; + +export const SwapNotification = ({ + status, + tokenIn, + tokenOut, + amountIn, + amountOut, + txReceipt, + error, + ...rest +}: SwapNotificationProps) => { + const intl = useIntl(); + + return ( + } + title={intl.formatMessage(title[status])} + href={ + isNilOrEmpty(txReceipt?.transactionHash) + ? null + : `https://etherscan.io/tx/${txReceipt.transactionHash}` + } + subtitle={ + isNilOrEmpty(error) ? ( + + {intl.formatMessage( + { + defaultMessage: + '{amountIn} {symbolIn} for {amountOut} {symbolOut}', + }, + { + amountIn: intl.formatNumber( + +formatUnits(amountIn, tokenIn.decimals), + { minimumFractionDigits: 4, maximumFractionDigits: 4 }, + ), + symbolIn: tokenIn.symbol, + amountOut: intl.formatNumber( + +formatUnits(amountOut, tokenOut.decimals), + { minimumFractionDigits: 4, maximumFractionDigits: 4 }, + ), + symbolOut: tokenOut.symbol, + }, + )} + + ) : ( + {error} + ) + } + endIcon={ + + + + + + } + /> + ); +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/components/index.ts b/libs/oeth/shared/src/components/ActivityProvider/components/index.ts new file mode 100644 index 000000000..14b1dccb3 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/components/index.ts @@ -0,0 +1,4 @@ +export * from './ActivityButton'; +export * from './ApprovalNotification'; +export * from './RedeemNotification'; +export * from './SwapNotification'; diff --git a/libs/oeth/shared/src/components/ActivityProvider/hooks.ts b/libs/oeth/shared/src/components/ActivityProvider/hooks.ts new file mode 100644 index 000000000..a8f83ae49 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/hooks.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { isNilOrEmpty } from '@origin/shared/utils'; +import { usePrevious } from '@react-hookz/web'; +import { produce } from 'immer'; +import { groupBy, prop, propEq } from 'ramda'; + +import { useActivityState } from './state'; + +import type { Activity, GlobalActivityStatus } from './types'; + +export const usePushActivity = () => { + const [, setState] = useActivityState(); + + return useCallback( + (value: Omit) => { + const activity = { + ...value, + id: Date.now().toString(), + createdOn: Date.now(), + }; + setState( + produce((state) => { + state.activities.unshift(activity); + }), + ); + + return activity; + }, + [setState], + ); +}; + +export const useUpdateActivity = () => { + const [, setState] = useActivityState(); + + return useCallback( + (activity: Partial) => { + setState( + produce((state) => { + const idx = state.activities.findIndex(propEq(activity.id, 'id')); + if (idx > -1) { + state.activities[idx] = { + ...state.activities[idx], + ...activity, + }; + } + }), + ); + }, + [setState], + ); +}; + +export const useDeleteActivity = () => { + const [, setState] = useActivityState(); + + return useCallback( + (id: string) => { + setState( + produce((state) => { + const idx = state.activities.findIndex(propEq(id, 'id')); + if (idx > -1) { + state.activities.splice(idx, 1); + } + }), + ); + }, + [setState], + ); +}; + +export const useGlobalStatus = () => { + const [{ activities }] = useActivityState(); + const [status, setStatus] = useState('idle'); + const prev = usePrevious(activities); + + useEffect(() => { + const prevGrouped = groupBy(prop('status'), prev ?? []); + const grouped = groupBy(prop('status'), activities ?? []); + + if (isNilOrEmpty(grouped.pending)) { + if ( + !isNilOrEmpty(grouped.success) && + prevGrouped?.success?.length !== grouped?.success?.length + ) { + setStatus('success'); + setTimeout(() => { + setStatus('idle'); + }, 5000); + } else if ( + !isNilOrEmpty(grouped.error) && + prevGrouped?.error?.length !== grouped?.error?.length + ) { + setStatus('error'); + setTimeout(() => { + setStatus('idle'); + }, 5000); + } else { + setStatus('idle'); + } + } else { + setStatus('pending'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activities]); + + return status; +}; diff --git a/libs/oeth/shared/src/components/ActivityProvider/index.ts b/libs/oeth/shared/src/components/ActivityProvider/index.ts new file mode 100644 index 000000000..a260a059b --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/index.ts @@ -0,0 +1,4 @@ +export * from './components'; +export * from './hooks'; +export * from './state'; +export * from './types'; diff --git a/libs/oeth/shared/src/components/ActivityProvider/state.ts b/libs/oeth/shared/src/components/ActivityProvider/state.ts new file mode 100644 index 000000000..62c2a9ef3 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/state.ts @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +import { createContainer } from 'react-tracked'; + +import type { Activity } from './types'; + +type ActivityState = { + activities: Activity[]; + maxVisible: number; +}; + +export const { Provider: ActivityProvider, useTracked: useActivityState } = + createContainer(() => + useState({ activities: [], maxVisible: 10 }), + ); diff --git a/libs/oeth/shared/src/components/ActivityProvider/types.ts b/libs/oeth/shared/src/components/ActivityProvider/types.ts new file mode 100644 index 000000000..171386145 --- /dev/null +++ b/libs/oeth/shared/src/components/ActivityProvider/types.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Token } from '@origin/shared/contracts'; +import type { TransactionReceipt } from 'viem'; + +export type ActivityType = 'swap' | 'approval' | 'redeem'; + +export type ActivityStatus = 'pending' | 'success' | 'error'; + +export type GlobalActivityStatus = 'idle' | ActivityStatus; + +export type Activity = { + id: string; + createdOn: number; + tokenIn: Token; + tokenOut: Token; + amountIn?: bigint; + amountOut?: bigint; + txReceipt?: TransactionReceipt; + type: ActivityType; + status: ActivityStatus; + error?: string; +}; diff --git a/libs/oeth/shared/src/components/ApyHeader/index.tsx b/libs/oeth/shared/src/components/ApyHeader/index.tsx new file mode 100644 index 000000000..12bd788c9 --- /dev/null +++ b/libs/oeth/shared/src/components/ApyHeader/index.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; + +import { + Box, + Button, + Menu, + MenuItem, + Skeleton, + Stack, + Typography, +} from '@mui/material'; +import { defineMessage, useIntl } from 'react-intl'; + +import { useApiesQuery } from './queries.generated'; + +import type { StackProps } from '@mui/material'; + +const trailingOptions = [ + { + label: defineMessage({ defaultMessage: '30 days trailing APY' }), + value: 30, + }, + { label: defineMessage({ defaultMessage: '7 days trailing APY' }), value: 7 }, +]; + +export const ApyHeader = (props: StackProps) => { + const intl = useIntl(); + const [trailing, setTrailing] = useState(trailingOptions[0]); + const [anchorEl, setAnchorEl] = useState(null); + const { data: apy, isLoading: apyLoading } = useApiesQuery( + { + limit: 1, + }, + { + select: (data) => data.apies[0], + }, + ); + + return ( + + {apyLoading ? ( + + ) : ( + + {intl.formatNumber( + trailing.value === 30 ? apy.apy30DayAvg : apy.apy7DayAvg, + { minimumFractionDigits: 2, maximumFractionDigits: 2 }, + )} + % + + )} + + + { + setAnchorEl(null); + }} + MenuListProps={{ dense: true }} + > + {trailingOptions + .filter((t) => t.value !== trailing.value) + .map((t) => ( + { + setTrailing(t); + setAnchorEl(null); + }} + > + {intl.formatMessage(t.label)} + + ))} + + + ); +}; diff --git a/libs/oeth/shared/src/components/ApyHeader/queries.generated.ts b/libs/oeth/shared/src/components/ApyHeader/queries.generated.ts new file mode 100644 index 000000000..caaac857b --- /dev/null +++ b/libs/oeth/shared/src/components/ApyHeader/queries.generated.ts @@ -0,0 +1,43 @@ +import * as Types from '@origin/oeth/shared'; + +import { useQuery, UseQueryOptions } from '@tanstack/react-query'; +import { graphqlClient } from '@origin/oeth/shared'; +export type ApiesQueryVariables = Types.Exact<{ + limit?: Types.InputMaybe; +}>; + + +export type ApiesQuery = { __typename?: 'Query', apies: Array<{ __typename?: 'APY', id: string, timestamp: string, apy7DayAvg: number, apy30DayAvg: number }> }; + + +export const ApiesDocument = ` + query Apies($limit: Int) { + apies( + limit: $limit + orderBy: timestamp_DESC + where: {timestamp_gt: "2023-06-06T12:38:47.000000Z"} + ) { + id + timestamp + apy7DayAvg + apy30DayAvg + } +} + `; +export const useApiesQuery = < + TData = ApiesQuery, + TError = unknown + >( + variables?: ApiesQueryVariables, + options?: UseQueryOptions + ) => + useQuery( + variables === undefined ? ['Apies'] : ['Apies', variables], + graphqlClient(ApiesDocument, variables), + options + ); + +useApiesQuery.getKey = (variables?: ApiesQueryVariables) => variables === undefined ? ['Apies'] : ['Apies', variables]; +; + +useApiesQuery.fetcher = (variables?: ApiesQueryVariables, options?: RequestInit['headers']) => graphqlClient(ApiesDocument, variables, options); \ No newline at end of file diff --git a/libs/oeth/swap/src/queries.graphql b/libs/oeth/shared/src/components/ApyHeader/queries.graphql similarity index 100% rename from libs/oeth/swap/src/queries.graphql rename to libs/oeth/shared/src/components/ApyHeader/queries.graphql diff --git a/libs/oeth/shared/src/components/GasPopover.tsx b/libs/oeth/shared/src/components/GasPopover.tsx index 510e85e97..58d146408 100644 --- a/libs/oeth/shared/src/components/GasPopover.tsx +++ b/libs/oeth/shared/src/components/GasPopover.tsx @@ -7,8 +7,6 @@ import { FormControl, FormHelperText, IconButton, - InputAdornment, - InputBase, InputLabel, Popover, Stack, @@ -16,7 +14,6 @@ import { } from '@mui/material'; import { InfoTooltip, PercentInput } from '@origin/shared/components'; import { useIntl } from 'react-intl'; -import { useFeeData } from 'wagmi'; import type { IconButtonProps } from '@mui/material'; @@ -45,7 +42,6 @@ export function GasPopover({ const theme = useTheme(); const intl = useIntl(); const [anchorEl, setAnchorEl] = useState(null); - const { data: feeData } = useFeeData({ formatUnits: 'gwei' }); return ( <> @@ -87,7 +83,7 @@ export function GasPopover({ }, }} > - + - - - {intl.formatMessage({ defaultMessage: 'Gas Price' })} - - - theme.palette.secondary.main, - backgroundColor: (theme) => - alpha(theme.palette.secondary.main, 0.05), - paddingInlineEnd: 2, - '& .MuiInputBase-input': { - textAlign: 'right', - borderColor: (theme) => theme.palette.secondary.main, - '&::placeholder': { - color: 'text.primary', - opacity: 1, - }, - }, - }} - endAdornment={ - - {intl.formatMessage({ defaultMessage: 'GWEI' })} - - } - /> - - diff --git a/libs/oeth/shared/src/components/index.ts b/libs/oeth/shared/src/components/index.ts index 45b4fb9d0..5ba8e635d 100644 --- a/libs/oeth/shared/src/components/index.ts +++ b/libs/oeth/shared/src/components/index.ts @@ -1,2 +1,4 @@ +export * from './ActivityProvider'; +export * from './ApyHeader'; export * from './AccountPopover'; export * from './GasPopover'; diff --git a/libs/oeth/swap/src/actions/index.ts b/libs/oeth/swap/src/actions/index.ts index 4890e10a5..016b2d6a4 100644 --- a/libs/oeth/swap/src/actions/index.ts +++ b/libs/oeth/swap/src/actions/index.ts @@ -43,9 +43,11 @@ const defaultApi: SwapApi = { }, approve: async () => { console.log('Approve operation not implemented'); + return null; }, swap: async () => { console.log('Route swap operation not implemented'); + return null; }, }; diff --git a/libs/oeth/swap/src/actions/mintVault.ts b/libs/oeth/swap/src/actions/mintVault.ts index 331cae331..7b25cd10f 100644 --- a/libs/oeth/swap/src/actions/mintVault.ts +++ b/libs/oeth/swap/src/actions/mintVault.ts @@ -8,7 +8,6 @@ import { prepareWriteContract, readContract, readContracts, - waitForTransaction, writeContract, } from '@wagmi/core'; import { formatUnits, parseUnits } from 'viem'; @@ -84,39 +83,37 @@ const estimateGas: EstimateGas = async ({ return gasEstimate; } catch {} - try { - const [rebaseThreshold, autoAllocateThreshold] = - await queryClient.fetchQuery({ - queryKey: ['vault-info', tokenOut.address], - queryFn: () => - readContracts({ - contracts: [ - { - address: contracts.mainnet.OETHVaultCore.address, - abi: contracts.mainnet.OETHVaultCore.abi, - functionName: 'rebaseThreshold', - }, - { - address: contracts.mainnet.OETHVaultCore.address, - abi: contracts.mainnet.OETHVaultCore.abi, - functionName: 'autoAllocateThreshold', - }, - ], - }), - staleTime: Infinity, - }); - - // TODO check validity - gasEstimate = 220000n; - if (amountIn > autoAllocateThreshold?.result) { - gasEstimate = 2900000n; - } else if (amountIn > rebaseThreshold?.result) { - gasEstimate = 510000n; - } - } catch (e) { - console.error(`mint vault gas estimate error!\n${e.message}`); + const [rebaseThreshold, autoAllocateThreshold] = await queryClient.fetchQuery( + { + queryKey: ['vault-info', tokenOut.address], + queryFn: () => + readContracts({ + contracts: [ + { + address: contracts.mainnet.OETHVaultCore.address, + abi: contracts.mainnet.OETHVaultCore.abi, + functionName: 'rebaseThreshold', + }, + { + address: contracts.mainnet.OETHVaultCore.address, + abi: contracts.mainnet.OETHVaultCore.abi, + functionName: 'autoAllocateThreshold', + }, + ], + }), + staleTime: Infinity, + }, + ); + + gasEstimate = 220000n; + if (amountIn > autoAllocateThreshold?.result) { + gasEstimate = 2900000n; + } else if (amountIn > rebaseThreshold?.result) { + gasEstimate = 510000n; } + console.log(`Mint vault uses fix gas estimate: ${gasEstimate}`); + return gasEstimate; }; @@ -158,7 +155,9 @@ const estimateApprovalGas: EstimateApprovalGas = async ({ args: [contracts.mainnet.OETHVaultCore.address, amountIn], account: address, }); - } catch {} + } catch { + console.log(`Mint vault uses fix approval gas estimate: 0`); + } return approvalEstimate; }; @@ -206,35 +205,16 @@ const estimateRoute: EstimateRoute = async ({ }; }; -const approve: Approve = async ({ - tokenIn, - amountIn, - onSuccess, - onError, - onReject, -}) => { - try { - const { request } = await prepareWriteContract({ - address: tokenIn.address, - abi: erc20ABI, - functionName: 'approve', - args: [contracts.mainnet.OETHVaultCore.address, amountIn], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); - - console.log(`mint vault approval done!`); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`mint vault approval error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Mint vault approval'); - } else if (onError) { - await onError('Mint vault approval'); - } - } +const approve: Approve = async ({ tokenIn, amountIn }) => { + const { request } = await prepareWriteContract({ + address: tokenIn.address, + abi: erc20ABI, + functionName: 'approve', + args: [contracts.mainnet.OETHVaultCore.address, amountIn], + }); + const { hash } = await writeContract(request); + + return hash; }; const swap: Swap = async ({ @@ -243,24 +223,17 @@ const swap: Swap = async ({ amountIn, slippage, amountOut, - onSuccess, - onError, - onReject, }) => { const { address } = getAccount(); if (amountIn === 0n || isNilOrEmpty(address)) { - return; + return null; } const approved = await allowance({ tokenIn, tokenOut }); if (approved < amountIn) { - console.error(`mint vault is not approved`); - if (onError) { - await onError('Mint vault is not approved'); - } - return; + throw new Error(`Mint vault is not approved`); } const minAmountOut = parseUnits( @@ -271,28 +244,15 @@ const swap: Swap = async ({ tokenOut.decimals, ); - try { - const { request } = await prepareWriteContract({ - address: contracts.mainnet.OETHVaultCore.address, - abi: contracts.mainnet.OETHVaultCore.abi, - functionName: 'mint', - args: [tokenIn.address, amountIn, minAmountOut], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); - - console.log('mint vault done!'); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`mint vault error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Mint vault swap'); - } else if (onError) { - await onError('Mint vault swap'); - } - } + const { request } = await prepareWriteContract({ + address: contracts.mainnet.OETHVaultCore.address, + abi: contracts.mainnet.OETHVaultCore.abi, + functionName: 'mint', + args: [tokenIn.address, amountIn, minAmountOut], + }); + const { hash } = await writeContract(request); + + return hash; }; export default { diff --git a/libs/oeth/swap/src/actions/swapCurve/index.ts b/libs/oeth/swap/src/actions/swapCurve/index.ts index 680619264..256f53ba1 100644 --- a/libs/oeth/swap/src/actions/swapCurve/index.ts +++ b/libs/oeth/swap/src/actions/swapCurve/index.ts @@ -5,7 +5,6 @@ import { getPublicClient, prepareWriteContract, readContract, - waitForTransaction, writeContract, } from '@wagmi/core'; import { formatUnits, maxUint256, parseUnits } from 'viem'; @@ -35,7 +34,7 @@ const estimateAmount: EstimateAmount = async ({ const curveConfig = curveRoutes[tokenIn.symbol]?.[tokenOut.symbol]; if (isNilOrEmpty(curveConfig)) { - console.error( + throw new Error( `No curve route found, verify exchange mapping ${tokenIn.symbol} -> ${tokenOut.symbol}`, ); } @@ -78,11 +77,9 @@ const estimateGas: EstimateGas = async ({ const curveConfig = curveRoutes[tokenIn.symbol]?.[tokenOut.symbol]; if (isNilOrEmpty(curveConfig)) { - console.error( + throw new Error( `No curve route found, verify exchange mapping ${tokenIn.symbol} -> ${tokenOut.symbol}`, ); - - return gasEstimate; } try { @@ -100,9 +97,7 @@ const estimateGas: EstimateGas = async ({ ...(isNilOrEmpty(tokenIn.address) && { value: amountIn }), }); } catch (e) { - console.error( - `swap curve exchange multiple gas estimate error, returning fix estimate! \n${e.message}`, - ); + console.log(`Swap curve uses fix gas estimate: 350000`); gasEstimate = 350000n; } @@ -152,7 +147,9 @@ const estimateApprovalGas: EstimateApprovalGas = async ({ args: [curve.CurveRegistryExchange.address, amountIn], account: address, }); - } catch {} + } catch { + console.log(`Swap curve uses fix approval gas estimate: 0`); + } return approvalEstimate; }; @@ -207,36 +204,16 @@ const estimateRoute: EstimateRoute = async ({ }; }; -const approve: Approve = async ({ - tokenIn, - amountIn, - curve, - onSuccess, - onError, - onReject, -}) => { - try { - const { request } = await prepareWriteContract({ - address: tokenIn.address, - abi: erc20ABI, - functionName: 'approve', - args: [curve.CurveRegistryExchange.address, amountIn], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); +const approve: Approve = async ({ tokenIn, amountIn, curve }) => { + const { request } = await prepareWriteContract({ + address: tokenIn.address, + abi: erc20ABI, + functionName: 'approve', + args: [curve.CurveRegistryExchange.address, amountIn], + }); + const { hash } = await writeContract(request); - console.log(`swap curve exchange multiple approval done!`); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`swap curve exchange multiple approval error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Swap Curve approval'); - } else if (onError) { - await onError('Swap Curve approval'); - } - } + return hash; }; const swap: Swap = async ({ @@ -246,24 +223,17 @@ const swap: Swap = async ({ amountOut, slippage, curve, - onSuccess, - onError, - onReject, }) => { const { address } = getAccount(); if (amountIn === 0n || isNilOrEmpty(address)) { - return; + return null; } const approved = await allowance({ tokenIn, tokenOut, curve }); if (approved < amountIn) { - console.error(`swap curve exchange multiple is not approved`); - if (onError) { - await onError('swap curve exchange multiple is not approved'); - } - return; + throw new Error(`Swap curve is not approved`); } const minAmountOut = parseUnits( @@ -277,43 +247,21 @@ const swap: Swap = async ({ const curveConfig = curveRoutes[tokenIn.symbol]?.[tokenOut?.symbol]; if (isNilOrEmpty(curveConfig)) { - console.error( + throw new Error( `No curve route found, verify exchange mapping ${tokenIn.symbol} -> ${tokenOut.symbol}`, ); - if (onError) { - await onError('No curve route found'); - } - return; } - try { - const { request } = await prepareWriteContract({ - address: curve.CurveRegistryExchange.address, - abi: curve.CurveRegistryExchange.abi, - functionName: 'exchange_multiple', - args: [ - curveConfig.routes, - curveConfig.swapParams, - amountIn, - minAmountOut, - ], - ...(isNilOrEmpty(tokenIn.address) && { value: amountIn }), - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); + const { request } = await prepareWriteContract({ + address: curve.CurveRegistryExchange.address, + abi: curve.CurveRegistryExchange.abi, + functionName: 'exchange_multiple', + args: [curveConfig.routes, curveConfig.swapParams, amountIn, minAmountOut], + ...(isNilOrEmpty(tokenIn.address) && { value: amountIn }), + }); + const { hash } = await writeContract(request); - console.log('swap curve exchange multiple done!'); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`swap curve exchange multiple error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Swap Curve exchange multiple'); - } else if (onError) { - await onError('Swap Curve exchange multiple'); - } - } + return hash; }; export default { diff --git a/libs/oeth/swap/src/actions/swapCurveEth.ts b/libs/oeth/swap/src/actions/swapCurveEth.ts index c38e8fe64..b69776272 100644 --- a/libs/oeth/swap/src/actions/swapCurveEth.ts +++ b/libs/oeth/swap/src/actions/swapCurveEth.ts @@ -5,7 +5,6 @@ import { getPublicClient, prepareWriteContract, readContract, - waitForTransaction, writeContract, } from '@wagmi/core'; import { formatUnits, isAddressEqual, maxUint256, parseUnits } from 'viem'; @@ -93,9 +92,7 @@ const estimateGas: EstimateGas = async ({ account: address ?? ETH_ADDRESS_CURVE, }); } catch (e) { - console.error( - `swap curve OETHPool gas estimate error, returning fix estimate!\n${e.message}`, - ); + console.log(`Swap curve OETH Pool uses fix gas estimate: 180000`); gasEstimate = 180000n; } @@ -110,6 +107,7 @@ const allowance: Allowance = async () => { const estimateApprovalGas: EstimateApprovalGas = async () => { // ETH doesn't need approval + console.log(`Swap curve OETH Pool uses fix approval gas estimate: 0`); return 0n; }; @@ -163,11 +161,9 @@ const estimateRoute: EstimateRoute = async ({ }; }; -const approve: Approve = async ({ onSuccess }) => { +const approve: Approve = async () => { // ETH doesn't need approval - if (onSuccess) { - await onSuccess(null); - } + return null; }; const swap: Swap = async ({ @@ -177,12 +173,9 @@ const swap: Swap = async ({ amountOut, slippage, curve, - onSuccess, - onError, - onReject, }) => { if (amountIn === 0n) { - return; + return null; } const minAmountOut = parseUnits( @@ -193,42 +186,29 @@ const swap: Swap = async ({ tokenOut.decimals, ); - try { - const { request } = await prepareWriteContract({ - address: contracts.mainnet.curveOethPool.address, - abi: contracts.mainnet.curveOethPool.abi, - functionName: 'exchange', - args: [ - BigInt( - curve.OethPoolUnderlyings.findIndex((t) => - isAddressEqual(t, tokenIn.address ?? ETH_ADDRESS_CURVE), - ), + const { request } = await prepareWriteContract({ + address: contracts.mainnet.curveOethPool.address, + abi: contracts.mainnet.curveOethPool.abi, + functionName: 'exchange', + args: [ + BigInt( + curve.OethPoolUnderlyings.findIndex((t) => + isAddressEqual(t, tokenIn.address ?? ETH_ADDRESS_CURVE), ), - BigInt( - curve.OethPoolUnderlyings.findIndex((t) => - isAddressEqual(t, tokenOut.address ?? ETH_ADDRESS_CURVE), - ), + ), + BigInt( + curve.OethPoolUnderlyings.findIndex((t) => + isAddressEqual(t, tokenOut.address ?? ETH_ADDRESS_CURVE), ), - amountIn, - minAmountOut, - ], - ...(isNilOrEmpty(tokenIn.address) && { value: amountIn }), - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); + ), + amountIn, + minAmountOut, + ], + ...(isNilOrEmpty(tokenIn.address) && { value: amountIn }), + }); + const { hash } = await writeContract(request); - console.log('swap curve OETHPool done!'); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`swap curve OETHPool error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Swap Curve exchange'); - } else if (onError) { - await onError('Swap Curve exchange'); - } - } + return hash; }; export default { diff --git a/libs/oeth/swap/src/actions/swapZapperEth.ts b/libs/oeth/swap/src/actions/swapZapperEth.ts index d3799be32..b2499059d 100644 --- a/libs/oeth/swap/src/actions/swapZapperEth.ts +++ b/libs/oeth/swap/src/actions/swapZapperEth.ts @@ -6,7 +6,6 @@ import { getPublicClient, prepareWriteContract, readContract, - waitForTransaction, writeContract, } from '@wagmi/core'; import { formatUnits, maxUint256 } from 'viem'; @@ -26,7 +25,7 @@ const estimateAmount: EstimateAmount = async ({ amountIn }) => { }; const estimateGas: EstimateGas = async ({ amountIn }) => { - let gasEstimate = 200000n; + let gasEstimate = 0n; const { address } = getAccount(); @@ -44,7 +43,10 @@ const estimateGas: EstimateGas = async ({ amountIn }) => { value: amountIn, account: address, }); - } catch {} + } catch { + console.log(`Swap zapper uses fix gas estimate: 200000`); + gasEstimate = 200000n; + } return gasEstimate; }; @@ -97,7 +99,9 @@ const estimateApprovalGas: EstimateApprovalGas = async ({ args: [contracts.mainnet.OETHZapper.address, amountIn], account: address, }); - } catch {} + } catch { + console.log(`Swap zapper uses fix approval gas estimate: 0`); + } return approvalEstimate; }; @@ -140,92 +144,44 @@ const estimateRoute: EstimateRoute = async ({ }; }; -const approve: Approve = async ({ - tokenIn, - tokenOut, - amountIn, - onSuccess, - onError, - onReject, -}) => { - if ( - (isNilOrEmpty(tokenIn.address) || isNilOrEmpty(tokenOut.address)) && - onSuccess - ) { - console.log(`swap eth does not require approval!`); - onSuccess(null); +const approve: Approve = async ({ tokenIn, tokenOut, amountIn }) => { + if (isNilOrEmpty(tokenIn.address) || isNilOrEmpty(tokenOut.address)) { + return null; } - try { - const { request } = await prepareWriteContract({ - address: tokenIn.address, - abi: erc20ABI, - functionName: 'approve', - args: [contracts.mainnet.OETHZapper.address, amountIn], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); - - console.log(`swap zapper eth approval done!`); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`swap zapper eth approval error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Swap Zapper ETH approval'); - } else if (onError) { - await onError('Swap Zapper ETH approval'); - } - } + const { request } = await prepareWriteContract({ + address: tokenIn.address, + abi: erc20ABI, + functionName: 'approve', + args: [contracts.mainnet.OETHZapper.address, amountIn], + }); + const { hash } = await writeContract(request); + + return hash; }; -const swap: Swap = async ({ - tokenIn, - tokenOut, - amountIn, - onSuccess, - onError, - onReject, -}) => { +const swap: Swap = async ({ tokenIn, tokenOut, amountIn }) => { const { address } = getAccount(); if (amountIn === 0n || isNilOrEmpty(address)) { - return; + return null; } const approved = await allowance({ tokenIn, tokenOut }); if (approved < amountIn) { - console.error(`swap zapper eth is not approved`); - if (onError) { - await onError('Swap Zapper Eth is not approved'); - } - return; + throw new Error(`Swap zapper is not approved`); } - try { - const { request } = await prepareWriteContract({ - address: contracts.mainnet.OETHZapper.address, - abi: contracts.mainnet.OETHZapper.abi, - functionName: 'deposit', - value: amountIn, - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); - - console.log('swap zapper eth done!'); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`swap zapper eth error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Swap Zapper Eth'); - } else if (onError) { - await onError('Swap Zapper Eth'); - } - } + const { request } = await prepareWriteContract({ + address: contracts.mainnet.OETHZapper.address, + abi: contracts.mainnet.OETHZapper.abi, + functionName: 'deposit', + value: amountIn, + }); + const { hash } = await writeContract(request); + + return hash; }; export default { diff --git a/libs/oeth/swap/src/actions/swapZapperSfrxeth.ts b/libs/oeth/swap/src/actions/swapZapperSfrxeth.ts index 5d855e988..225b7de42 100644 --- a/libs/oeth/swap/src/actions/swapZapperSfrxeth.ts +++ b/libs/oeth/swap/src/actions/swapZapperSfrxeth.ts @@ -7,7 +7,6 @@ import { prepareWriteContract, readContract, readContracts, - waitForTransaction, writeContract, } from '@wagmi/core'; import { formatUnits, maxUint256, parseUnits } from 'viem'; @@ -54,6 +53,7 @@ const estimateAmount: EstimateAmount = async ({ tokenOut, amountIn }) => { }; const estimateGas: EstimateGas = async () => { + console.log(`Swap zapper sfrxETH uses fix gas estimate: 90000`); return 90000n; }; @@ -106,6 +106,7 @@ const estimateApprovalGas: EstimateApprovalGas = async ({ account: address, }); } catch { + console.log(`Swap zapper sfrxETH uses fix approval gas estimate: 64000`); approvalEstimate = 64000n; } @@ -150,44 +151,20 @@ const estimateRoute: EstimateRoute = async ({ }; }; -const approve: Approve = async ({ - tokenIn, - tokenOut, - amountIn, - onSuccess, - onError, - onReject, -}) => { - if ( - (isNilOrEmpty(tokenIn.address) || isNilOrEmpty(tokenOut.address)) && - onSuccess - ) { - console.log(`swap zapper does not require approval!`); - onSuccess(null); +const approve: Approve = async ({ tokenIn, tokenOut, amountIn }) => { + if (isNilOrEmpty(tokenIn.address) || isNilOrEmpty(tokenOut.address)) { + return null; } - try { - const { request } = await prepareWriteContract({ - address: tokenIn.address, - abi: erc20ABI, - functionName: 'approve', - args: [contracts.mainnet.OETHZapper.address, amountIn], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); + const { request } = await prepareWriteContract({ + address: tokenIn.address, + abi: erc20ABI, + functionName: 'approve', + args: [contracts.mainnet.OETHZapper.address, amountIn], + }); + const { hash } = await writeContract(request); - console.log(`swap zapper approval done!`); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`swap zapper approval error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Swap Zapper approval'); - } else if (onError) { - await onError('Swap Zapper approval'); - } - } + return hash; }; const swap: Swap = async ({ @@ -196,9 +173,6 @@ const swap: Swap = async ({ amountIn, slippage, amountOut, - onSuccess, - onError, - onReject, }) => { const { address } = getAccount(); @@ -209,11 +183,7 @@ const swap: Swap = async ({ const approved = await allowance({ tokenIn, tokenOut }); if (approved < amountIn) { - console.error(`swap zapper is not approved`); - if (onError) { - await onError('Swap Zapper is not approved'); - } - return; + throw new Error(`Swap zapper sfrxETH is not approved`); } const minAmountOut = parseUnits( @@ -224,28 +194,15 @@ const swap: Swap = async ({ tokenOut.decimals, ); - try { - const { request } = await prepareWriteContract({ - address: contracts.mainnet.OETHZapper.address, - abi: contracts.mainnet.OETHZapper.abi, - functionName: 'depositSFRXETH', - args: [amountIn, minAmountOut], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); + const { request } = await prepareWriteContract({ + address: contracts.mainnet.OETHZapper.address, + abi: contracts.mainnet.OETHZapper.abi, + functionName: 'depositSFRXETH', + args: [amountIn, minAmountOut], + }); + const { hash } = await writeContract(request); - console.log('swap zapper sfrxEth done!'); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`swap zapper sfrxEth error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Swap Zapper'); - } else if (onError) { - await onError('Swap Zapper'); - } - } + return hash; }; export default { diff --git a/libs/oeth/swap/src/actions/unwrapWOETH.ts b/libs/oeth/swap/src/actions/unwrapWOETH.ts index 13bfdea9c..863efa020 100644 --- a/libs/oeth/swap/src/actions/unwrapWOETH.ts +++ b/libs/oeth/swap/src/actions/unwrapWOETH.ts @@ -5,7 +5,6 @@ import { getPublicClient, prepareWriteContract, readContract, - waitForTransaction, writeContract, } from '@wagmi/core'; import { formatUnits, maxUint256 } from 'viem'; @@ -68,7 +67,9 @@ const estimateGas: EstimateGas = async ({ amountIn }) => { args: [amountIn, whales.mainnet.WOETH, whales.mainnet.WOETH], account: whales.mainnet.WOETH, }); - } catch {} + } catch { + console.log(`Unwrap WOETH uses fix gas estimate: 0`); + } return gasEstimate; }; @@ -80,6 +81,7 @@ const allowance: Allowance = async () => { const estimateApprovalGas: EstimateApprovalGas = async () => { // Unwrap WOETH does not require approval + console.log(`Unwrap WOETH uses fix gas estimate: 0`); return 0n; }; @@ -121,11 +123,9 @@ const estimateRoute: EstimateRoute = async ({ }; }; -const approve: Approve = async ({ onSuccess }) => { +const approve: Approve = async () => { // Unwrap WOETH does not require approval - if (onSuccess) { - await onSuccess(null); - } + return null; }; const swap: Swap = async ({ amountIn }) => { @@ -135,21 +135,15 @@ const swap: Swap = async ({ amountIn }) => { return; } - try { - const { request } = await prepareWriteContract({ - address: contracts.mainnet.WOETH.address, - abi: contracts.mainnet.WOETH.abi, - functionName: 'redeem', - args: [amountIn, address, address], - }); - const { hash } = await writeContract(request); - await waitForTransaction({ hash }); - // TODO trigger notification - console.log('unwrap woeth done!'); - } catch (e) { - // TODO trigger notification - console.log(`unwrap woeth error!\n${e.message}`); - } + const { request } = await prepareWriteContract({ + address: contracts.mainnet.WOETH.address, + abi: contracts.mainnet.WOETH.abi, + functionName: 'redeem', + args: [amountIn, address, address], + }); + const { hash } = await writeContract(request); + + return hash; }; export default { diff --git a/libs/oeth/swap/src/actions/wrapOETH.ts b/libs/oeth/swap/src/actions/wrapOETH.ts index 075f0e696..869e97e46 100644 --- a/libs/oeth/swap/src/actions/wrapOETH.ts +++ b/libs/oeth/swap/src/actions/wrapOETH.ts @@ -6,7 +6,6 @@ import { getPublicClient, prepareWriteContract, readContract, - waitForTransaction, writeContract, } from '@wagmi/core'; import { formatUnits } from 'viem'; @@ -69,7 +68,9 @@ const estimateGas: EstimateGas = async ({ amountIn }) => { args: [amountIn, whales.mainnet.OETH], account: whales.mainnet.OETH, }); - } catch {} + } catch { + console.log(`Wrap OETH uses fix gas estimate: 0`); + } return gasEstimate; }; @@ -112,7 +113,9 @@ const estimateApprovalGas: EstimateApprovalGas = async ({ args: [contracts.mainnet.WOETH.address, amountIn], account: address, }); - } catch {} + } catch { + console.log(`Wrap OETH uses fix approval gas estimate: 0`); + } return approvalEstimate; }; @@ -155,83 +158,40 @@ const estimateRoute: EstimateRoute = async ({ }; }; -const approve: Approve = async ({ - tokenIn, - amountIn, - onSuccess, - onError, - onReject, -}) => { - try { - const { request } = await prepareWriteContract({ - address: tokenIn.address, - abi: erc20ABI, - functionName: 'approve', - args: [contracts.mainnet.WOETH.address, amountIn], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); - - console.log(`wrap oeth approval done!`); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`wrap oeth approval error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Wrap OETH approval'); - } else if (onError) { - await onError('Wrap OETH approval'); - } - } +const approve: Approve = async ({ tokenIn, amountIn }) => { + const { request } = await prepareWriteContract({ + address: tokenIn.address, + abi: erc20ABI, + functionName: 'approve', + args: [contracts.mainnet.WOETH.address, amountIn], + }); + const { hash } = await writeContract(request); + + return hash; }; -const swap: Swap = async ({ - tokenIn, - tokenOut, - amountIn, - onSuccess, - onError, - onReject, -}) => { +const swap: Swap = async ({ tokenIn, tokenOut, amountIn }) => { const { address } = getAccount(); if (amountIn === 0n || isNilOrEmpty(address)) { - return; + return null; } const approved = await allowance({ tokenIn, tokenOut }); if (approved < amountIn) { - console.error(`wrap oeth is not approved`); - if (onError) { - await onError('Wrap OETH is not approved'); - } - return; + throw new Error(`Wrap OETH is not approved`); } - try { - const { request } = await prepareWriteContract({ - address: contracts.mainnet.WOETH.address, - abi: contracts.mainnet.WOETH.abi, - functionName: 'deposit', - args: [amountIn, address], - }); - const { hash } = await writeContract(request); - const txReceipt = await waitForTransaction({ hash }); - - console.log('wrap oeth done!'); - if (onSuccess) { - await onSuccess(txReceipt); - } - } catch (e) { - console.error(`wrap oeth error!\n${e.message}`); - if (e?.code === 'ACTION_REJECTED' && onReject) { - await onReject('Wrap OETH'); - } else if (onError) { - await onError('Wrap OETH'); - } - } + const { request } = await prepareWriteContract({ + address: contracts.mainnet.WOETH.address, + abi: contracts.mainnet.WOETH.abi, + functionName: 'deposit', + args: [amountIn, address], + }); + const { hash } = await writeContract(request); + + return hash; }; export default { diff --git a/libs/oeth/swap/src/components/ApyHeader.tsx b/libs/oeth/swap/src/components/ApyHeader.tsx deleted file mode 100644 index d2bffbfcc..000000000 --- a/libs/oeth/swap/src/components/ApyHeader.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useState } from 'react'; - -import { - Box, - Button, - Menu, - MenuItem, - Paper, - Skeleton, - Stack, - Typography, -} from '@mui/material'; -import { defineMessage, useIntl } from 'react-intl'; - -import { useApiesQuery } from '../queries.generated'; - -import type { StackProps } from '@mui/material'; - -const trailingOptions = [ - { label: defineMessage({ defaultMessage: '30 days trailing' }), value: 30 }, - { label: defineMessage({ defaultMessage: '7 days trailing' }), value: 7 }, -]; - -export const ApyHeader = (props: StackProps) => { - const intl = useIntl(); - const [trailing, setTrailing] = useState(trailingOptions[0]); - const [anchorEl, setAnchorEl] = useState(null); - const { data: apy, isLoading: apyLoading } = useApiesQuery( - { - limit: 1, - }, - { - select: (data) => data.apies[0], - }, - ); - - return ( - - - {apyLoading ? ( - - ) : ( - - {intl.formatNumber( - trailing.value === 30 ? apy.apy30DayAvg : apy.apy7DayAvg, - { minimumFractionDigits: 2, maximumFractionDigits: 2 }, - )} - % - - )} - - - { - setAnchorEl(null); - }} - MenuListProps={{ dense: true }} - > - {trailingOptions - .filter((t) => t.value !== trailing.value) - .map((t) => ( - { - setTrailing(t); - setAnchorEl(null); - }} - > - {intl.formatMessage(t.label)} - - ))} - - - - ); -}; diff --git a/libs/oeth/swap/src/components/BestRoutes.tsx b/libs/oeth/swap/src/components/BestRoutes.tsx index 8de1bc56e..c449b6ed2 100644 --- a/libs/oeth/swap/src/components/BestRoutes.tsx +++ b/libs/oeth/swap/src/components/BestRoutes.tsx @@ -10,8 +10,7 @@ import type { Grid2Props } from '@mui/material'; export type BestRoutesProps = { isLoading: boolean } & Grid2Props; export function BestRoutes(props: Grid2Props) { - const [{ swapRoutes, selectedSwapRoute, isSwapRoutesLoading }] = - useSwapState(); + const [{ swapRoutes, selectedSwapRoute }] = useSwapState(); const handleSelectSwapRoute = useHandleSelectSwapRoute(); return ( @@ -24,7 +23,6 @@ export function BestRoutes(props: Grid2Props) { isBest={index === 0} onSelect={handleSelectSwapRoute} route={route} - isLoading={isSwapRoutesLoading} /> ))} diff --git a/libs/oeth/swap/src/components/SwapRouteCard.tsx b/libs/oeth/swap/src/components/SwapRouteCard.tsx index 2e0bf7f83..f84d8764e 100644 --- a/libs/oeth/swap/src/components/SwapRouteCard.tsx +++ b/libs/oeth/swap/src/components/SwapRouteCard.tsx @@ -21,7 +21,6 @@ import type { EstimatedSwapRoute } from '../types'; export type SwapRouteCardProps = { isSelected: boolean; isBest: boolean; - isLoading: boolean; onSelect: (route: EstimatedSwapRoute) => void; route: EstimatedSwapRoute; } & Omit; @@ -29,19 +28,29 @@ export type SwapRouteCardProps = { export function SwapRouteCard({ isSelected, isBest, - isLoading, onSelect, route, ...rest }: SwapRouteCardProps) { const intl = useIntl(); - const [{ amountIn }] = useSwapState(); + const [{ amountIn, isSwapRoutesLoading }] = useSwapState(); const { data: prices } = usePrices(); - const { data: swapGasPrice, isLoading: swapGasPriceLoading } = useGasPrice( - route.gas, - ); - const { data: approvalGasPrice, isLoading: approvalGasPriceLoading } = - useGasPrice(route.approvalGas, { refetchInterval: 30e3 }); + const { + data: swapGasPrice, + isLoading: swapGasPriceLoading, + isFetching: swapGasPriceFetching, + } = useGasPrice(route.gas, { + refetchInterval: 30e3, + enabled: route.gas > 0n, + }); + const { + data: approvalGasPrice, + isLoading: approvalGasPriceLoading, + isFetching: approvalGasPriceFetching, + } = useGasPrice(route.approvalGas, { + refetchInterval: 30e3, + enabled: route.approvalGas > 0n, + }); const { data: allowance } = useSwapRouteAllowance(route); const estimatedAmount = +formatUnits( @@ -51,7 +60,9 @@ export function SwapRouteCard({ const convertedAmount = (prices?.[route.tokenOut.symbol] ?? 1) * estimatedAmount; const isGasLoading = - isLoading || swapGasPriceLoading || approvalGasPriceLoading; + isSwapRoutesLoading || + (swapGasPriceLoading && swapGasPriceFetching) || + (approvalGasPriceLoading && approvalGasPriceFetching); const gasPrice = swapGasPrice?.gasCostUsd + (allowance < amountIn ? approvalGasPrice?.gasCostUsd : 0); @@ -114,7 +125,7 @@ export function SwapRouteCard({ - {isLoading ? ( + {isSwapRoutesLoading ? ( ) : ( - {isLoading ? ( + {isSwapRoutesLoading ? ( ) : ( formatAmount(route.estimatedAmount, route.tokenOut.decimals) @@ -139,7 +150,7 @@ export function SwapRouteCard({ - {isLoading ? ( + {isSwapRoutesLoading ? ( ) : ( `(${intl.formatNumber(convertedAmount, currencyFormat)})` @@ -152,7 +163,7 @@ export function SwapRouteCard({ fontWeight={500} sx={{ fontSize: 12, marginBlock: { xs: 1.5, md: 1 } }} > - {isLoading ? ( + {isSwapRoutesLoading ? ( ) : ( intl.formatMessage(routeActionLabel[route.action]) @@ -169,7 +180,7 @@ export function SwapRouteCard({ {intl.formatMessage({ defaultMessage: 'Rate:' })} - {isLoading ? ( + {isSwapRoutesLoading ? ( ) : ( `1:${intl.formatNumber(route.rate, quantityFormat)}` diff --git a/libs/oeth/swap/src/hooks.ts b/libs/oeth/swap/src/hooks.tsx similarity index 59% rename from libs/oeth/swap/src/hooks.ts rename to libs/oeth/swap/src/hooks.tsx index dd95fbc14..5b7770817 100644 --- a/libs/oeth/swap/src/hooks.ts +++ b/libs/oeth/swap/src/hooks.tsx @@ -1,12 +1,22 @@ import { useCallback, useMemo } from 'react'; +import { Box } from '@mui/material'; +import { + ApprovalNotification, + SwapNotification, + useDeleteActivity, + usePushActivity, + useUpdateActivity, +} from '@origin/oeth/shared'; +import { NotificationSnack } from '@origin/shared/components'; import { useCurve, usePushNotification, useSlippage, } from '@origin/shared/providers'; -import { isNilOrEmpty } from '@origin/shared/utils'; +import { isNilOrEmpty, isUserRejected } from '@origin/shared/utils'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { waitForTransaction } from '@wagmi/core'; import { produce } from 'immer'; import { useIntl } from 'react-intl'; import { useAccount, useQueryClient as useWagmiClient } from 'wagmi'; @@ -161,12 +171,18 @@ export const useSwapRouteAllowance = (route: SwapRoute) => { route?.tokenOut.symbol, route?.action, ], - queryFn: () => - swapActions[route.action].allowance({ - tokenIn: route.tokenIn, - tokenOut: route.tokenOut, - curve, - }), + queryFn: async () => { + let res = 0n; + try { + res = await swapActions[route.action].allowance({ + tokenIn: route.tokenIn, + tokenOut: route.tokenOut, + curve, + }); + } catch {} + + return res; + }, enabled: !isNilOrEmpty(route), placeholderData: 0n, }); @@ -179,8 +195,13 @@ export const useHandleApprove = () => { const queryClient = useQueryClient(); const wagmiClient = useWagmiClient(); const pushNotification = usePushNotification(); - const [{ amountIn, selectedSwapRoute, tokenIn, tokenOut }, setSwapState] = - useSwapState(); + const pushActivity = usePushActivity(); + const updateActivity = useUpdateActivity(); + const deleteActivity = useDeleteActivity(); + const [ + { amountIn, amountOut, selectedSwapRoute, tokenIn, tokenOut }, + setSwapState, + ] = useSwapState(); return useCallback(async () => { if (isNilOrEmpty(selectedSwapRoute) || isNilOrEmpty(address)) { @@ -192,62 +213,98 @@ export const useHandleApprove = () => { draft.isApprovalLoading = true; }), ); - await swapActions[selectedSwapRoute.action].approve({ + const activity = pushActivity({ tokenIn, tokenOut, + type: 'approval', + status: 'pending', amountIn, - curve, - onSuccess: () => { + amountOut, + }); + try { + const hash = await swapActions[selectedSwapRoute.action].approve({ + tokenIn, + tokenOut, + amountIn, + curve, + }); + setSwapState( + produce((draft) => { + draft.isApprovalLoading = false; + }), + ); + if (!isNilOrEmpty(hash)) { + const txReceipt = await waitForTransaction({ hash }); wagmiClient.invalidateQueries({ queryKey: ['swap_balance'], }); queryClient.invalidateQueries({ queryKey: ['swap_allowance'], }); + updateActivity({ ...activity, status: 'success', txReceipt }); pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Approval complete' }), - severity: 'success', + content: ( + + ), }); - setSwapState( - produce((draft) => { - draft.isApprovalLoading = false; - }), - ); - }, - onError: () => { + } + } catch (error) { + setSwapState( + produce((draft) => { + draft.isApprovalLoading = false; + }), + ); + if (isUserRejected(error)) { + deleteActivity(activity.id); pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Approval failed' }), - severity: 'error', + content: ( + } + title={intl.formatMessage({ + defaultMessage: 'Operation Cancelled', + })} + subtitle={intl.formatMessage({ + defaultMessage: 'User rejected operation', + })} + /> + ), + }); + } else { + updateActivity({ + ...activity, + status: 'error', + error: error?.shortMessage ?? error.message, }); - setSwapState( - produce((draft) => { - draft.isApprovalLoading = false; - }), - ); - }, - onReject: () => { pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Approval cancelled' }), - severity: 'info', + content: ( + + ), }); - setSwapState( - produce((draft) => { - draft.isApprovalLoading = false; - }), - ); - }, - }); + } + } }, [ address, amountIn, + amountOut, curve, + deleteActivity, intl, + pushActivity, pushNotification, queryClient, selectedSwapRoute, setSwapState, tokenIn, tokenOut, + updateActivity, wagmiClient, ]); }; @@ -260,6 +317,9 @@ export const useHandleSwap = () => { const queryClient = useQueryClient(); const wagmiClient = useWagmiClient(); const pushNotification = usePushNotification(); + const pushActivity = usePushActivity(); + const updateActivity = useUpdateActivity(); + const deleteActivity = useDeleteActivity(); const [ { amountIn, amountOut, selectedSwapRoute, tokenIn, tokenOut }, setSwapState, @@ -270,20 +330,36 @@ export const useHandleSwap = () => { return; } + const activity = pushActivity({ + tokenIn, + tokenOut, + type: 'swap', + status: 'pending', + amountIn, + amountOut, + }); setSwapState( produce((draft) => { draft.isSwapLoading = true; }), ); - await swapActions[selectedSwapRoute.action].swap({ - tokenIn, - tokenOut, - amountIn, - estimatedRoute: selectedSwapRoute, - slippage, - amountOut, - curve, - onSuccess: () => { + try { + const hash = await swapActions[selectedSwapRoute.action].swap({ + tokenIn, + tokenOut, + amountIn, + estimatedRoute: selectedSwapRoute, + slippage, + amountOut, + curve, + }); + setSwapState( + produce((draft) => { + draft.isSwapLoading = false; + }), + ); + if (!isNilOrEmpty(hash)) { + const txReceipt = await waitForTransaction({ hash }); wagmiClient.invalidateQueries({ queryKey: ['swap_balance'], }); @@ -291,34 +367,62 @@ export const useHandleSwap = () => { queryKey: ['swap_allowance'], }); pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Swap complete' }), - severity: 'success', + content: ( + + ), }); - }, - onError: () => { + updateActivity({ ...activity, status: 'success', txReceipt }); + } + } catch (error) { + setSwapState( + produce((draft) => { + draft.isSwapLoading = false; + }), + ); + if (isUserRejected(error)) { + deleteActivity(activity.id); pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Swap failed' }), - severity: 'error', + content: ( + } + title={intl.formatMessage({ + defaultMessage: 'Operation Cancelled', + })} + subtitle={intl.formatMessage({ + defaultMessage: 'User rejected operation', + })} + /> + ), + }); + } else { + updateActivity({ + ...activity, + status: 'error', + error: error.shortMessage, }); - }, - onReject: () => { pushNotification({ - title: intl.formatMessage({ defaultMessage: 'Swap cancelled' }), - severity: 'info', + content: ( + + ), }); - }, - }); - setSwapState( - produce((draft) => { - draft.isSwapLoading = false; - }), - ); + } + } }, [ address, amountIn, amountOut, curve, + deleteActivity, intl, + pushActivity, pushNotification, queryClient, selectedSwapRoute, @@ -326,6 +430,7 @@ export const useHandleSwap = () => { slippage, tokenIn, tokenOut, + updateActivity, wagmiClient, ]); }; diff --git a/libs/oeth/swap/src/queries.generated.ts b/libs/oeth/swap/src/queries.generated.ts deleted file mode 100644 index 148c79d8f..000000000 --- a/libs/oeth/swap/src/queries.generated.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { graphqlClient } from '@origin/oeth/shared'; -import { useQuery } from '@tanstack/react-query'; - -import type * as Types from '@origin/oeth/shared'; -import type { UseQueryOptions } from '@tanstack/react-query'; -export type ApiesQueryVariables = Types.Exact<{ - limit?: Types.InputMaybe; -}>; - -export type ApiesQuery = { - __typename?: 'Query'; - apies: Array<{ - __typename?: 'APY'; - id: string; - timestamp: string; - apy7DayAvg: number; - apy30DayAvg: number; - }>; -}; - -export const ApiesDocument = ` - query Apies($limit: Int) { - apies( - limit: $limit - orderBy: timestamp_DESC - where: {timestamp_gt: "2023-06-06T12:38:47.000000Z"} - ) { - id - timestamp - apy7DayAvg - apy30DayAvg - } -} - `; -export const useApiesQuery = ( - variables?: ApiesQueryVariables, - options?: UseQueryOptions, -) => - useQuery( - variables === undefined ? ['Apies'] : ['Apies', variables], - graphqlClient(ApiesDocument, variables), - options, - ); - -useApiesQuery.getKey = (variables?: ApiesQueryVariables) => - variables === undefined ? ['Apies'] : ['Apies', variables]; -useApiesQuery.fetcher = ( - variables?: ApiesQueryVariables, - options?: RequestInit['headers'], -) => - graphqlClient( - ApiesDocument, - variables, - options, - ); diff --git a/libs/oeth/swap/src/state.ts b/libs/oeth/swap/src/state.ts index 0e60a175f..dfd1b3893 100644 --- a/libs/oeth/swap/src/state.ts +++ b/libs/oeth/swap/src/state.ts @@ -10,7 +10,7 @@ import { createContainer } from 'react-tracked'; import { swapActions } from './actions'; import { getAvailableRoutes } from './utils'; -import type { SwapState } from './types'; +import type { EstimatedSwapRoute, SwapState } from './types'; export const { Provider: SwapProvider, useTracked: useSwapState } = createContainer(() => { @@ -58,19 +58,39 @@ export const { Provider: SwapProvider, useTracked: useSwapState } = slippage, state.amountIn.toString(), ] as const, - queryFn: async () => - swapActions[route.action].estimateRoute({ - tokenIn: route.tokenIn, - tokenOut: route.tokenOut, - amountIn: state.amountIn, - amountOut: state.amountOut, - route, - slippage, - curve: { - CurveRegistryExchange, - OethPoolUnderlyings, - }, - }), + queryFn: async () => { + let res: EstimatedSwapRoute; + try { + res = await swapActions[route.action].estimateRoute({ + tokenIn: route.tokenIn, + tokenOut: route.tokenOut, + amountIn: state.amountIn, + amountOut: state.amountOut, + route, + slippage, + curve: { + CurveRegistryExchange, + OethPoolUnderlyings, + }, + }); + } catch (error) { + console.error( + `Fail to estimate route ${route.action}\n${error.message}`, + ); + res = { + tokenIn: route.tokenIn, + tokenOut: route.tokenOut, + estimatedAmount: 0n, + action: route.action, + allowanceAmount: 0n, + approvalGas: 0n, + gas: 0n, + rate: 0, + }; + } + + return res; + }, }), ), ); diff --git a/libs/oeth/swap/src/types.ts b/libs/oeth/swap/src/types.ts index 4cf5e9db0..4a9f6076b 100644 --- a/libs/oeth/swap/src/types.ts +++ b/libs/oeth/swap/src/types.ts @@ -1,6 +1,5 @@ import type { Contract, Token } from '@origin/shared/contracts'; import type { HexAddress } from '@origin/shared/utils'; -import type { TransactionReceipt } from 'viem'; export type TokenSource = 'tokenIn' | 'tokenOut'; @@ -25,9 +24,6 @@ type Args = { CurveRegistryExchange: Contract; OethPoolUnderlyings: HexAddress[]; }; - onSuccess?: (txReceipt: TransactionReceipt) => void | Promise; - onError?: (msg: string) => void | Promise; - onReject?: (msg: string) => void | Promise; }; export type EstimateAmount = ( @@ -63,17 +59,8 @@ export type EstimateApprovalGas = ( ) => Promise; export type Approve = ( - args: Pick< - Args, - | 'tokenIn' - | 'tokenOut' - | 'amountIn' - | 'curve' - | 'onSuccess' - | 'onError' - | 'onReject' - >, -) => Promise; + args: Pick, +) => Promise; export type Swap = ( args: Pick< @@ -85,11 +72,8 @@ export type Swap = ( | 'slippage' | 'estimatedRoute' | 'curve' - | 'onSuccess' - | 'onError' - | 'onReject' >, -) => Promise; +) => Promise; export type SwapApi = { estimateAmount: EstimateAmount; diff --git a/libs/oeth/swap/src/views/SwapView.tsx b/libs/oeth/swap/src/views/SwapView.tsx index 285c7df93..5783a9abe 100644 --- a/libs/oeth/swap/src/views/SwapView.tsx +++ b/libs/oeth/swap/src/views/SwapView.tsx @@ -13,7 +13,7 @@ import { Stack, Typography, } from '@mui/material'; -import { GasPopover } from '@origin/oeth/shared'; +import { ApyHeader, GasPopover } from '@origin/oeth/shared'; import { TokenInput } from '@origin/shared/components'; import { ConnectedButton, @@ -24,7 +24,6 @@ import { composeContexts, isNilOrEmpty } from '@origin/shared/utils'; import { useIntl } from 'react-intl'; import { mainnet, useAccount, useBalance, useNetwork } from 'wagmi'; -import { ApyHeader } from '../components/ApyHeader'; import { SwapRoute } from '../components/SwapRoute'; import { TokenSelectModal } from '../components/TokenSelectModal'; import { buttonActionLabel } from '../constants'; diff --git a/libs/shared/components/src/Icons/ActivityIcon.tsx b/libs/shared/components/src/Icons/ActivityIcon.tsx new file mode 100644 index 000000000..baaf1133b --- /dev/null +++ b/libs/shared/components/src/Icons/ActivityIcon.tsx @@ -0,0 +1,48 @@ +import { keyframes } from '@emotion/react'; +import { Box, Fade } from '@mui/material'; + +import type { BoxProps } from '@mui/material'; + +export type ActivityIconStatus = 'idle' | 'pending' | 'success' | 'error'; + +const spin = keyframes` + to { + transform: rotate(360deg); + } +`; + +const iconPaths: Record = { + idle: '/images/activity.svg', + pending: '/images/pending.svg', + error: '/images/failed.svg', + success: '/images/success.svg', +}; + +type ActivityIconProps = { + status: ActivityIconStatus; + disablePendingSpin?: boolean; +} & BoxProps<'img'>; + +export const ActivityIcon = ({ + status, + disablePendingSpin, + ...rest +}: ActivityIconProps) => { + return ( + + + + ); +}; diff --git a/libs/shared/components/src/LinkIcon/index.tsx b/libs/shared/components/src/Icons/LinkIcon.tsx similarity index 100% rename from libs/shared/components/src/LinkIcon/index.tsx rename to libs/shared/components/src/Icons/LinkIcon.tsx diff --git a/libs/shared/components/src/Icons/index.ts b/libs/shared/components/src/Icons/index.ts new file mode 100644 index 000000000..a592efbb0 --- /dev/null +++ b/libs/shared/components/src/Icons/index.ts @@ -0,0 +1,2 @@ +export * from './ActivityIcon'; +export * from './LinkIcon'; diff --git a/libs/shared/components/src/Notifications/NotificationSnack.tsx b/libs/shared/components/src/Notifications/NotificationSnack.tsx new file mode 100644 index 000000000..7cecf6ab1 --- /dev/null +++ b/libs/shared/components/src/Notifications/NotificationSnack.tsx @@ -0,0 +1,56 @@ +import { Stack, Typography } from '@mui/material'; +import { isNilOrEmpty } from '@origin/shared/utils'; + +import { LinkIcon } from '../Icons'; + +import type { StackProps, TypographyProps } from '@mui/material'; +import type { ReactNode } from 'react'; + +export type NotificationSnackProps = { + icon?: ReactNode; + title: ReactNode; + href?: string; + subtitle: ReactNode; + endIcon?: ReactNode; + titleProps?: TypographyProps; + subtitleProps?: TypographyProps; +} & Omit; + +export const NotificationSnack = ({ + icon, + title, + href, + subtitle, + endIcon, + titleProps, + subtitleProps, + ...rest +}: NotificationSnackProps) => { + return ( + + + + {icon} + {typeof title === 'string' ? ( + {title} + ) : ( + title + )} + {!isNilOrEmpty(href) && } + + + {typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + )} + + + + {endIcon} + + + ); +}; diff --git a/libs/shared/components/src/Notifications/index.ts b/libs/shared/components/src/Notifications/index.ts new file mode 100644 index 000000000..755134b64 --- /dev/null +++ b/libs/shared/components/src/Notifications/index.ts @@ -0,0 +1 @@ +export * from './NotificationSnack'; diff --git a/libs/shared/components/src/index.ts b/libs/shared/components/src/index.ts index 525e4f83b..1e8949d57 100644 --- a/libs/shared/components/src/index.ts +++ b/libs/shared/components/src/index.ts @@ -2,8 +2,9 @@ export * from './Cards'; export * from './DataTable'; export * from './InfoTooltip'; export * from './Inputs'; -export * from './LinkIcon'; +export * from './Icons'; export * from './Loader'; export * from './MiddleTruncated'; export * from './Mix'; +export * from './Notifications'; export * from './top-nav'; diff --git a/libs/shared/components/src/top-nav/ConnectedButton.tsx b/libs/shared/components/src/top-nav/ConnectedButton.tsx index 121e778be..523c4b805 100644 --- a/libs/shared/components/src/top-nav/ConnectedButton.tsx +++ b/libs/shared/components/src/top-nav/ConnectedButton.tsx @@ -12,7 +12,7 @@ import { } from '@mui/material'; import { useIntl } from 'react-intl'; -import { LinkIcon } from '../LinkIcon'; +import { LinkIcon } from '../Icons'; import { MiddleTruncated } from '../MiddleTruncated'; import { Icon } from './Icon'; import { styles } from './utils'; diff --git a/libs/shared/components/src/top-nav/Transaction.tsx b/libs/shared/components/src/top-nav/Transaction.tsx index 5bef600f7..437090a1d 100644 --- a/libs/shared/components/src/top-nav/Transaction.tsx +++ b/libs/shared/components/src/top-nav/Transaction.tsx @@ -2,7 +2,7 @@ import { keyframes } from '@emotion/react'; import { Stack, Typography } from '@mui/material'; import { useIntl } from 'react-intl'; -import { LinkIcon } from '../LinkIcon'; +import { LinkIcon } from '../Icons'; import { Icon } from './Icon'; import { messages } from './utils'; diff --git a/libs/shared/providers/src/notifications/components/NotificationSnack.tsx b/libs/shared/providers/src/notifications/components/NotificationSnack.tsx index e20102af1..e4f5d4bda 100644 --- a/libs/shared/providers/src/notifications/components/NotificationSnack.tsx +++ b/libs/shared/providers/src/notifications/components/NotificationSnack.tsx @@ -47,8 +47,12 @@ export const NotificationSnack = ({ borderRadius: 1, minWidth: { sm: 300, md: 400, lg: 500, xl: 600 }, maxWidth: { sm: 400, md: 500, lg: 600, xl: 700 }, + '.MuiAlert-message': { + width: 1, + }, ...AlertProps?.sx, }} + {...(!isNilOrEmpty(content) && { icon: false })} onClose={handleCloseClick} > {!isNilOrEmpty(title) && ( diff --git a/libs/shared/providers/src/notifications/hooks.ts b/libs/shared/providers/src/notifications/hooks.ts index ae269fc29..a127e3db4 100644 --- a/libs/shared/providers/src/notifications/hooks.ts +++ b/libs/shared/providers/src/notifications/hooks.ts @@ -10,7 +10,7 @@ import type { ReactNode } from 'react'; type NotificationOptions = { severity?: AlertColor; - title: string; + title?: string; message?: string; content?: ReactNode; visible?: boolean; diff --git a/libs/shared/providers/src/notifications/types.ts b/libs/shared/providers/src/notifications/types.ts index 322fa21e7..1c4e95d31 100644 --- a/libs/shared/providers/src/notifications/types.ts +++ b/libs/shared/providers/src/notifications/types.ts @@ -3,8 +3,8 @@ import type { ReactNode } from 'react'; export type Notification = { id: string; - severity: AlertColor; - title: string; + severity?: AlertColor; + title?: string; message?: string; content?: ReactNode; createdOn: number; diff --git a/libs/shared/theme/src/theme.tsx b/libs/shared/theme/src/theme.tsx index 773463223..81f65f3d0 100644 --- a/libs/shared/theme/src/theme.tsx +++ b/libs/shared/theme/src/theme.tsx @@ -164,7 +164,7 @@ export const theme = extendTheme({ ), warning: ( - + ), }, }, @@ -364,6 +364,16 @@ export const theme = extendTheme({ }), }, }, + MuiIconButton: { + styleOverrides: { + root: ({ theme }) => ({ + background: theme.palette.background.gradientPaper, + '&:hover': { + background: theme.palette.background.paper, + }, + }), + }, + }, MuiInputBase: { styleOverrides: { root: ({ theme }) => ({ @@ -464,7 +474,7 @@ export const theme = extendTheme({ }, styleOverrides: { text: ({ theme }) => ({ - borderRadius: 15, + borderRadius: 10, // backgroundColor: theme.palette.grey[900], }), }, diff --git a/libs/shared/utils/src/errors.ts b/libs/shared/utils/src/errors.ts new file mode 100644 index 000000000..f99046b61 --- /dev/null +++ b/libs/shared/utils/src/errors.ts @@ -0,0 +1,6 @@ +import { pathEq } from 'ramda'; + +export const isUserRejected = pathEq('UserRejectedRequestError', [ + 'cause', + 'name', +]); diff --git a/libs/shared/utils/src/index.ts b/libs/shared/utils/src/index.ts index aef480533..c571e023d 100644 --- a/libs/shared/utils/src/index.ts +++ b/libs/shared/utils/src/index.ts @@ -1,6 +1,7 @@ export * from './addresses'; export * from './BigInt'; export * from './composeContext'; +export * from './errors'; export * from './formatters'; export * from './isNilOrEmpty'; export * from './types';