From 0826e97a4913dd98a5452cdcf51c59bdfa85b99a Mon Sep 17 00:00:00 2001 From: Wukong Sun Date: Tue, 24 Sep 2024 10:16:49 +0800 Subject: [PATCH] feat: fw-6316 okx bridge (#11790) --- cspell.json | 3 + packages/icons/general/Checkbox.svg | 2 +- packages/icons/general/EmptySimple.dark.svg | 2 +- packages/icons/general/EmptySimple.light.svg | 2 +- packages/icons/general/RadioButtonChecked.svg | 2 +- packages/icons/icon-generated-as-jsx.js | 78 ++- packages/plugins/Trader/package.json | 1 + .../src/SiteAdaptor/components/BridgeNode.tsx | 95 ++++ .../src/SiteAdaptor/components/CoinIcon.tsx | 74 +++ .../src/SiteAdaptor/components/DexRouters.tsx | 86 +++ .../SiteAdaptor/components/RouterDialog.tsx | 17 - .../Trader/src/SiteAdaptor/constants.ts | 57 +- .../plugins/Trader/src/SiteAdaptor/index.tsx | 2 +- .../plugins/Trader/src/SiteAdaptor/storage.ts | 49 +- .../src/SiteAdaptor/trader/ExchangeDialog.tsx | 44 +- .../Trader/src/SiteAdaptor/trader/Routes.tsx | 10 +- .../trader/contexts/GasManager.tsx | 2 +- .../trader/contexts/SwapProvider.tsx | 156 ------ .../trader/contexts/TradeProvider.tsx | 245 ++++++++ .../src/SiteAdaptor/trader/contexts/index.tsx | 8 +- .../Trader/src/SiteAdaptor/trader/helpers.ts | 13 + .../SiteAdaptor/trader/hooks/useBridgable.ts | 25 + .../SiteAdaptor/trader/hooks/useBridgeData.ts | 19 + .../trader/hooks/useBridgeQuotes.ts | 23 + .../SiteAdaptor/trader/hooks/useGasCost.ts | 2 +- .../trader/hooks/useLiquidityResources.ts | 11 +- .../src/SiteAdaptor/trader/hooks/useQuotes.ts | 11 +- .../SiteAdaptor/trader/hooks/useSwappable.ts | 6 +- .../src/SiteAdaptor/trader/hooks/useToken.ts | 12 + .../SiteAdaptor/trader/hooks/useTokenPrice.ts | 12 + .../trader/views/BridgeConfirm.tsx | 521 ++++++++++++++++++ .../trader/views/BridgeQuoteRoute.tsx | 198 +++++++ .../src/SiteAdaptor/trader/views/Confirm.tsx | 40 +- .../src/SiteAdaptor/trader/views/History.tsx | 67 ++- .../SiteAdaptor/trader/views/QuoteRoute.tsx | 41 +- .../trader/views/SelectLiquidity.tsx | 22 +- .../src/SiteAdaptor/trader/views/Slippage.tsx | 2 +- .../trader/views/{Swap => Trade}/Quote.tsx | 46 +- .../trader/views/{Swap => Trade}/index.tsx | 65 ++- .../SiteAdaptor/trader/views/TradingRoute.tsx | 269 +++++++-- .../SiteAdaptor/trader/views/Transaction.tsx | 23 +- .../plugins/Trader/src/assets/cbridge.svg | 19 + packages/plugins/Trader/src/assets/cctp.svg | 16 + .../plugins/Trader/src/assets/connext.png | Bin 0 -> 8836 bytes packages/plugins/Trader/src/assets/hyphen.svg | 5 + .../plugins/Trader/src/assets/stargate.svg | 9 + .../plugins/Trader/src/assets/wanchain.png | Bin 0 -> 7031 bytes .../plugins/Trader/src/assets/wormhole.svg | 11 + .../plugins/Trader/src/helpers/formatTime.ts | 7 + packages/plugins/Trader/src/locale/en-US.json | 17 + packages/plugins/Trader/src/locale/en-US.po | 206 +++++-- packages/plugins/Trader/src/locale/ja-JP.json | 17 + packages/plugins/Trader/src/locale/ja-JP.po | 206 +++++-- packages/plugins/Trader/src/locale/ko-KR.json | 17 + packages/plugins/Trader/src/locale/ko-KR.po | 206 +++++-- packages/plugins/Trader/src/locale/zh-CN.json | 17 + packages/plugins/Trader/src/locale/zh-CN.po | 206 +++++-- packages/plugins/Trader/src/locale/zh-TW.json | 17 + packages/plugins/Trader/src/locale/zh-TW.po | 206 +++++-- packages/plugins/Trader/src/types/trader.ts | 31 +- .../FungibleTokenList/ManageTokenListBar.tsx | 14 +- .../UI/components/FungibleTokenList/index.tsx | 11 +- .../src/UI/components/NetworkIcon/index.tsx | 1 + .../src/UI/components/TokenIcon/index.tsx | 15 +- .../SelectFungibleTokenDialog.tsx | 14 +- packages/web3-hooks/evm/src/index.ts | 1 - .../web3-hooks/evm/src/useBalanceChecker.ts | 10 - .../web3-hooks/evm/src/useOKXTokenList.ts | 7 +- packages/web3-providers/src/OKX/constant.ts | 6 + packages/web3-providers/src/OKX/helper.ts | 12 +- packages/web3-providers/src/OKX/index.ts | 171 +++++- packages/web3-providers/src/OKX/types.ts | 375 +++++++++++-- pnpm-lock.yaml | 3 + 73 files changed, 3457 insertions(+), 759 deletions(-) create mode 100644 packages/plugins/Trader/src/SiteAdaptor/components/BridgeNode.tsx create mode 100644 packages/plugins/Trader/src/SiteAdaptor/components/CoinIcon.tsx create mode 100644 packages/plugins/Trader/src/SiteAdaptor/components/DexRouters.tsx delete mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/contexts/SwapProvider.tsx create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/contexts/TradeProvider.tsx create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/helpers.ts create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgable.ts create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeData.ts create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeQuotes.ts create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useToken.ts create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useTokenPrice.ts create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/views/BridgeConfirm.tsx create mode 100644 packages/plugins/Trader/src/SiteAdaptor/trader/views/BridgeQuoteRoute.tsx rename packages/plugins/Trader/src/SiteAdaptor/trader/views/{Swap => Trade}/Quote.tsx (78%) rename packages/plugins/Trader/src/SiteAdaptor/trader/views/{Swap => Trade}/index.tsx (87%) create mode 100644 packages/plugins/Trader/src/assets/cbridge.svg create mode 100644 packages/plugins/Trader/src/assets/cctp.svg create mode 100644 packages/plugins/Trader/src/assets/connext.png create mode 100644 packages/plugins/Trader/src/assets/hyphen.svg create mode 100644 packages/plugins/Trader/src/assets/stargate.svg create mode 100644 packages/plugins/Trader/src/assets/wanchain.png create mode 100644 packages/plugins/Trader/src/assets/wormhole.svg create mode 100644 packages/plugins/Trader/src/helpers/formatTime.ts delete mode 100644 packages/web3-hooks/evm/src/useBalanceChecker.ts diff --git a/cspell.json b/cspell.json index 0b2459d8d861..4b6e1a2998de 100644 --- a/cspell.json +++ b/cspell.json @@ -56,6 +56,8 @@ "blurhash", "boba", "bonfida", + "bridgable", + "bridgers", "brise", "cacheable", "caip", @@ -377,6 +379,7 @@ "bsct", "btcb", "canft", + "cctp", "celo", "ceur", "chainid", diff --git a/packages/icons/general/Checkbox.svg b/packages/icons/general/Checkbox.svg index 7a18d34bc6ad..8e54b893d235 100644 --- a/packages/icons/general/Checkbox.svg +++ b/packages/icons/general/Checkbox.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/icons/general/EmptySimple.dark.svg b/packages/icons/general/EmptySimple.dark.svg index 276e84aa0566..8244d4719900 100644 --- a/packages/icons/general/EmptySimple.dark.svg +++ b/packages/icons/general/EmptySimple.dark.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/icons/general/EmptySimple.light.svg b/packages/icons/general/EmptySimple.light.svg index 49191e225f0b..8070ebb6d54f 100644 --- a/packages/icons/general/EmptySimple.light.svg +++ b/packages/icons/general/EmptySimple.light.svg @@ -1,5 +1,5 @@ - + diff --git a/packages/icons/general/RadioButtonChecked.svg b/packages/icons/general/RadioButtonChecked.svg index 1a4baeac3ee8..6d117ffda86b 100644 --- a/packages/icons/general/RadioButtonChecked.svg +++ b/packages/icons/general/RadioButtonChecked.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/icons/icon-generated-as-jsx.js b/packages/icons/icon-generated-as-jsx.js index 0c526fd73ceb..fd8403a034cc 100644 --- a/packages/icons/icon-generated-as-jsx.js +++ b/packages/icons/icon-generated-as-jsx.js @@ -1263,10 +1263,13 @@ export const Checkbox = /*#__PURE__*/ __createIcon('Checkbox', [ d: 'M0 4a4 4 0 0 1 4-4h10a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4z', }), /*#__PURE__*/ _jsx('path', { - fill: '#fff', fillRule: 'evenodd', d: 'M14.03 5.47a.75.75 0 0 1 0 1.06l-6 6a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 0 1 1.06-1.06l2.47 2.47 5.47-5.47a.75.75 0 0 1 1.06 0', clipRule: 'evenodd', + style: { + '--default-stroke-color': '#fff', + fill: 'var(--stroke-color, var(--default-stroke-color, currentColor))', + }, }), ], }), @@ -1982,16 +1985,64 @@ export const Empty = /*#__PURE__*/ __createIcon('Empty', [ u: () => new URL('./general/Empty.png', import.meta.url).href, }, ]) -export const EmptySimple = /*#__PURE__*/ __createIcon('EmptySimple', [ - { - c: ['dark'], - u: () => new URL('./general/EmptySimple.dark.svg', import.meta.url).href, - }, - { - c: ['light'], - u: () => new URL('./general/EmptySimple.light.svg', import.meta.url).href, - }, -]) +export const EmptySimple = /*#__PURE__*/ __createIcon( + 'EmptySimple', + [ + { + c: ['dark'], + u: () => new URL('./general/EmptySimple.dark.svg', import.meta.url).href, + j: () => + /*#__PURE__*/ _jsx('svg', { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 36 37', + children: /*#__PURE__*/ _jsxs('g', { + fillRule: 'evenodd', + clipRule: 'evenodd', + style: { + '--default-color': '#666c75', + fill: 'var(--icon-color, var(--default-color, currentColor))', + }, + children: [ + /*#__PURE__*/ _jsx('path', { + d: 'M4.59 5.223C6.611 3.2 9.631 2.434 13.5 2.434h9c3.869 0 6.889.766 8.911 2.789 2.023 2.023 2.79 5.043 2.79 8.911v9c0 3.868-.767 6.889-2.79 8.911-2.022 2.023-5.042 2.79-8.91 2.79h-9c-3.87 0-6.89-.767-8.912-2.79S1.8 27.002 1.8 23.135v-9c0-3.87.767-6.89 2.79-8.912M6.285 6.92C4.934 8.272 4.2 10.502 4.2 14.134v9c0 3.632.734 5.862 2.086 7.214C7.64 31.7 9.87 32.434 13.5 32.434h9c3.632 0 5.862-.734 7.214-2.086s2.086-3.582 2.086-7.214v-9c0-3.632-.733-5.862-2.086-7.214S26.132 4.834 22.5 4.834h-9c-3.631 0-5.861.734-7.214 2.086z', + }), + /*#__PURE__*/ _jsx('path', { + d: 'm25.749 22.321-1.335 2.685a4.2 4.2 0 0 1-3.759 2.328H15.36c-.436 0-2.597-.003-3.77-2.305l-.004-.01-1.334-2.683a1.8 1.8 0 0 0-1.612-1.002H3a1.2 1.2 0 1 1 0-2.4h5.64a4.2 4.2 0 0 1 3.759 2.329v.002l1.333 2.68c.507.99 1.34.99 1.626.99h5.297a1.8 1.8 0 0 0 1.611-1.001l1.336-2.686a4.2 4.2 0 0 1 3.758-2.329h5.61a1.2 1.2 0 0 1 0 2.4h-5.61c-.68 0-1.303.386-1.611 1.002', + }), + ], + }), + }), + s: true, + }, + { + c: ['light'], + u: () => new URL('./general/EmptySimple.light.svg', import.meta.url).href, + j: () => + /*#__PURE__*/ _jsx('svg', { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 36 37', + children: /*#__PURE__*/ _jsxs('g', { + fillRule: 'evenodd', + clipRule: 'evenodd', + style: { + '--default-color': '#acb4c1', + fill: 'var(--icon-color, var(--default-color, currentColor))', + }, + children: [ + /*#__PURE__*/ _jsx('path', { + d: 'M4.59 5.223C6.611 3.2 9.631 2.434 13.5 2.434h9c3.869 0 6.889.766 8.911 2.789 2.023 2.023 2.79 5.043 2.79 8.911v9c0 3.868-.767 6.889-2.79 8.911-2.022 2.023-5.042 2.79-8.91 2.79h-9c-3.87 0-6.89-.767-8.912-2.79S1.8 27.002 1.8 23.135v-9c0-3.87.767-6.89 2.79-8.912M6.285 6.92C4.934 8.272 4.2 10.502 4.2 14.134v9c0 3.632.734 5.862 2.086 7.214C7.64 31.7 9.87 32.434 13.5 32.434h9c3.632 0 5.862-.734 7.214-2.086s2.086-3.582 2.086-7.214v-9c0-3.632-.733-5.862-2.086-7.214S26.132 4.834 22.5 4.834h-9c-3.631 0-5.861.734-7.214 2.086z', + }), + /*#__PURE__*/ _jsx('path', { + d: 'm25.749 22.321-1.335 2.685a4.2 4.2 0 0 1-3.759 2.328H15.36c-.436 0-2.597-.003-3.77-2.305l-.004-.01-1.334-2.683a1.8 1.8 0 0 0-1.612-1.002H3a1.2 1.2 0 1 1 0-2.4h5.64a4.2 4.2 0 0 1 3.759 2.329v.002l1.333 2.68c.507.99 1.34.99 1.626.99h5.297a1.8 1.8 0 0 0 1.611-1.001l1.336-2.686a4.2 4.2 0 0 1 3.758-2.329h5.61a1.2 1.2 0 0 1 0 2.4h-5.61c-.68 0-1.303.386-1.611 1.002', + }), + ], + }), + }), + s: true, + }, + ], + [36, 37], +) export const EncryptedFiles = /*#__PURE__*/ __createIcon('EncryptedFiles', [ { u: () => new URL('./general/EncryptedFiles.svg', import.meta.url).href, @@ -3038,11 +3089,14 @@ export const RadioButtonChecked = /*#__PURE__*/ __createIcon('RadioButtonChecked }, }), /*#__PURE__*/ _jsx('path', { - stroke: '#fff', strokeLinecap: 'round', strokeLinejoin: 'round', strokeWidth: '1.5', d: 'm5.166 10.63 3.333 3.5 6.667-7', + style: { + '--default-stroke-color': '#fff', + stroke: 'var(--stroke-color, var(--default-stroke-color, currentColor))', + }, }), ], }), diff --git a/packages/plugins/Trader/package.json b/packages/plugins/Trader/package.json index 6071467272df..2f07d4140812 100644 --- a/packages/plugins/Trader/package.json +++ b/packages/plugins/Trader/package.json @@ -35,6 +35,7 @@ "bignumber.js": "9.1.2", "date-fns": "^2.30.0", "fuse.js": "^7.0.0", + "immer": "^10.1.1", "react-router-dom": "^6.24.0", "react-use": "^17.4.0", "urlcat": "^3.1.0", diff --git a/packages/plugins/Trader/src/SiteAdaptor/components/BridgeNode.tsx b/packages/plugins/Trader/src/SiteAdaptor/components/BridgeNode.tsx new file mode 100644 index 000000000000..ec659dd90523 --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/components/BridgeNode.tsx @@ -0,0 +1,95 @@ +import { makeStyles } from '@masknet/theme' +import { Typography } from '@mui/material' +import { memo, type HTMLProps } from 'react' +import { CoinIcon, type CoinIconProps } from './CoinIcon.js' + +const useStyles = makeStyles()((theme, _, refs) => ({ + active: {}, + node: { + position: 'relative', + padding: theme.spacing(1.5), + display: 'flex', + flexDirection: 'column', + gap: 10, + borderRadius: theme.spacing(1.5), + border: `1px solid ${theme.palette.maskColor.line}`, + backgroundColor: theme.palette.maskColor.bg, + [`&.${refs.active}`]: { + backgroundColor: theme.palette.maskColor.bottom, + '&::after': { + content: '""', + display: 'block', + borderRadius: 4, + border: '1px solid transparent', + borderRightColor: theme.palette.maskColor.line, + borderBottomColor: theme.palette.maskColor.line, + transform: 'scaleX(.6) rotate(45deg) translate(-50%, 100%)', + backgroundColor: theme.palette.maskColor.bottom, + position: 'absolute', + width: 12, + height: 12, + left: '50%', + bottom: -2, + zIndex: 1, + }, + }, + }, + coin: { + display: 'flex', + gap: theme.spacing(0.5), + alignItems: 'flex-start', + }, + chainName: { + fontSize: 16, + lineHeight: '20px', + color: theme.palette.maskColor.second, + }, + symbol: { + fontSize: 16, + lineHeight: '20px', + fontWeight: 700, + color: theme.palette.maskColor.main, + }, + step: { + position: 'absolute', + userSelect: 'none', + right: 0, + top: 0, + minWidth: theme.spacing(4), + textAlign: 'center', + lineHeight: '76px', + fontSize: 64, + fontWeight: 700, + fontFamily: 'Helvetica', + color: theme.palette.maskColor.secondaryMain, + }, +})) + +interface Props extends HTMLProps, Pick { + symbol?: string + step: number + active?: boolean +} + +export const BridgeNode = memo(function BridgeNode({ + symbol, + chainId, + address, + label, + step, + active, + disableBadge, + ...rest +}) { + const { classes, cx } = useStyles() + return ( +
+ {label} +
+ + {symbol ?? '--'} +
+ {step} +
+ ) +}) diff --git a/packages/plugins/Trader/src/SiteAdaptor/components/CoinIcon.tsx b/packages/plugins/Trader/src/SiteAdaptor/components/CoinIcon.tsx new file mode 100644 index 000000000000..2f73fda0898e --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/components/CoinIcon.tsx @@ -0,0 +1,74 @@ +import { NetworkIcon, TokenIcon } from '@masknet/shared' +import { NetworkPluginID } from '@masknet/shared-base' +import { makeStyles } from '@masknet/theme' +import type { Web3Helper } from '@masknet/web3-helpers' +import { OKX } from '@masknet/web3-providers' +import { isSameAddress } from '@masknet/web3-shared-base' +import type { ChainId } from '@masknet/web3-shared-evm' +import { Box, type BoxProps } from '@mui/material' +import { skipToken, useQuery } from '@tanstack/react-query' +import { memo } from 'react' + +const useStyles = makeStyles()(() => ({ + icon: { + position: 'relative', + width: 30, + height: 30, + }, + tokenIcon: { + height: 30, + width: 30, + }, + badgeIcon: { + position: 'absolute', + right: -3, + bottom: -2, + }, +})) + +export interface CoinIconProps extends BoxProps { + chainId?: ChainId + address?: string + token?: Web3Helper.FungibleTokenAll + tokenIconSize?: number + chainIconSize?: number + disableBadge?: boolean +} + +export const CoinIcon = memo(function CoinIcon({ + chainId, + address, + tokenIconSize = 30, + chainIconSize = 12, + disableBadge, + ...rest +}) { + const { classes, cx } = useStyles() + + const { data: logoURL } = useQuery({ + queryKey: ['okx-tokens', chainId], + queryFn: chainId ? () => OKX.getTokens(chainId) : skipToken, + select(tokens) { + return tokens?.find((x) => isSameAddress(x.address, address))?.logoURL + }, + }) + return ( + + + {chainId && !disableBadge ? + + : null} + + ) +}) diff --git a/packages/plugins/Trader/src/SiteAdaptor/components/DexRouters.tsx b/packages/plugins/Trader/src/SiteAdaptor/components/DexRouters.tsx new file mode 100644 index 000000000000..0bf281b5cc43 --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/components/DexRouters.tsx @@ -0,0 +1,86 @@ +import { Icons } from '@masknet/icons' +import { TokenIcon } from '@masknet/shared' +import { makeStyles } from '@masknet/theme' +import type { SubRouter } from '@masknet/web3-providers/types' +import { Typography } from '@mui/material' +import { Fragment, memo } from 'react' + +const useStyles = makeStyles()((theme) => ({ + route: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, + tokenIcon: { + height: 30, + width: 30, + }, + arrow: { + transform: 'rotate(-90deg)', + color: theme.palette.maskColor.second, + }, + token: { + backgroundColor: theme.palette.maskColor.bg, + display: 'flex', + alignItems: 'center', + padding: theme.spacing('8px', '6px'), + borderRadius: theme.spacing(1.5), + gap: theme.spacing(0.5), + fontSize: 14, + fontWeight: 700, + color: theme.palette.maskColor.main, + }, + pool: { + flexGrow: 1, + backgroundColor: theme.palette.maskColor.bg, + padding: theme.spacing(1.5), + borderRadius: theme.spacing(1.5), + fontSize: 13, + fontWeight: 400, + color: theme.palette.maskColor.main, + textOverflow: 'ellipsis', + overflow: 'hidden', + whiteSpace: 'nowrap', + }, +})) + +interface Props { + percent: string + chainId: number + routers: SubRouter[] +} + +export const DexRouters = memo(function DexRouters({ chainId, percent, routers }) { + const { classes } = useStyles() + const startSubRoute = routers.at(0) + const endSubRoute = routers.at(-1) + const middleSubRoutes = routers.slice(1, -1) + return ( +
+ + + {percent}% + + {middleSubRoutes.map((subRouter) => { + return ( + + + {subRouter.dexProtocol[0].dexName} + + ) + })} + +
+ +
+
+ ) +}) diff --git a/packages/plugins/Trader/src/SiteAdaptor/components/RouterDialog.tsx b/packages/plugins/Trader/src/SiteAdaptor/components/RouterDialog.tsx index b6b4baf8f7f3..691e08ff43ea 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/components/RouterDialog.tsx +++ b/packages/plugins/Trader/src/SiteAdaptor/components/RouterDialog.tsx @@ -2,27 +2,11 @@ import { InjectedDialog, type InjectedDialogProps } from '@masknet/shared' import { useLayoutEffect } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { RoutePaths } from '../constants.js' -import { t } from '@lingui/macro' export function RouterDialog(props: InjectedDialogProps) { const { pathname } = useLocation() const navigate = useNavigate() - const titleMap: Record = { - [RoutePaths.Swap]: t`Exchange`, - [RoutePaths.History]: t`History`, - [RoutePaths.Confirm]: t`Confirm swap`, - [RoutePaths.SelectLiquidity]: t`Select Liquidity`, - [RoutePaths.Slippage]: t`Slippage`, - [RoutePaths.QuoteRoute]: t`Quote Route`, - [RoutePaths.TradingRoute]: t`Trading Route`, - [RoutePaths.Exit]: null, - [RoutePaths.NetworkFee]: t`Network fee`, - [RoutePaths.Transaction]: t`Transaction Details`, - } - - const title = titleMap[pathname as RoutePaths] ?? t`Exchange` - useLayoutEffect(() => { if (pathname === RoutePaths.Exit) { props.onClose?.() @@ -32,7 +16,6 @@ export function RouterDialog(props: InjectedDialogProps) { return ( { navigate(-1) }} diff --git a/packages/plugins/Trader/src/SiteAdaptor/constants.ts b/packages/plugins/Trader/src/SiteAdaptor/constants.ts index 8cf830b7f612..551d69171739 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/constants.ts +++ b/packages/plugins/Trader/src/SiteAdaptor/constants.ts @@ -1,12 +1,14 @@ export const enum RoutePaths { - Swap = '/swap', + Trade = '/trade', History = '/history', Confirm = '/confirm', + BridgeConfirm = '/bridge-confirm', Transaction = '/transaction', Exit = '/exit', SelectLiquidity = '/select-liquidity', Slippage = '/slippage', QuoteRoute = '/quote-route', + BridgeQuoteRoute = '/bridge-quote-route', TradingRoute = '/trading-route', NetworkFee = '/network-fee', } @@ -14,3 +16,56 @@ export const enum RoutePaths { export const DEFAULT_SLIPPAGE = '0.5' export const QUOTE_STALE_DURATION = 20_000 + +export const bridges = [ + { + id: 275, + name: 'Stargate', + logoUrl: 'https://www.okx.com/cdn/wallet/logo/dex_stargate_bridge.png', + }, + { + id: 639, + name: 'Stargate V2', + logoUrl: 'https://www.okx.com/cdn/wallet/logo/dex_stargate_bridge.png', + }, + { + id: 315, + name: 'Across', + logoUrl: '', + }, + { + id: 211, + name: 'Cbridge', + logoUrl: new URL('../assets/cbridge.svg', import.meta.url).href, + }, + { + id: 353, + name: 'Wanchain', + logoUrl: 'https://www.okx.com/cdn/wallet/logo/dex_Wanchain.png', + }, + { + id: 416, + name: 'Hyphen', + logoUrl: new URL('../assets/hyphen.svg', import.meta.url).href, + }, + { + id: 223, + name: 'Meson', + logoUrl: '', + }, + { + id: 129, + name: 'Wormhole', + logoUrl: new URL('../assets/wormhole.svg', import.meta.url).href, + }, + { + id: 480, + name: 'Connext', + logoUrl: new URL('../assets/connext.png', import.meta.url).href, + }, + { + id: 235, + name: 'Bridgers', + logoUrl: 'https://www.okx.com/cdn/web3/dex/logo/4e1e3c31-aaed-4131-a1d4-3440b7ae9cc3.png', + }, +] diff --git a/packages/plugins/Trader/src/SiteAdaptor/index.tsx b/packages/plugins/Trader/src/SiteAdaptor/index.tsx index 55585b203475..3b7d2a3be802 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/index.tsx +++ b/packages/plugins/Trader/src/SiteAdaptor/index.tsx @@ -26,7 +26,7 @@ function openDialog() { const site: Plugin.SiteAdaptor.Definition = { ...base, init(_, context) { - setupStorage(context.createKVStorage('persistent', { transactions: [] })) + setupStorage(context.createKVStorage('persistent', { transactions: {} })) }, SearchResultInspector: { ID: PluginID.Trader, diff --git a/packages/plugins/Trader/src/SiteAdaptor/storage.ts b/packages/plugins/Trader/src/SiteAdaptor/storage.ts index 42deacbaf870..5ea9759eb3ef 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/storage.ts +++ b/packages/plugins/Trader/src/SiteAdaptor/storage.ts @@ -1,9 +1,10 @@ -import type { ScopedStorage } from '@masknet/shared-base' +import { EMPTY_LIST, type ScopedStorage } from '@masknet/shared-base' import { useSubscription } from 'use-subscription' -import type { OkxSwapTransaction } from '../types/trader.js' +import type { OkxTransaction } from '../types/trader.js' export interface StorageOptions { - transactions: OkxSwapTransaction[] + /** isolated by wallet */ + transactions: Record } let storage: ScopedStorage @@ -11,34 +12,44 @@ export function setupStorage(initialized: ScopedStorage) { storage = initialized } -export function useSwapHistory() { +export function useSwapHistory(address: string) { const txes = useSubscription(storage?.storage?.transactions?.subscription) - return txes + return txes[address.toLowerCase()] || EMPTY_LIST } -export async function addTransaction(transaction: OkxSwapTransaction) { +export async function addTransaction(address: string, transaction: T) { if (!storage?.storage?.transactions) return - const transactions = storage.storage.transactions - await transactions.initializedPromise - transactions.setValue([...transactions.value, transaction]) + const txObject = storage.storage.transactions + await txObject.initializedPromise + const addr = address.toLowerCase() + const transactions = txObject.value[addr] || [] + txObject.setValue({ + ...txObject.value, + [addr]: [...transactions, transaction], + }) } -export async function updateTransaction( +export async function updateTransaction( + address: string, txId: string, - transaction: Partial | ((tx: OkxSwapTransaction) => OkxSwapTransaction), + transaction: Partial | ((tx: T) => T), ) { if (!storage?.storage?.transactions) return - const transactions = storage.storage.transactions - await transactions.initializedPromise - transactions.setValue( - transactions.value.map((tx) => { + const txesObject = storage.storage.transactions + + await txesObject.initializedPromise + const addr = address.toLowerCase() + const transactions = txesObject.value[addr] || [] + txesObject.setValue({ + ...txesObject.value, + [addr]: transactions.map((tx) => { if (tx.hash !== txId) return tx - return typeof transaction === 'function' ? transaction(tx) : { ...tx, ...transaction } + return typeof transaction === 'function' ? transaction(tx as T) : { ...tx, ...transaction } }), - ) + }) } -export function useTransaction(hash: string | null) { - const txes = useSwapHistory() +export function useTransaction(address: string, hash: string | null) { + const txes = useSwapHistory(address) return hash ? txes.find((x) => x.hash === hash) : null } diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/ExchangeDialog.tsx b/packages/plugins/Trader/src/SiteAdaptor/trader/ExchangeDialog.tsx index 1cc315f50cfa..2107338b26ce 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/ExchangeDialog.tsx +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/ExchangeDialog.tsx @@ -1,13 +1,15 @@ +import { t } from '@lingui/macro' import { Icons } from '@masknet/icons' -import { makeStyles } from '@masknet/theme' -import { DialogContent } from '@mui/material' +import { makeStyles, MaskTabList } from '@masknet/theme' +import { TabContext } from '@mui/lab' +import { DialogContent, Tab } from '@mui/material' import { Box } from '@mui/system' import { memo } from 'react' import { matchPath, MemoryRouter, useLocation, useNavigate } from 'react-router-dom' import { RouterDialog } from '../components/RouterDialog.js' import { RoutePaths } from '../constants.js' import { ExchangeRoutes } from './Routes.js' -import { Providers } from './contexts/index.js' +import { Providers, useSwap, type TradeMode } from './contexts/index.js' const useStyles = makeStyles()((theme) => ({ icons: { @@ -47,14 +49,32 @@ export const Dialog = memo(function Dialog({ onClose }) { const { classes } = useStyles() const { pathname } = useLocation() - const match = matchPath(RoutePaths.Swap, pathname) + const match = matchPath(RoutePaths.Trade, pathname) const navigate = useNavigate() + const { mode, setMode } = useSwap() + + const titleMap: Record = { + [RoutePaths.Trade]: t`Exchange`, + [RoutePaths.History]: t`History`, + [RoutePaths.Confirm]: t`Confirm Swap`, + [RoutePaths.BridgeConfirm]: t`Confirm Bridge`, + [RoutePaths.SelectLiquidity]: t`Select Liquidity`, + [RoutePaths.Slippage]: t`Slippage`, + [RoutePaths.QuoteRoute]: t`Quote Route`, + [RoutePaths.BridgeQuoteRoute]: t`Quote Route`, + [RoutePaths.TradingRoute]: t`Trading Route`, + [RoutePaths.Exit]: null, + [RoutePaths.NetworkFee]: t`Network fee`, + [RoutePaths.Transaction]: t`Transaction Details`, + } + const title = titleMap[pathname as RoutePaths] ?? t`Exchange` return ( (function Dialog({ onClose }) { /> : null + } + titleTabs={ + match ? + + { + setMode(tab as TradeMode) + }}> + + + + + : null }> @@ -79,7 +113,7 @@ export const Dialog = memo(function Dialog({ onClose }) { ) }) -const initialEntries = [RoutePaths.Exit, RoutePaths.Swap] +const initialEntries = [RoutePaths.Exit, RoutePaths.Trade] export const ExchangeDialog = memo(function ExchangeDialog(props) { return ( diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/Routes.tsx b/packages/plugins/Trader/src/SiteAdaptor/trader/Routes.tsx index 459dfcab6dcd..b690ec30009c 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/Routes.tsx +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/Routes.tsx @@ -1,31 +1,35 @@ import { Navigate, Route, Routes } from 'react-router-dom' import { RoutePaths } from '../constants.js' +import { BridgeConfirm } from './views/BridgeConfirm.js' +import { BridgeQuoteRoute } from './views/BridgeQuoteRoute.js' import { Confirm } from './views/Confirm.js' import { HistoryView } from './views/History.js' import { NetworkFee } from './views/NetworkFee.js' import { QuoteRoute } from './views/QuoteRoute.js' import { SelectLiquidity } from './views/SelectLiquidity.js' import { Slippage } from './views/Slippage.js' -import { SwapView } from './views/Swap/index.js' +import { TradeView } from './views/Trade/index.js' import { TradingRoute } from './views/TradingRoute.js' import { Transaction } from './views/Transaction.js' export function ExchangeRoutes() { return ( - } /> + } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> {/* If router is embedded inside a dialog, */} {/* which should know it's time to close itself once we enter Exit */} - } /> + } /> ) } diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/GasManager.tsx b/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/GasManager.tsx index 4cdcede30515..c3afa146c11e 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/GasManager.tsx +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/GasManager.tsx @@ -13,7 +13,7 @@ import { type PropsWithChildren, type SetStateAction, } from 'react' -import { useSwap } from './SwapProvider.js' +import { useSwap } from './TradeProvider.js' interface Options { gasLimit: string | undefined diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/SwapProvider.tsx b/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/SwapProvider.tsx deleted file mode 100644 index 8e59d9484382..000000000000 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/SwapProvider.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { EMPTY_LIST, NetworkPluginID } from '@masknet/shared-base' -import type { Web3Helper } from '@masknet/web3-helpers' -import { useChainContext, useNativeToken } from '@masknet/web3-hooks-base' -import type { GetQuotesResponse, OKXSwapQuote } from '@masknet/web3-providers/types' -import { rightShift } from '@masknet/web3-shared-base' -import { ChainId } from '@masknet/web3-shared-evm' -import { - createContext, - useContext, - useMemo, - useState, - type Dispatch, - type PropsWithChildren, - type SetStateAction, -} from 'react' -import { base } from '../../../base.js' -import { DEFAULT_SLIPPAGE } from '../../constants.js' -import { useLiquidityResources } from '../hooks/useLiquidityResources.js' -import { useQuotes } from '../hooks/useQuotes.js' -import { useUsdtToken } from '../hooks/useUsdtToken.js' -import type { QueryObserverResult, RefetchOptions } from '@tanstack/react-query' -import { t } from '@lingui/macro' - -interface Options { - chainId: ChainId - setChainId: Dispatch> - nativeToken: Web3Helper.FungibleTokenAll | undefined - fromToken: Web3Helper.FungibleTokenAll | undefined - setFromToken: Dispatch> - toToken: Web3Helper.FungibleTokenAll | undefined - setToToken: Dispatch> - quote: OKXSwapQuote | undefined - isQuoteStale: boolean - updateQuote: (options?: RefetchOptions) => Promise> - quoteErrorMessage: string | undefined - inputAmount: string - setInputAmount: Dispatch> - /** - * Disabled dexId of the liquidity pool for limited quotes. - * We record disabled ones only, if no disabled ones, we cna omit the parameter - */ - disabledDexIds: string[] - setDisabledDexIds: Dispatch> - expand: boolean - setExpand: Dispatch> - slippage: string - setSlippage: Dispatch> - isAutoSlippage: boolean - setIsAutoSlippage: Dispatch> - /** Gas Limit */ - gas: string | undefined -} - -const chainIds = base.enableRequirement.web3[NetworkPluginID.PLUGIN_EVM].supportedChainIds - -const SwapContext = createContext(null!) -export function SwapProvider({ children }: PropsWithChildren) { - const { chainId: contextChainId } = useChainContext() - const defaultChainId = chainIds.includes(contextChainId) ? contextChainId : ChainId.Mainnet - const [chainId = defaultChainId, setChainId] = useState(defaultChainId) - const { data: nativeToken } = useNativeToken(NetworkPluginID.PLUGIN_EVM, { chainId }) - - const usdtToken = useUsdtToken(chainId) - const [fromToken = nativeToken, setFromToken] = useState() - const [toToken = fromToken?.address === usdtToken?.address ? undefined : usdtToken, setToToken] = - useState() - - const [inputAmount, setInputAmount] = useState('') - const decimals = fromToken?.decimals - const amount = useMemo( - () => (inputAmount && decimals ? rightShift(inputAmount, decimals).toFixed(0) : ''), - [inputAmount, decimals], - ) - - const [disabledDexIds, setDisabledDexIds] = useState(EMPTY_LIST) - const { data: liquidityRes } = useLiquidityResources(chainId) - const dexIds = useMemo(() => { - if (!liquidityRes?.data.length) return undefined - const allIds = liquidityRes.data.map((x) => x.id) - if (!disabledDexIds.length) return undefined - return allIds.filter((x) => !disabledDexIds.includes(x)) - }, [disabledDexIds, liquidityRes?.data]) - - const { - data: quoteRes, - isStale: isQuoteStale, - refetch: updateQuote, - } = useQuotes({ - chainId: chainId.toString(), - amount, - fromTokenAddress: fromToken?.address, - toTokenAddress: toToken?.address, - dexIds: dexIds?.length ? dexIds.join(',') : undefined, - }) - const quote = quoteRes?.code === 0 ? quoteRes.data[0] : undefined - const quoteErrorMessage = - quoteRes?.msg ?? - (quoteRes?.code === 0 && !quoteRes.data.length ? - t`Swaps between this token pair are’t supported at the moment. You can try with third-party DApps instead.` - : '') - - // slippage - const [isAutoSlippage, setIsAutoSlippage] = useState(true) - const [slippage, setSlippage] = useState('') - - // misc, ui - const [expand, setExpand] = useState(false) - - const value = useMemo( - () => ({ - chainId, - setChainId, - quote, - isQuoteStale, - updateQuote, - nativeToken, - fromToken, - setFromToken, - toToken, - setToToken, - inputAmount, - setInputAmount, - quoteErrorMessage, - disabledDexIds, - setDisabledDexIds, - expand, - setExpand, - isAutoSlippage, - setIsAutoSlippage, - slippage: slippage || DEFAULT_SLIPPAGE, - setSlippage, - gas: quote?.estimateGasFee, - }), - [ - chainId, - quote, - isQuoteStale, - updateQuote, - nativeToken, - fromToken, - toToken, - inputAmount, - quoteErrorMessage, - disabledDexIds, - expand, - isAutoSlippage, - slippage, - setSlippage, - ], - ) - return {children} -} - -export function useSwap() { - return useContext(SwapContext) -} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/TradeProvider.tsx b/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/TradeProvider.tsx new file mode 100644 index 000000000000..071e25739368 --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/TradeProvider.tsx @@ -0,0 +1,245 @@ +import { t } from '@lingui/macro' +import { EMPTY_LIST, NetworkPluginID } from '@masknet/shared-base' +import type { Web3Helper } from '@masknet/web3-helpers' +import { useChainContext, useNativeToken } from '@masknet/web3-hooks-base' +import type { + GetBridgeQuoteResponse, + GetQuotesResponse, + OKXBridgeQuote, + OKXSwapQuote, +} from '@masknet/web3-providers/types' +import { dividedBy, rightShift } from '@masknet/web3-shared-base' +import { ChainId } from '@masknet/web3-shared-evm' +import type { QueryObserverResult, RefetchOptions } from '@tanstack/react-query' +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type Dispatch, + type PropsWithChildren, + type SetStateAction, +} from 'react' +import { base } from '../../../base.js' +import { DEFAULT_SLIPPAGE } from '../../constants.js' +import { useLiquidityResources } from '../hooks/useLiquidityResources.js' +import { useQuotes } from '../hooks/useQuotes.js' +import { useUsdtToken } from '../hooks/useUsdtToken.js' +import { useBridgeQuotes } from '../hooks/useBridgeQuotes.js' +import { fixBridgeMessage } from '../helpers.js' + +export type TradeMode = 'swap' | 'bridge' + +interface Options { + chainId: ChainId + setChainId: Dispatch> + nativeToken: Web3Helper.FungibleTokenAll | undefined + mode: TradeMode + setMode: Dispatch> + fromToken: Web3Helper.FungibleTokenAll | undefined + setFromToken: Dispatch> + toToken: Web3Helper.FungibleTokenAll | undefined + setToToken: Dispatch> + quote: OKXSwapQuote | undefined + isQuoteStale: boolean + isQuoteLoading: boolean + updateQuote: (options?: RefetchOptions) => Promise> + swapQuoteErrorMessage: string | undefined + inputAmount: string + setInputAmount: Dispatch> + /** + * Disabled dexId of the liquidity pool for limited quotes. + * We record disabled ones only, if no disabled ones, we cna omit the parameter + */ + disabledDexIds: string[] + setDisabledDexIds: Dispatch> + expand: boolean + setExpand: Dispatch> + slippage: string + setSlippage: Dispatch> + isAutoSlippage: boolean + setIsAutoSlippage: Dispatch> + /** Gas Limit */ + gas: string | undefined + bridgeQuote: OKXBridgeQuote | undefined + isBridgeQuoteStale: boolean + isBridgeQuoteLoading: boolean + updateBridgeQuote: (options?: RefetchOptions) => Promise> + bridgeQuoteErrorMessage: string | undefined +} + +const chainIds = base.enableRequirement.web3[NetworkPluginID.PLUGIN_EVM].supportedChainIds + +function useModeState(mode: TradeMode): [T | undefined, Dispatch>] +function useModeState(mode: TradeMode, defaultValue: T): [T, Dispatch>] + +function useModeState(mode: TradeMode, defaultValue?: T): [T | undefined, Dispatch>] { + const [map, setMap] = useState>(() => ({ + swap: defaultValue, + bridge: defaultValue, + })) + + const setValue: Dispatch> = useCallback( + (val) => { + setMap((map) => ({ + ...map, + [mode]: typeof val === 'function' ? (val as (prevState: any) => any)(map[mode]) : val, + })) + }, + [mode], + ) + + const value = map[mode] + return [value, setValue] as const +} + +const SwapContext = createContext(null!) +export function TradeProvider({ children }: PropsWithChildren) { + const { chainId: contextChainId } = useChainContext() + const [mode, setMode] = useState('swap') + const defaultChainId = chainIds.includes(contextChainId) ? contextChainId : ChainId.Mainnet + const [chainId = defaultChainId, setChainId] = useState(defaultChainId) + const { data: nativeToken } = useNativeToken(NetworkPluginID.PLUGIN_EVM, { chainId }) + + const usdtToken = useUsdtToken(chainId) + const [fromToken = nativeToken, setFromToken] = useModeState(mode) + const [toToken = fromToken?.address === usdtToken?.address ? undefined : usdtToken, setToToken] = + useModeState(mode) + + const [inputAmount, setInputAmount] = useModeState(mode, '') + const decimals = fromToken?.decimals + const amount = useMemo( + () => (inputAmount && decimals ? rightShift(inputAmount, decimals).toFixed(0) : ''), + [inputAmount, decimals], + ) + + const [disabledDexIds, setDisabledDexIds] = useModeState(mode, EMPTY_LIST) + const { data: liquidityList } = useLiquidityResources(chainId) + const dexIds = useMemo(() => { + if (!liquidityList?.length) return undefined + const allIds = liquidityList.map((x) => x.id) + if (!disabledDexIds.length) return undefined + return allIds.filter((x) => !disabledDexIds.includes(x)) + }, [disabledDexIds, liquidityList]) + + // slippage + const [isAutoSlippage, setIsAutoSlippage] = useModeState(mode, true) + const [slippage = DEFAULT_SLIPPAGE, setSlippage] = useModeState(mode) + + const { + data: quoteRes, + isStale: isQuoteStale, + isLoading: isQuoteLoading, + refetch: updateQuote, + } = useQuotes( + { + amount, + chainId: chainId.toString(), + fromTokenAddress: fromToken?.address, + dexIds: dexIds?.length ? dexIds.join(',') : undefined, + toTokenAddress: toToken?.address, + }, + mode === 'swap', + ) + const quote = quoteRes?.code === 0 ? quoteRes.data[0] : undefined + const swapQuoteErrorMessage = + quoteRes?.msg ?? + (quoteRes?.code === 0 && !quoteRes.data.length ? + t`Swaps between this token pair are’t supported at the moment. You can try with third-party DApps instead.` + : '') + + const { + data: bridgeQuoteRes, + isStale: isBridgeQuoteStale, + isLoading: isBridgeQuoteLoading, + refetch: updateBridgeQuote, + } = useBridgeQuotes( + { + fromChainId: fromToken?.chainId.toString(), + fromTokenAddress: fromToken?.address, + amount, + toChainId: toToken?.chainId.toString(), + toTokenAddress: toToken?.address, + slippage: dividedBy(slippage, 100).toFixed(), + }, + mode === 'bridge', + ) + const bridgeQuote = bridgeQuoteRes?.code === 0 ? bridgeQuoteRes.data[0] : undefined + const bridgeQuoteErrorMessage = + bridgeQuoteRes?.msg ?? + (bridgeQuoteRes?.code === 0 && !bridgeQuoteRes.data.length ? + t`Bridge between this token pair are't supported at the moment.` + : '') + + // misc, ui + const [expand, setExpand] = useModeState(mode, false) + + const value = useMemo( + () => ({ + chainId, + mode, + setMode, + setChainId, + quote, + isQuoteStale, + isQuoteLoading, + updateQuote, + nativeToken, + fromToken, + setFromToken, + toToken, + setToToken, + inputAmount, + setInputAmount, + swapQuoteErrorMessage, + disabledDexIds, + setDisabledDexIds, + expand, + setExpand, + isAutoSlippage, + setIsAutoSlippage, + slippage, + setSlippage, + gas: quote?.estimateGasFee, + bridgeQuote, + isBridgeQuoteStale, + isBridgeQuoteLoading, + updateBridgeQuote, + bridgeQuoteErrorMessage: fixBridgeMessage(bridgeQuoteErrorMessage, fromToken), + }), + [ + chainId, + mode, + quote, + isQuoteStale, + isQuoteLoading, + updateQuote, + nativeToken, + fromToken, + setFromToken, + toToken, + setToToken, + inputAmount, + setInputAmount, + swapQuoteErrorMessage, + disabledDexIds, + setDisabledDexIds, + expand, + isAutoSlippage, + setIsAutoSlippage, + slippage, + setSlippage, + bridgeQuote, + isBridgeQuoteStale, + isBridgeQuoteLoading, + updateBridgeQuote, + bridgeQuoteErrorMessage, + ], + ) + return {children} +} + +export function useSwap() { + return useContext(SwapContext) +} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/index.tsx b/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/index.tsx index a264979a7c78..d50b2aaee8f7 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/index.tsx +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/contexts/index.tsx @@ -1,14 +1,14 @@ import type { PropsWithChildren } from 'react' -import { SwapProvider } from './SwapProvider.js' +import { TradeProvider } from './TradeProvider.js' import { GasManager } from './GasManager.js' export function Providers({ children }: PropsWithChildren) { return ( - + {children} - + ) } -export * from './SwapProvider.js' +export * from './TradeProvider.js' export * from './GasManager.js' diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/helpers.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/helpers.ts new file mode 100644 index 000000000000..09e38d76338d --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/helpers.ts @@ -0,0 +1,13 @@ +import type { Web3Helper } from '@masknet/web3-helpers' +import { leftShift } from '@masknet/web3-shared-base' + +const MINIMUM_AMOUNT_RE = /(Minimum amount is\s+)(\d+)/ +export function fixBridgeMessage(message: string, token?: Web3Helper.FungibleTokenAll) { + // "Minimum amount is 1136775000000000000" + if (!message.match(MINIMUM_AMOUNT_RE)) { + return message + } + return message.replace(MINIMUM_AMOUNT_RE, (_, pre, amount: string) => { + return `${pre} ${leftShift(amount, token?.decimals ?? 0).toFixed()} ${token?.symbol ?? ''}` + }) +} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgable.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgable.ts new file mode 100644 index 000000000000..5a6b17920e5a --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgable.ts @@ -0,0 +1,25 @@ +import { t } from '@lingui/macro' +import { NetworkPluginID } from '@masknet/shared-base' +import { useFungibleTokenBalance } from '@masknet/web3-hooks-base' +import { isLessThan, rightShift } from '@masknet/web3-shared-base' +import type { ChainId } from '@masknet/web3-shared-evm' +import { useSwap } from '../contexts/index.js' + +export function useBridgable(): [result: boolean, message?: string] { + const { inputAmount, fromToken, toToken, bridgeQuote } = useSwap() + const { data: balance = '0' } = useFungibleTokenBalance(NetworkPluginID.PLUGIN_EVM, fromToken?.address, { + chainId: fromToken?.chainId as ChainId, + }) + + if (fromToken?.chainId === toToken?.chainId) return [false] + if (!inputAmount || !fromToken) return [false, t`Enter an Amount`] + + const amount = rightShift(inputAmount, fromToken.decimals) + + const symbol = fromToken.symbol + if (isLessThan(balance || 0, amount)) return [false, t`Insufficient ${symbol} Balance`] + + if (!bridgeQuote) return [false] + + return [true] +} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeData.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeData.ts new file mode 100644 index 000000000000..36a9872aa344 --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeData.ts @@ -0,0 +1,19 @@ +import { OKX } from '@masknet/web3-providers' +import type { BridgeOptions } from '@masknet/web3-providers/types' +import { skipToken, useQuery } from '@tanstack/react-query' + +export function useBridgeData(opts: Partial) { + const enabled = + opts.fromChainId && + opts.toChainId && + opts.fromTokenAddress && + opts.toTokenAddress && + opts.amount && + opts.slippage !== undefined && + opts.userWalletAddress + + return useQuery({ + queryKey: ['okx', 'get-bridge', opts], + queryFn: enabled ? async () => OKX.bridge(opts as BridgeOptions) : skipToken, + }) +} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeQuotes.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeQuotes.ts new file mode 100644 index 000000000000..feb0410cb7fb --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useBridgeQuotes.ts @@ -0,0 +1,23 @@ +import { OKX } from '@masknet/web3-providers' +import type { GetBridgeQuoteOptions } from '@masknet/web3-providers/types' +import { useQuery } from '@tanstack/react-query' +import { QUOTE_STALE_DURATION } from '../../constants.js' + +export function useBridgeQuotes(options: Partial, enabled = true) { + const valid = + options.fromChainId && + options.toChainId && + options.fromChainId !== options.toChainId && + options.amount && + options.amount !== '0' && + options.fromTokenAddress && + options.toTokenAddress && + options.slippage + + return useQuery({ + enabled: !!valid && enabled, + queryKey: ['okx-bridge', 'get-quotes', options], + queryFn: () => OKX.getBridgeQuote(options as GetBridgeQuoteOptions), + staleTime: QUOTE_STALE_DURATION, + }) +} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useGasCost.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useGasCost.ts index 2570d8e0ed04..d125c01c7d2b 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useGasCost.ts +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useGasCost.ts @@ -4,7 +4,7 @@ import { multipliedBy } from '@masknet/web3-shared-base' import { formatWeiToEther } from '@masknet/web3-shared-evm' import { type BigNumber } from 'bignumber.js' import { useMemo } from 'react' -import { useSwap } from '../contexts/SwapProvider.js' +import { useSwap } from '../contexts/TradeProvider.js' export function useGasCost(gasPrice: BigNumber.Value, gas: BigNumber.Value) { const { chainId } = useSwap() diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useLiquidityResources.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useLiquidityResources.ts index 9fb7eb1829cb..13e1bf706ce7 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useLiquidityResources.ts +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useLiquidityResources.ts @@ -2,9 +2,16 @@ import { OKX } from '@masknet/web3-providers' import type { ChainId } from '@masknet/web3-shared-evm' import { skipToken, useQuery } from '@tanstack/react-query' -export function useLiquidityResources(chainId: ChainId) { +export function useLiquidityResources(chainId: ChainId, enabled = true) { return useQuery({ + enabled, queryKey: ['okx-swap', 'liquidity', chainId], - queryFn: chainId ? async () => OKX.getLiquidity(chainId.toString()) : skipToken, + queryFn: + chainId ? + async () => { + const res = await OKX.getLiquidity(chainId.toString()) + return res?.code === 0 ? res.data : undefined + } + : skipToken, }) } diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useQuotes.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useQuotes.ts index 7c010f59fe02..463cfa2a1f8f 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useQuotes.ts +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useQuotes.ts @@ -1,13 +1,16 @@ import { OKX } from '@masknet/web3-providers' import type { GetQuotesOptions } from '@masknet/web3-providers/types' -import { skipToken, useQuery } from '@tanstack/react-query' +import { useQuery } from '@tanstack/react-query' import { QUOTE_STALE_DURATION } from '../../constants.js' +import { isGreaterThan } from '@masknet/web3-shared-base' -export function useQuotes(options: Partial) { - const enable = options.chainId && options.fromTokenAddress && options.toTokenAddress && options.amount +export function useQuotes(options: Partial, enabled = true) { + const valid = + options.chainId && options.fromTokenAddress && options.toTokenAddress && isGreaterThan(options.amount ?? 0, 0) return useQuery({ + enabled: !!valid && enabled, queryKey: ['okx-swap', 'get-quotes', options], - queryFn: enable ? () => OKX.getQuotes(options as GetQuotesOptions) : skipToken, + queryFn: () => OKX.getQuotes(options as GetQuotesOptions), staleTime: QUOTE_STALE_DURATION, }) } diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useSwappable.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useSwappable.ts index bc245c5c4bda..def7648d6006 100644 --- a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useSwappable.ts +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useSwappable.ts @@ -1,8 +1,8 @@ -import { useFungibleTokenBalance } from '@masknet/web3-hooks-base' -import { useSwap } from '../contexts/index.js' +import { t } from '@lingui/macro' import { NetworkPluginID } from '@masknet/shared-base' +import { useFungibleTokenBalance } from '@masknet/web3-hooks-base' import { isLessThan, rightShift } from '@masknet/web3-shared-base' -import { t } from '@lingui/macro' +import { useSwap } from '../contexts/index.js' export function useSwappable(): [result: boolean, message?: string] { const { inputAmount, chainId, fromToken, quote } = useSwap() diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useToken.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useToken.ts new file mode 100644 index 000000000000..bc73d7886962 --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useToken.ts @@ -0,0 +1,12 @@ +import { useOKXTokenList } from '@masknet/web3-hooks-evm' +import { isSameAddress } from '@masknet/web3-shared-base' +import type { ChainId } from '@masknet/web3-shared-evm' +import { useMemo } from 'react' + +export function useToken(chainId: ChainId | undefined, address: string | undefined) { + const { data: tokens } = useOKXTokenList(chainId as ChainId) + return useMemo(() => { + if (!tokens || !chainId) return + return tokens.find((x) => isSameAddress(x.address, address)) + }, [tokens, chainId]) +} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useTokenPrice.ts b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useTokenPrice.ts new file mode 100644 index 000000000000..f9993d3ade91 --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/hooks/useTokenPrice.ts @@ -0,0 +1,12 @@ +import { OKX } from '@masknet/web3-providers' +import type { ChainId } from '@masknet/web3-shared-evm' +import { skipToken, useQuery } from '@tanstack/react-query' + +export function useTokenPrice(chainId: ChainId | undefined, address: string | undefined) { + const enabled = !!address && !!chainId + return useQuery({ + enabled, + queryKey: ['okx', 'token-price', address, chainId], + queryFn: enabled ? () => OKX.getTokenPrice(address, chainId.toString()) : skipToken, + }) +} diff --git a/packages/plugins/Trader/src/SiteAdaptor/trader/views/BridgeConfirm.tsx b/packages/plugins/Trader/src/SiteAdaptor/trader/views/BridgeConfirm.tsx new file mode 100644 index 000000000000..45a66a403cae --- /dev/null +++ b/packages/plugins/Trader/src/SiteAdaptor/trader/views/BridgeConfirm.tsx @@ -0,0 +1,521 @@ +import { Select, t, Trans } from '@lingui/macro' +import { Icons } from '@masknet/icons' +import { LoadingStatus, NetworkIcon, PluginWalletStatusBar, ProgressiveText, TokenIcon } from '@masknet/shared' +import { NetworkPluginID } from '@masknet/shared-base' +import { ActionButton, LoadingBase, makeStyles, ShadowRootTooltip, useCustomSnackbar } from '@masknet/theme' +import { + useAccount, + useNativeTokenPrice, + useNetwork, + useNetworkDescriptor, + useWeb3Connection, +} from '@masknet/web3-hooks-base' +import { useERC20TokenApproveCallback } from '@masknet/web3-hooks-evm' +import { + dividedBy, + formatBalance, + formatCompact, + GasOptionType, + isLessThan, + leftShift, + multipliedBy, + rightShift, +} from '@masknet/web3-shared-base' +import { type ChainId, formatAmount, formatWeiToEther } from '@masknet/web3-shared-evm' +import { Box, Typography } from '@mui/material' +import { BigNumber } from 'bignumber.js' +import { memo, useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { useAsyncFn } from 'react-use' +import urlcat from 'urlcat' +import { Warning } from '../../components/Warning.js' +import { DEFAULT_SLIPPAGE, RoutePaths } from '../../constants.js' +import { addTransaction } from '../../storage.js' +import { useGasManagement, useSwap } from '../contexts/index.js' +import { useBridgeData } from '../hooks/useBridgeData.js' +import { useBridgable } from '../hooks/useBridgable.js' +import { useToken } from '../hooks/useToken.js' +import { useTokenPrice } from '../hooks/useTokenPrice.js' + +const useStyles = makeStyles()((theme) => ({ + container: { + display: 'flex', + flexDirection: 'column', + height: '100%', + boxSizing: 'border-box', + scrollbarWidth: 'none', + }, + content: { + display: 'flex', + flexDirection: 'column', + height: '100%', + overflow: 'auto', + boxSizing: 'border-box', + padding: theme.spacing(2), + scrollbarWidth: 'none', + gap: theme.spacing(1.5), + }, + pair: { + backgroundColor: theme.palette.maskColor.bg, + borderRadius: 12, + padding: theme.spacing(1.5), + }, + token: { + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.5), + }, + tokenTitle: { + fontSize: 14, + lineHeight: '18px', + fontWeight: 400, + }, + tokenIcon: { + height: 30, + width: 30, + }, + tokenInfo: { + display: 'flex', + gap: theme.spacing(1), + }, + tokenValue: { + display: 'flex', + lineHeight: '18px', + flexDirection: 'column', + }, + value: { + fontSize: 14, + fontWeight: 700, + }, + fromToken: { + fontSize: 13, + fontWeight: 400, + lineHeight: '18px', + }, + network: { + fontSize: 13, + color: theme.palette.maskColor.second, + lineHeight: '18px', + }, + toToken: { + fontSize: 14, + lineHeight: '18px', + fontWeight: 400, + color: theme.palette.maskColor.success, + }, + infoList: { + display: 'flex', + flexDirection: 'column', + gap: 10, + }, + infoRow: { + display: 'flex', + width: '100%', + alignItems: 'flex-start', + color: theme.palette.maskColor.main, + justifyContent: 'space-between', + }, + rowName: { + fontSize: 14, + display: 'flex', + gap: theme.spacing(0.5), + color: theme.palette.maskColor.second, + alignItems: 'center', + flexGrow: 1, + marginRight: 'auto', + textTransform: 'capitalize', + }, + rowValue: { + display: 'flex', + alignItems: 'center', + lineHeight: '18px', + gap: theme.spacing(0.5), + fontSize: 14, + }, + toChainIcon: { + borderRadius: '50%', + marginLeft: -8, + marginRight: theme.spacing(1), + boxShadow: '0 0 0 1px #fff', + }, + link: { + cursor: 'pointer', + textDecoration: 'none', + textAlign: 'right', + fontSize: 14, + lineHeight: '18px', + color: theme.palette.maskColor.main, + }, + text: { + fontSize: 14, + lineHeight: '18px', + color: theme.palette.maskColor.main, + }, + rotate: { + transform: 'rotate(180deg)', + }, + data: { + wordBreak: 'break-all', + fontFamily: 'monospace', + fontSize: 14, + lineHeight: '18px', + color: theme.palette.maskColor.second, + maxHeight: 60, + overflow: 'auto', + scrollbarWidth: 'none', + }, + footer: { + flexShrink: 0, + boxShadow: + theme.palette.mode === 'light' ? + '0px 0px 20px rgba(0, 0, 0, 0.05)' + : '0px 0px 20px rgba(255, 255, 255, 0.12)', + }, +})) + +export const BridgeConfirm = memo(function BridgeConfirm() { + const { classes, cx, theme } = useStyles() + const navigate = useNavigate() + const { + inputAmount, + nativeToken, + fromToken, + toToken, + chainId, + isAutoSlippage, + slippage, + quote, + bridgeQuote, + isQuoteStale, + updateQuote, + } = useSwap() + const account = useAccount(NetworkPluginID.PLUGIN_EVM) + const fromNetwork = useNetwork(NetworkPluginID.PLUGIN_EVM, fromToken?.chainId as ChainId) + const toNetwork = useNetwork(NetworkPluginID.PLUGIN_EVM, toToken?.chainId as ChainId) + const networkDescriptor = useNetworkDescriptor(NetworkPluginID.PLUGIN_EVM, chainId) + const decimals = fromToken?.decimals + const amount = useMemo( + () => (inputAmount && decimals ? rightShift(inputAmount, decimals).toFixed(0) : ''), + [inputAmount, decimals], + ) + const { data: bridgeData, isLoading } = useBridgeData({ + fromChainId: fromToken?.chainId as ChainId, + toChainId: toToken?.chainId as ChainId, + amount, + fromTokenAddress: fromToken?.address, + toTokenAddress: toToken?.address, + slippage: new BigNumber(isAutoSlippage || !slippage ? DEFAULT_SLIPPAGE : slippage).div(100).toString(), + userWalletAddress: account, + }) + const { gasFee, gasCost, gasLimit, gasConfig, gasOptions } = useGasManagement() + const gasOptionType = gasConfig.gasOptionType ?? GasOptionType.NORMAL + const [expand, setExpand] = useState(false) + const transaction = bridgeData?.data[0]?.tx + const fromToken_ = fromToken + const fromTokenAmount = bridgeData?.data[0].fromTokenAmount + const toToken_ = toToken + + const toTokenAmount = bridgeData?.data[0].toTokenAmount + + const [forwardCompare, setForwardCompare] = useState(true) + const [baseToken, targetToken] = + forwardCompare ? [quote?.fromToken, quote?.toToken] : [quote?.toToken, quote?.fromToken] + const rate = useMemo(() => { + const fromAmount = leftShift(fromTokenAmount || 0, fromToken?.decimals || 1) + const toAmount = leftShift(toTokenAmount || 0, toToken?.decimals || 1) + if (fromAmount.isZero() || toAmount.isZero()) return null + return forwardCompare ? dividedBy(toAmount, fromAmount) : dividedBy(fromAmount, toAmount) + }, [fromTokenAmount, toToken, fromToken, toToken]) + + const rateNode = + baseToken && targetToken && rate ? + <> + 1 {baseToken.tokenSymbol} ≈ {formatCompact(rate.toNumber())} {targetToken.tokenSymbol} + setForwardCompare((v) => !v)} + /> + + : null + + const [isBridgable, errorMessage] = useBridgable() + const Web3 = useWeb3Connection(NetworkPluginID.PLUGIN_EVM, { chainId }) + const [{ loading: isSending }, sendBridge] = useAsyncFn(async () => { + if (!transaction?.data) return + return Web3.sendTransaction({ + data: transaction.data, + to: transaction.to, + from: account, + value: transaction.value, + gasPrice: gasConfig.gasPrice ?? transaction.gasPrice, + gas: transaction.gasLimit, + maxPriorityFeePerGas: + 'maxPriorityFeePerGas' in gasConfig && gasConfig.maxFeePerGas ? + gasConfig.maxFeePerGas + : transaction.maxPriorityFeePerGas, + }) + }, [transaction, account, gasConfig]) + + const spender = transaction?.to + const [{ allowance }, { loading: isApproving, loadingApprove, loadingAllowance }, approve] = + useERC20TokenApproveCallback(account, amount, spender) + const notEnoughAllowance = isLessThan(allowance, amount) + const loading = isSending || isApproving || loadingApprove + const disabled = !isBridgable || loading + + const { showSnackbar } = useCustomSnackbar() + const { data: toChainNativeTokenPrice } = useNativeTokenPrice(NetworkPluginID.PLUGIN_EVM, { + chainId: toNetwork?.chainId, + }) + const toChainNetworkFee = bridgeQuote?.routerList[0]?.toChainNetworkFee + const toNetworkFeeValue = leftShift(toChainNetworkFee ?? 0, toNetwork?.nativeCurrency.decimals ?? 0) + .times(toChainNativeTokenPrice ?? 0) + .toFixed(2) + const bridge = bridgeQuote?.routerList[0] + const router = bridge?.router + const bridgeFee = router?.crossChainFee + const bridgeFeeToken = useToken(fromNetwork?.chainId, router?.crossChainFeeTokenAddress) + const { data: bridgeFeeTokenPrice } = useTokenPrice(fromNetwork?.chainId, router?.crossChainFeeTokenAddress) + const bridgeFeeValue = multipliedBy(bridgeFee ?? 0, bridgeFeeTokenPrice ?? 0).toFixed(2) + + return ( +
+
+ {bridgeData ? +
+
+ + From + +
+ +
+ + -{formatAmount(fromTokenAmount, -(fromToken_?.decimals ?? 0))}{' '} + {fromToken_?.symbol} + + {fromNetwork?.name} +
+
+
+
+ + To + +
+ +
+ + +{formatAmount(toTokenAmount, -(toToken_?.decimals ?? 0))} {toToken_?.symbol} + + {toNetwork?.name} +
+
+
+
+ : } +
+
+ + Network + + + + + + {fromNetwork?.name || '--'} to {toNetwork?.name || '--'} + + +
+
+ + {fromNetwork?.shortName} fee + + + + + + + + {`${formatWeiToEther(gasFee).toFixed(4)} ${nativeToken?.symbol ?? 'ETH'}${gasCost ? ` ≈ $${gasCost}` : ''}`} + + +