diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 105ecaaa20..91b2909252 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,8 +1,8 @@ -name: "scan 🔎" +name: 'scan 🔎' on: pull_request: - branches: [ develop, staging, master ] + branches: [develop, staging, master] schedule: # ┌───────────── minute (0 - 59) @@ -28,44 +28,44 @@ jobs: security-events: write steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - - name: Checkout repository - uses: actions/checkout@v4.0.0 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + with: + access_token: ${{ github.token }} + - name: Checkout repository + uses: actions/checkout@v4.0.0 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - # Override language selection by uncommenting this and choosing your languages - # with: - # languages: go, javascript, csharp, python, cpp, java + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + # Override language selection by uncommenting this and choosing your languages + # with: + # languages: go, javascript, csharp, python, cpp, java - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl - #- run: | - # make bootstrap - # make release + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/deploy-storybook-docs.yml b/.github/workflows/deploy-storybook-docs.yml index e941e8c1fb..59a09a6263 100644 --- a/.github/workflows/deploy-storybook-docs.yml +++ b/.github/workflows/deploy-storybook-docs.yml @@ -1,9 +1,13 @@ name: publish -on: +on: push: branches: - develop - paths: ["libs/webb-ui-components/src/stories/**", "libs/webb-ui-components/.storybook/**", ".storybook" ] # Trigger the action only when files change in the folders defined here + paths: [ + 'libs/webb-ui-components/src/stories/**', + 'libs/webb-ui-components/.storybook/**', + '.storybook', + ] # Trigger the action only when files change in the folders defined here jobs: component-docs: runs-on: ubuntu-latest @@ -22,4 +26,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BRANCH: gh-pages # The branch the action should deploy to. FOLDER: ~/work/webb-dapp/webb-dapp/dist/storybook/webb-ui-components/ # The folder that the build-storybook script generates files. - CLEAN: true # Automatically remove deleted files from the deploy branch \ No newline at end of file + CLEAN: true # Automatically remove deleted files from the deploy branch diff --git a/.github/workflows/ui-review.yml b/.github/workflows/ui-review.yml index 76fefdbaae..c72f95d079 100644 --- a/.github/workflows/ui-review.yml +++ b/.github/workflows/ui-review.yml @@ -6,7 +6,7 @@ name: 'visual ✨' on: pull_request: # types: [opened, synchronize, reopened, ready_for_review] - branches: [ develop ] + branches: [develop] workflow_dispatch: @@ -32,4 +32,4 @@ jobs: # 👇 Chromatic projectToken, refer to the manage page to obtain it. projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} buildScriptName: 'build:storybook' - exitZeroOnChanges: true # 👈 Option to prevent the workflow from failing \ No newline at end of file + exitZeroOnChanges: true # 👈 Option to prevent the workflow from failing diff --git a/.storybook/main.js b/.storybook/main.js index ca58d7a612..c70ab28be9 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -7,7 +7,6 @@ module.exports = { 'storybook-addon-react-router-v6', '@storybook/theming', '@storybook/addon-a11y', - 'storybook-tailwind-dark-mode', ], features: { interactionsDebugger: true, diff --git a/apps/bridge-dapp/.eslintrc.json b/apps/bridge-dapp/.eslintrc.json index d5c5bb0f44..94150e9597 100644 --- a/apps/bridge-dapp/.eslintrc.json +++ b/apps/bridge-dapp/.eslintrc.json @@ -1,6 +1,11 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": [ + "plugin:@nx/react", + "../../.eslintrc.json", + "plugin:react-hooks/recommended" + ], "ignorePatterns": ["!**/*", "node_modules/**", "build/**"], + "plugins": ["unused-imports"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], @@ -14,5 +19,9 @@ "files": ["*.js", "*.jsx"], "rules": {} } - ] + ], + "rules": { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "error" + } } diff --git a/apps/bridge-dapp/src/App.tsx b/apps/bridge-dapp/src/App.tsx index f4847a08db..ffa3f4e679 100644 --- a/apps/bridge-dapp/src/App.tsx +++ b/apps/bridge-dapp/src/App.tsx @@ -1,14 +1,8 @@ -import { - AppEvent, - RouterProvider, - WebbProvider, -} from '@webb-tools/api-provider-environment'; -import { FC } from 'react'; - -import { WebbUIProvider } from '@webb-tools/webb-ui-components'; -import { config as routerConfig } from './routes'; - import * as Sentry from '@sentry/react'; +import { AppEvent, WebbProvider } from '@webb-tools/api-provider-environment'; +import { WebbUIProvider } from '@webb-tools/webb-ui-components'; +import { FC } from 'react'; +import BridgeRoutes from './routes'; // Singleton app event instance export const appEvent = new AppEvent(); @@ -17,7 +11,7 @@ const App: FC = () => { return ( - + ); diff --git a/apps/bridge-dapp/src/components/ChainListCardWrapper/ChainListCardWrapper.tsx b/apps/bridge-dapp/src/components/ChainListCardWrapper/ChainListCardWrapper.tsx index 91cb3b6280..e3520c4416 100644 --- a/apps/bridge-dapp/src/components/ChainListCardWrapper/ChainListCardWrapper.tsx +++ b/apps/bridge-dapp/src/components/ChainListCardWrapper/ChainListCardWrapper.tsx @@ -1,12 +1,11 @@ import { useWebContext } from '@webb-tools/api-provider-environment'; import { calculateTypedChainId } from '@webb-tools/sdk-core'; import { ChainListCard, useWebbUI } from '@webb-tools/webb-ui-components'; +import { ChainType } from '@webb-tools/webb-ui-components/components/ListCard/types'; import { FC, useCallback, useMemo } from 'react'; import { useConnectWallet } from '../../hooks'; -import { ChainListCardWrapperProps } from './types'; -import { getNativeCurrencyFromConfig } from '@webb-tools/dapp-config'; import { getActiveSourceChains } from '../../utils/getActiveSourceChains'; -import { Bridge } from '@webb-tools/abstract-api-provider'; +import { ChainListCardWrapperProps } from './types'; /** * The wrapper component for the ChainListCard component @@ -53,16 +52,10 @@ export const ChainListCardWrapper: FC = ({ if (chainsProps) return chainsProps; return getActiveSourceChains(apiConfig.chains).map((val) => { - const currency = getNativeCurrencyFromConfig( - apiConfig.currencies, - calculateTypedChainId(val.chainType, val.id) - ); - return { name: val.name, tag: val.tag, - symbol: currency?.symbol ?? 'Unknown', - }; + } satisfies ChainType; }); }, [apiConfig, chainsProps]); @@ -87,13 +80,6 @@ export const ChainListCardWrapper: FC = ({ calculateTypedChainId(chain.chainType, chain.id) ); - let bridge: Bridge | undefined; - const bridgeConfig = - fungibleCurrency && apiConfig.bridgeByAsset[fungibleCurrency.id]; - if (bridgeConfig) { - bridge = new Bridge(fungibleCurrency, bridgeConfig.anchors); - } - // If the selected chain is supported by the active wallet if (isSupported) { await switchChain(chain, activeWallet); @@ -105,9 +91,7 @@ export const ChainListCardWrapper: FC = ({ }, [ activeWallet, - apiConfig.bridgeByAsset, chainsConfig, - fungibleCurrency, onChange, setMainComponent, switchChain, diff --git a/apps/bridge-dapp/src/components/Header/ChainSwitcherButton.tsx b/apps/bridge-dapp/src/components/Header/ChainSwitcherButton.tsx deleted file mode 100644 index 85b7e23c33..0000000000 --- a/apps/bridge-dapp/src/components/Header/ChainSwitcherButton.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { ChevronDownIcon } from '@radix-ui/react-icons'; -import { useWebContext } from '@webb-tools/api-provider-environment'; -import { ChainIcon } from '@webb-tools/icons'; -import { Typography, useWebbUI } from '@webb-tools/webb-ui-components'; -import cx from 'classnames'; -import { FC } from 'react'; -import { ChainListCardWrapper } from '../ChainListCardWrapper'; -import { HeaderButton } from './HeaderButton'; - -/** - * The ChainSwitcherButton defines the clickable button in the Header, - * as well as the displayable component passed to the WebbUI's special "customMainComponent" - */ -export const ChainSwitcherButton: FC = () => { - const { activeChain } = useWebContext(); - const { setMainComponent } = useWebbUI(); - - return ( - setMainComponent()} - > - - - - {activeChain?.name} - - - - - ); -}; diff --git a/apps/bridge-dapp/src/components/Header/Header.tsx b/apps/bridge-dapp/src/components/Header/Header.tsx index 90ad40a357..797fe1870f 100644 --- a/apps/bridge-dapp/src/components/Header/Header.tsx +++ b/apps/bridge-dapp/src/components/Header/Header.tsx @@ -1,19 +1,23 @@ import { useWebContext } from '@webb-tools/api-provider-environment'; +import { ContrastTwoLine } from '@webb-tools/icons'; import { Breadcrumbs, BreadcrumbsItem, Button, + ChainButton, NavigationMenu, NavigationMenuContent, NavigationMenuTrigger, + SideBarMenu, } from '@webb-tools/webb-ui-components'; import { FC, useCallback, useMemo } from 'react'; import { NavLink, useLocation } from 'react-router-dom'; -import { useConnectWallet } from '../../hooks'; -import { ChainSwitcherButton } from './ChainSwitcherButton'; -import { WalletButton } from './WalletButton'; +import { BRIDGE_PATH, SELECT_SOURCE_CHAIN_PATH } from '../../constants'; +import sidebarProps from '../../constants/sidebar'; +import { useConnectWallet, useNavigateWithPersistParams } from '../../hooks'; +import TxProgressDropdown from './TxProgressDropdown'; +import { WalletDropdown } from './WalletDropdown'; import { HeaderProps } from './types'; -import { ContrastTwoLine } from '@webb-tools/icons'; /** * The statistic `Header` for `Layout` container @@ -21,6 +25,8 @@ import { ContrastTwoLine } from '@webb-tools/icons'; export const Header: FC = () => { const { activeAccount, activeWallet, activeChain, loading } = useWebContext(); + const navigate = useNavigateWithPersistParams(); + const { toggleModal } = useConnectWallet(); // On connect wallet button click - connect to the default chain(ETH Goerli) @@ -34,70 +40,93 @@ export const Header: FC = () => { [activeAccount, activeChain, activeWallet, loading] ); + const location = useLocation(); + + const items = location.pathname.split('/').filter((item) => item !== ''); + return ( -
-
+
+
+ - - } - className="!pl-0" - > - Hubble - - - Bridge + {items.map((item, index) => { + return ( + + : undefined} + className="capitalize" + > + {index === 0 ? `Hubble ${item}` : item.split('-').join(' ')} + + + ); + })} +
-
- {/** Wallet is actived */} - {isDisplayNetworkSwitcherAndWalletButton && - activeAccount && - activeWallet ? ( -
- - -
- ) : ( - - )} +
+ - - - {/** TODO: Refactor these links into a config file and make the menu items dynamically based on the config */} - - window.open( - 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Ftangle-standalone-archive.webb.tools%2F#/explorer', - '_blank' - ) - } - onFaucetClick={() => { - window.open('https://faucet.webb.tools/', '_blank'); - }} - onHelpCenterClick={() => - window.open('https://t.me/webbprotocol', '_blank') - } - onRequestFeaturesClick={() => - window.open( - 'https://github.com/webb-tools/webb-dapp/issues/new?assignees=&labels=&template=feature_request.md&title=', - '_blank' - ) - } - onAboutClick={() => - window.open('https://www.webb.tools/', '_blank') + {/** Wallet is actived */} + {isDisplayNetworkSwitcherAndWalletButton && + activeAccount && + activeWallet && + activeChain ? ( +
+ + navigate(`/${BRIDGE_PATH}/${SELECT_SOURCE_CHAIN_PATH}`) } /> - -
+ +
+ ) : ( + + )} + + + + {/** TODO: Refactor these links into a config file and make the menu items dynamically based on the config */} + + window.open( + 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Ftangle-standalone-archive.webb.tools%2F#/explorer', + '_blank' + ) + } + onFaucetClick={() => { + window.open('https://faucet.webb.tools/', '_blank'); + }} + onHelpCenterClick={() => + window.open('https://t.me/webbprotocol', '_blank') + } + onRequestFeaturesClick={() => + window.open( + 'https://github.com/webb-tools/webb-dapp/issues/new?assignees=&labels=&template=feature_request.md&title=', + '_blank' + ) + } + onAboutClick={() => + window.open('https://www.webb.tools/', '_blank') + } + /> +
); diff --git a/apps/bridge-dapp/src/components/Header/HeaderButton.tsx b/apps/bridge-dapp/src/components/Header/HeaderButton.tsx deleted file mode 100644 index e3565d101b..0000000000 --- a/apps/bridge-dapp/src/components/Header/HeaderButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PropsOf } from '@webb-tools/webb-ui-components/types'; -import { forwardRef } from 'react'; -import { twMerge } from 'tailwind-merge'; - -export const HeaderButton = forwardRef>( - ({ className, ...props }, ref) => ( - + ) : 'txHash' in props ? ( + + ) : null} +
+ + + + + + + ); +}; + +export default SubmittedTxModal; diff --git a/apps/bridge-dapp/src/components/SubmittedTxModal/index.ts b/apps/bridge-dapp/src/components/SubmittedTxModal/index.ts new file mode 100644 index 0000000000..24e7cd64fe --- /dev/null +++ b/apps/bridge-dapp/src/components/SubmittedTxModal/index.ts @@ -0,0 +1,3 @@ +import SubmittedTxModal from './SubmittedTxModal'; + +export default SubmittedTxModal; diff --git a/apps/bridge-dapp/src/components/SubmittedTxModal/types.ts b/apps/bridge-dapp/src/components/SubmittedTxModal/types.ts new file mode 100644 index 0000000000..defbeef076 --- /dev/null +++ b/apps/bridge-dapp/src/components/SubmittedTxModal/types.ts @@ -0,0 +1,27 @@ +import { BRIDGE_TABS } from '../../constants'; + +interface Props { + /** + * The transaction type to display in the modal + * if not provided, the modal will display the default + * transaction type + * @default 'transaction' + */ + txType?: (typeof BRIDGE_TABS)[number]; +} + +interface PropsWithExplorerUrl extends Props { + /** + * Explorer url to the transaction + */ + txExplorerUrl: URL; +} + +interface PropsWithTxHash extends Props { + /** + * The transaction hash + */ + txHash: string; +} + +export type SubmittedTxModalProps = PropsWithExplorerUrl | PropsWithTxHash; diff --git a/apps/bridge-dapp/src/components/TxInfoItem/TxInfoItem.tsx b/apps/bridge-dapp/src/components/TxInfoItem/TxInfoItem.tsx new file mode 100644 index 0000000000..894db85c64 --- /dev/null +++ b/apps/bridge-dapp/src/components/TxInfoItem/TxInfoItem.tsx @@ -0,0 +1,49 @@ +import { CornerDownRightLine } from '@webb-tools/icons/CornerDownRightLine'; +import { TitleWithInfo, Typography } from '@webb-tools/webb-ui-components'; +import { cloneElement, forwardRef } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { TxInfoItemProps } from './types'; + +const TxInfoItem = forwardRef, TxInfoItemProps>( + ({ leftContent, rightIcon, rightText, className, ...props }, ref) => { + return ( +
+
+ + + +
+ +
+ {rightIcon && + cloneElement(rightIcon, { + ...rightIcon.props, + className: twMerge('!fill-current', rightIcon.props.className), + })} + + {rightText && ( + + {rightText} + + )} +
+
+ ); + } +); + +export default TxInfoItem; diff --git a/apps/bridge-dapp/src/components/TxInfoItem/index.ts b/apps/bridge-dapp/src/components/TxInfoItem/index.ts new file mode 100644 index 0000000000..7a38734abb --- /dev/null +++ b/apps/bridge-dapp/src/components/TxInfoItem/index.ts @@ -0,0 +1,3 @@ +import TxInfoItem from './TxInfoItem'; + +export default TxInfoItem; diff --git a/apps/bridge-dapp/src/components/TxInfoItem/types.ts b/apps/bridge-dapp/src/components/TxInfoItem/types.ts new file mode 100644 index 0000000000..2835e65094 --- /dev/null +++ b/apps/bridge-dapp/src/components/TxInfoItem/types.ts @@ -0,0 +1,22 @@ +import { IconBase } from '@webb-tools/icons/types'; +import { TitleWithInfo } from '@webb-tools/webb-ui-components'; +import { PropsOf } from '@webb-tools/webb-ui-components/types'; +import { ComponentProps } from 'react'; + +export interface TxInfoItemProps extends PropsOf<'div'> { + /** + * The props of TitleWithInfo component to render + * the title with tooltip info. + */ + leftContent: ComponentProps; + + /** + * The right icon of the item. + */ + rightIcon?: React.ReactElement; + + /** + * The right text to display. + */ + rightText: string; +} diff --git a/apps/bridge-dapp/src/components/index.ts b/apps/bridge-dapp/src/components/index.ts index 59f18076c7..20fa2b6f50 100644 --- a/apps/bridge-dapp/src/components/index.ts +++ b/apps/bridge-dapp/src/components/index.ts @@ -2,3 +2,6 @@ export * from './ChainListCardWrapper'; export * from './EducationCard'; export * from './Header'; export * from './InteractiveFeedbackView'; +export { default as SlideAnimation } from './SlideAnimation'; +export { default as SubmittedTxModal } from './SubmittedTxModal'; +export { default as TxInfoItem } from './TxInfoItem'; diff --git a/apps/bridge-dapp/src/constants/index.ts b/apps/bridge-dapp/src/constants/index.ts index f7b251a5a5..fd3cc38110 100644 --- a/apps/bridge-dapp/src/constants/index.ts +++ b/apps/bridge-dapp/src/constants/index.ts @@ -1,3 +1,58 @@ export * from '@webb-tools/webb-ui-components/constants'; export * from './signIn'; +export * from './links'; + +export const BRIDGE_PATH = 'bridge'; +export const WRAP_UNWRAP_PATH = 'wrap-unwrap'; +export const NOTE_ACCOUNT_PATH = 'account'; +export const ECOSYSTEM_PATH = 'ecosystem'; + +export const DEPOSIT_PATH = 'deposit'; +export const TRANSFER_PATH = 'transfer'; +export const WITHDRAW_PATH = 'withdraw'; + +export const SELECT_SOURCE_CHAIN_PATH = 'select-source-chain'; +export const SELECT_DESTINATION_CHAIN_PATH = 'select-destination-chain'; +export const SELECT_TOKEN_PATH = 'select-token'; +export const SELECT_SHIELDED_POOL_PATH = 'select-shielded-pool'; +export const SELECT_RELAYER_PATH = 'select-relayer'; + +/** Key for source chain query params */ +export const SOURCE_CHAIN_KEY = 'source'; + +/** Key for destination chain query params */ +export const DEST_CHAIN_KEY = 'dest'; + +/** Key for fungible currency query param */ +export const TOKEN_KEY = 'token'; + +/** Key for wrappable currency query param */ +export const POOL_KEY = 'pool'; + +/** Key for fixed amount or custom amount */ +export const IS_CUSTOM_AMOUNT_KEY = 'isCustomAmount'; + +/** Key for transaction amount query param */ +export const AMOUNT_KEY = 'amount'; + +/** Key for has reund query param */ +export const HAS_REFUND_KEY = 'hasRefund'; + +/** Key for refund recipient query param */ +export const REFUND_RECIPIENT_KEY = 'refundRecipient'; + +/** Key for recipient query param */ +export const RECIPIENT_KEY = 'recipient'; + +/** Key for no relayer query params */ +export const NO_RELAYER_KEY = 'noRelayer'; + +/** Key for relayer endpoint query param */ +export const RELAYER_ENDPOINT_KEY = 'relayer'; + +export const BRIDGE_TABS = [ + DEPOSIT_PATH, + TRANSFER_PATH, + WITHDRAW_PATH, +] as const; diff --git a/apps/bridge-dapp/src/constants/links.ts b/apps/bridge-dapp/src/constants/links.ts new file mode 100644 index 0000000000..03061edb70 --- /dev/null +++ b/apps/bridge-dapp/src/constants/links.ts @@ -0,0 +1,2 @@ +export const NOTE_ACCOUNT_DOCS_URL = + 'https://docs.webb.tools/docs/projects/hubble-bridge/usage-guide/account/'; diff --git a/apps/bridge-dapp/src/constants/sidebar.ts b/apps/bridge-dapp/src/constants/sidebar.ts new file mode 100644 index 0000000000..7c4bf73712 --- /dev/null +++ b/apps/bridge-dapp/src/constants/sidebar.ts @@ -0,0 +1,73 @@ +import { ContrastTwoLine } from '@webb-tools/icons/ContrastTwoLine'; +import { DocumentationIcon } from '@webb-tools/icons/DocumentationIcon'; +import { Tangle } from '@webb-tools/icons/Tangle'; +import { + type SideBarFooterType, + type SideBarItemProps, + type SidebarProps, +} from '@webb-tools/webb-ui-components'; +import { Logo } from '@webb-tools/webb-ui-components/components/Logo'; +import { LogoWithoutName } from '@webb-tools/webb-ui-components/components/LogoWithoutName'; +import { + STATS_URL, + TANGLE_MKT_URL, + WEBB_DOCS_URL, + WEBB_FAUCET_URL, + WEBB_MKT_URL, +} from '@webb-tools/webb-ui-components/constants'; + +const items: SideBarItemProps[] = [ + { + name: 'Hubble', + isInternal: true, + href: '', + Icon: ContrastTwoLine, + subItems: [ + { + name: 'Bridge', + isInternal: true, + href: '/bridge', + }, + { + name: 'Faucet', + isInternal: false, + href: WEBB_FAUCET_URL, + }, + ], + }, + { + name: 'Tangle Network', + isInternal: false, + href: '', + Icon: Tangle, + subItems: [ + { + name: 'DKG Explorer', + isInternal: false, + href: STATS_URL, + }, + { + name: 'Homepage', + isInternal: false, + href: TANGLE_MKT_URL, + }, + ], + }, +]; + +const footer: SideBarFooterType = { + name: 'Webb Docs', + isInternal: false, + href: WEBB_DOCS_URL, + Icon: DocumentationIcon, +}; + +const sidebar: SidebarProps = { + items: items, + Logo: Logo, + ClosedLogo: LogoWithoutName, + logoLink: WEBB_MKT_URL, + footer: footer, +}; + +export default sidebar; diff --git a/apps/bridge-dapp/src/constants/tooltipContent.ts b/apps/bridge-dapp/src/constants/tooltipContent.ts new file mode 100644 index 0000000000..5583624899 --- /dev/null +++ b/apps/bridge-dapp/src/constants/tooltipContent.ts @@ -0,0 +1,5 @@ +export const FIXED_AMOUNT_TOOLTIP_CONTENT = + 'Fixed amounts improve both pool and user privacy.'; + +export const CUSTOM_AMOUNT_TOOLTIP_CONTENT = + 'Custom amounts offer more flexibility but are not as private.'; diff --git a/apps/bridge-dapp/src/containers/BridgeTabsContainer/BridgeTabsContainer.tsx b/apps/bridge-dapp/src/containers/BridgeTabsContainer/BridgeTabsContainer.tsx new file mode 100644 index 0000000000..aa7dfe7f70 --- /dev/null +++ b/apps/bridge-dapp/src/containers/BridgeTabsContainer/BridgeTabsContainer.tsx @@ -0,0 +1,65 @@ +import SettingsFillIcon from '@webb-tools/icons/SettingsFillIcon'; +import cx from 'classnames'; +import { FC } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { twMerge } from 'tailwind-merge'; +import { BRIDGE_TABS } from '../../constants'; +import { BridgeTabsContainerProps } from './types'; +import { IconButton } from '@webb-tools/webb-ui-components'; + +const BridgeTabsContainer: FC = ({ + children, + settingBtnProps, + className, + ...props +}) => { + const { pathname } = useLocation(); + + // Find active tab from pathname + const activeTab = pathname + .split('/') + .find((path) => !!BRIDGE_TABS.find((tab) => tab === path)); + + return ( +
+
    + {BRIDGE_TABS.map((tab, idx) => ( +
  • + + {`${tab[0].toUpperCase()}${tab.substring(1)}`} + +
  • + ))} + +
  • + + + +
  • +
+ + {children} +
+ ); +}; + +export default BridgeTabsContainer; diff --git a/apps/bridge-dapp/src/containers/BridgeTabsContainer/index.ts b/apps/bridge-dapp/src/containers/BridgeTabsContainer/index.ts new file mode 100644 index 0000000000..9782f2a89d --- /dev/null +++ b/apps/bridge-dapp/src/containers/BridgeTabsContainer/index.ts @@ -0,0 +1,3 @@ +import BridgeTabsContainer from './BridgeTabsContainer'; + +export default BridgeTabsContainer; diff --git a/apps/bridge-dapp/src/containers/BridgeTabsContainer/types.ts b/apps/bridge-dapp/src/containers/BridgeTabsContainer/types.ts new file mode 100644 index 0000000000..ff52b84760 --- /dev/null +++ b/apps/bridge-dapp/src/containers/BridgeTabsContainer/types.ts @@ -0,0 +1,8 @@ +import { PropsOf } from '@webb-tools/webb-ui-components/types'; + +export interface BridgeTabsContainerProps extends PropsOf<'div'> { + /** + * The props of the setting button. + */ + settingBtnProps?: PropsOf<'button'>; +} diff --git a/apps/bridge-dapp/src/containers/CreateAccountModal/CreateAccountModal.tsx b/apps/bridge-dapp/src/containers/CreateAccountModal/CreateAccountModal.tsx index 2a9f573bd7..1583c46b4f 100644 --- a/apps/bridge-dapp/src/containers/CreateAccountModal/CreateAccountModal.tsx +++ b/apps/bridge-dapp/src/containers/CreateAccountModal/CreateAccountModal.tsx @@ -19,6 +19,7 @@ import { import cx from 'classnames'; import Lottie from 'lottie-react'; import { FC, useCallback, useState } from 'react'; +import { NOTE_ACCOUNT_DOCS_URL } from '../../constants/links'; import { createSignInMessage } from '../../constants/signIn'; import congratsJson from './congrats.json'; import privacySecurityJson from './privacy-security.json'; @@ -185,7 +186,7 @@ export const CreateAccountModal: FC = ({ + + } + /> + + ); +}; + +export default SelectRelayer; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/SelectToken.tsx b/apps/bridge-dapp/src/pages/Hubble/Bridge/SelectToken.tsx new file mode 100644 index 0000000000..d66220228d --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/SelectToken.tsx @@ -0,0 +1,207 @@ +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; +import { CurrencyConfig } from '@webb-tools/dapp-config/currencies/currency-config.interface'; +import { CurrencyRole } from '@webb-tools/dapp-types/Currency'; +import { useCurrenciesBalances } from '@webb-tools/react-hooks'; +import { TokenListCard } from '@webb-tools/webb-ui-components'; +import { AssetType } from '@webb-tools/webb-ui-components/components/ListCard/types'; +import { FC, useCallback, useMemo } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import SlideAnimation from '../../../components/SlideAnimation'; +import { + BRIDGE_TABS, + DEST_CHAIN_KEY, + POOL_KEY, + SOURCE_CHAIN_KEY, + TOKEN_KEY, +} from '../../../constants'; +import useCurrenciesFromRoute from '../../../hooks/useCurrenciesFromRoute'; + +const SelectToken: FC = () => { + const [searhParams] = useSearchParams(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + + const currentTxType = useMemo(() => { + return BRIDGE_TABS.find((tab) => pathname.includes(tab)); + }, [pathname]); + + const { apiConfig } = useWebContext(); + + const [srcTypedChainId, destTypedChainId] = useMemo(() => { + const srcTypedId = searhParams.get(SOURCE_CHAIN_KEY) + ? Number(searhParams.get(SOURCE_CHAIN_KEY)) + : undefined; + + const destTypedId = searhParams.get(DEST_CHAIN_KEY) + ? Number(searhParams.get(DEST_CHAIN_KEY)) + : undefined; + + return [srcTypedId, destTypedId]; + }, [searhParams]); + + const [srcChainCfg, destChainCfg] = useMemo(() => { + const srcChainCfg = + typeof srcTypedChainId === 'number' + ? apiConfig.chains[srcTypedChainId] + : undefined; + + const destChainCfg = + typeof destTypedChainId === 'number' + ? apiConfig.chains[destTypedChainId] + : undefined; + + return [srcChainCfg, destChainCfg]; + }, [apiConfig.chains, destTypedChainId, srcTypedChainId]); + + const blockExplorer = useMemo(() => { + if (currentTxType === 'deposit' && srcChainCfg) { + return srcChainCfg.blockExplorers?.default.url; + } else if (currentTxType === 'withdraw' && destChainCfg) { + return destChainCfg.blockExplorers?.default.url; + } + }, [currentTxType, destChainCfg, srcChainCfg]); + + const { allCurrencies, allCurrencyCfgs } = useCurrenciesFromRoute( + currentTxType === 'deposit' ? srcTypedChainId : destTypedChainId + ); + + const fungibleAddress = useMemo(() => { + const poolId = searhParams.get(POOL_KEY); + if (!poolId) { + return; + } + + const fungible = apiConfig.currencies[Number(poolId)]; + if (!fungible) { + return; + } + + if (typeof destTypedChainId === 'number') { + return fungible.addresses.get(destTypedChainId); + } + + return undefined; + }, [apiConfig.currencies, destTypedChainId, searhParams]); + + const { balances, isLoading: isBalancesLoading } = useCurrenciesBalances( + allCurrencies, + currentTxType === 'withdraw' ? destTypedChainId : srcTypedChainId, + currentTxType === 'withdraw' ? fungibleAddress : undefined + ); + + const selectTokens = useMemo>( + () => + allCurrencyCfgs.map((currencyCfg) => { + const balanceProps = getBalanceProps( + currencyCfg, + balances, + isBalancesLoading, + currentTxType + ); + + const badgeProps = getBadgeProps( + currencyCfg, + balances, + isBalancesLoading, + currentTxType + ); + + const address = getAddress(currencyCfg, srcTypedChainId); + const explorerUrl = getExplorerUrl(address, blockExplorer); + + return { + name: currencyCfg.name, + symbol: currencyCfg.symbol, + tokenType: 'unshielded', + explorerUrl: explorerUrl, + assetBalanceProps: balanceProps, + assetBadgeProps: badgeProps, + } satisfies AssetType; + }), + // prettier-ignore + [allCurrencyCfgs, balances, blockExplorer, currentTxType, isBalancesLoading, srcTypedChainId] + ); + + const handleClose = useCallback( + (selectedCfg?: CurrencyConfig) => { + const params = new URLSearchParams(searhParams); + if (selectedCfg) { + params.set(TOKEN_KEY, `${selectedCfg.id}`); + } + + const path = pathname.split('/').slice(0, -1).join('/'); + navigate({ + pathname: path, + search: params.toString(), + }); + }, + [navigate, pathname, searhParams] + ); + + const handleTokenChange = useCallback( + ({ name, symbol }: AssetType) => { + const currencyCfg = Object.values(apiConfig.currencies).find( + (cfg) => cfg.name === name && cfg.symbol === symbol + ); + + handleClose(currencyCfg); + }, + [apiConfig.currencies, handleClose] + ); + + return ( + + handleClose()} + txnType={currentTxType} + /> + + ); +}; + +export default SelectToken; + +const getAddress = (currencyCfg: CurrencyConfig, srcTypedChainId?: number) => + typeof srcTypedChainId === 'number' + ? currencyCfg.addresses.get(srcTypedChainId) + : undefined; + +const getBalanceProps = ( + currencyCfg: CurrencyConfig, + balances: Record, + isLoading?: boolean, + txType?: string +) => + !isLoading && + balances[currencyCfg.id] && + (txType !== 'withdraw' || currencyCfg.role !== CurrencyRole.Governable) + ? { balance: balances[currencyCfg.id] } + : undefined; + +const getBadgeProps = ( + currencyCfg: CurrencyConfig, + balances: Record, + isLoading?: boolean, + txType?: string +) => + !isLoading && + !balances[currencyCfg.id] && + (txType !== 'withdraw' || currencyCfg.role !== CurrencyRole.Governable) + ? { + variant: 'warning' as const, + children: + txType === 'withdraw' ? 'Insufficient liquidity' : 'No balance', + } + : undefined; + +const getExplorerUrl = (addr?: string, blockExplorer?: string) => + blockExplorer && addr && BigInt(addr) !== ZERO_BIG_INT + ? new URL(`/address${addr}`, blockExplorer).toString() + : undefined; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/index.tsx b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/index.tsx new file mode 100644 index 0000000000..ff1da91c19 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/index.tsx @@ -0,0 +1,576 @@ +import { Transition } from '@headlessui/react'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import { + AccountCircleLineIcon, + ArrowRight, + ClipboardLineIcon, + FileCopyLine, + GasStationFill, + SettingsFillIcon, +} from '@webb-tools/icons'; +import { useBalancesFromNotes } from '@webb-tools/react-hooks/currency/useBalancesFromNotes'; +import { calculateTypedChainId } from '@webb-tools/sdk-core/typed-chain-id'; +import { + Button, + ConnectWalletMobileButton, + FeeDetails, + IconWithTooltip, + TextField, + TitleWithInfo, + ToggleCard, + TransactionInputCard, + useCheckMobile, + useCopyable, + useWebbUI, +} from '@webb-tools/webb-ui-components'; +import { FeeItem } from '@webb-tools/webb-ui-components/components/FeeDetails/types'; +import { FC, useCallback, useEffect, useMemo } from 'react'; +import { Outlet, useLocation } from 'react-router'; +import { useSearchParams } from 'react-router-dom'; +import { formatEther, parseEther } from 'viem'; +import SlideAnimation from '../../../../components/SlideAnimation'; +import { + BRIDGE_TABS, + DEST_CHAIN_KEY, + POOL_KEY, + SELECT_DESTINATION_CHAIN_PATH, + SELECT_RELAYER_PATH, + SELECT_SHIELDED_POOL_PATH, + SELECT_SOURCE_CHAIN_PATH, + SOURCE_CHAIN_KEY, +} from '../../../../constants'; +import BridgeTabsContainer from '../../../../containers/BridgeTabsContainer'; +import TxInfoContainer from '../../../../containers/TxInfoContainer'; +import useNavigateWithPersistParams from '../../../../hooks/useNavigateWithPersistParams'; +import useFeeCalculation from './private/useFeeCalculation'; +import useInputs from './private/useInputs'; +import useRelayerWithRoute from './private/useRelayerWithRoute'; +import useTransferButtonProps from './private/useTransferButtonProps'; + +const Transfer = () => { + const { pathname } = useLocation(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const { balances, initialized } = useBalancesFromNotes(); + + const navigate = useNavigateWithPersistParams(); + + const { isMobile } = useCheckMobile(); + + const { + apiConfig, + activeApi, + activeChain, + activeAccount, + loading, + isConnecting, + noteManager, + } = useWebContext(); + + const { notificationApi } = useWebbUI(); + + const { + amount, + hasRefund, + recipient, + refundRecipient, + setRefundRecipient, + refundRecipientErrorMsg, + recipientErrorMsg, + setAmount, + setHasRefund, + setRecipient, + } = useInputs(); + + const { activeRelayer } = useRelayerWithRoute(); + + const [srcTypedChainId, destTypedChainId, poolId] = useMemo(() => { + const srcTypedId = parseInt(searchParams.get(SOURCE_CHAIN_KEY) ?? ''); + const destTypedId = parseInt(searchParams.get(DEST_CHAIN_KEY) ?? ''); + + const poolId = parseInt(searchParams.get(POOL_KEY) ?? ''); + + return [ + Number.isNaN(srcTypedId) ? undefined : srcTypedId, + Number.isNaN(destTypedId) ? undefined : destTypedId, + Number.isNaN(poolId) ? undefined : poolId, + ]; + }, [searchParams]); + + const [srcChainCfg, destChainCfg] = useMemo(() => { + const src = + typeof srcTypedChainId === 'number' + ? apiConfig.chains[srcTypedChainId] + : undefined; + + const dest = + typeof destTypedChainId === 'number' + ? apiConfig.chains[destTypedChainId] + : undefined; + + return [src, dest]; + }, [apiConfig.chains, destTypedChainId, srcTypedChainId]); + + const fungibleCfg = useMemo(() => { + return typeof poolId === 'number' + ? apiConfig.currencies[poolId] + : undefined; + }, [poolId, apiConfig.currencies]); + + const fungibleMaxAmount = useMemo(() => { + if (!srcTypedChainId) { + return; + } + + if (fungibleCfg && balances[fungibleCfg.id]?.[srcTypedChainId]) { + return Number(formatEther(balances[fungibleCfg.id][srcTypedChainId])); + } + }, [balances, fungibleCfg, srcTypedChainId]); + + const activeBridge = useMemo(() => { + return activeApi?.state.activeBridge; + }, [activeApi?.state.activeBridge]); + + // Set default poolId and destTypedChainId on first render + useEffect( + () => { + if (loading || isConnecting || !initialized) { + return; + } + + if (typeof srcTypedChainId === 'number' && typeof poolId === 'number') { + return; + } + + const entries = Object.entries(balances); + if (entries.length > 0) { + // Find first pool & destTypedChainId from balances + const [currencyId, balanceRecord] = entries[0]; + const [typedChainId] = Object.entries(balanceRecord)?.[0] ?? []; + + if (currencyId && typedChainId) { + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + + if (typeof srcTypedChainId !== 'number') { + params.set(SOURCE_CHAIN_KEY, typedChainId); + } + + if (typeof poolId !== 'number') { + params.set(POOL_KEY, currencyId); + } + + return params; + }); + return; + } + } + + if (activeChain && activeBridge) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + + const typedChainId = calculateTypedChainId( + activeChain.chainType, + activeChain.id + ); + + if (typeof srcTypedChainId !== 'number') { + next.set(SOURCE_CHAIN_KEY, `${typedChainId}`); + } + + if (typeof poolId !== 'number') { + next.set(POOL_KEY, `${activeBridge.currency.id}`); + } + + return next; + }); + return; + } + + // Here is when no balances and active connection + const [defaultPool, anchors] = Object.entries(apiConfig.anchors)[0]; + const [defaultTypedId] = Object.entries(anchors)[0]; + + const nextParams = new URLSearchParams(); + if (typeof poolId !== 'number' && defaultPool) { + nextParams.set(POOL_KEY, defaultPool); + } + + if (typeof srcTypedChainId !== 'number' && defaultTypedId) { + nextParams.set(SOURCE_CHAIN_KEY, defaultTypedId); + } + + setSearchParams(nextParams); + }, + // prettier-ignore + [activeBridge, activeChain, apiConfig.anchors, balances, initialized, isConnecting, loading, poolId, setSearchParams, srcTypedChainId] + ); + + // If no active relayer, reset refund states + useEffect( + () => { + if (!activeRelayer && (hasRefund || refundRecipient)) { + setHasRefund(''); + setRefundRecipient(''); + } + }, + // prettier-ignore + [activeRelayer, hasRefund, refundRecipient, setHasRefund, setRefundRecipient] + ); + + const handleChainClick = useCallback( + (destChain?: boolean) => { + navigate( + destChain ? SELECT_DESTINATION_CHAIN_PATH : SELECT_SOURCE_CHAIN_PATH + ); + }, + [navigate] + ); + + const handleTokenClick = useCallback(() => { + navigate(SELECT_SHIELDED_POOL_PATH); + }, [navigate]); + + const handlePasteButtonClick = useCallback(async () => { + try { + const addr = await window.navigator.clipboard.readText(); + + setRecipient(addr); + } catch (e) { + notificationApi({ + message: 'Failed to read clipboard', + secondaryMessage: + 'Please change your browser settings to allow clipboard access.', + variant: 'warning', + }); + } + }, [notificationApi, setRecipient]); + + const handleSendToSelfRefundClick = useCallback(() => { + if (!activeAccount) { + notificationApi({ + message: 'Failed to get account', + secondaryMessage: 'Please connect your wallet first.', + variant: 'warning', + }); + return; + } + + setRefundRecipient(activeAccount.address); + }, [activeAccount, notificationApi, setRefundRecipient]); + + const handleSendToSelfClick = useCallback(() => { + if (!noteManager) { + notificationApi({ + message: 'Failed to get note account', + secondaryMessage: 'Please create a note account first.', + variant: 'warning', + }); + return; + } + const noteAccPub = noteManager.getKeypair().toString(); + setRecipient(noteAccPub); + }, [noteManager, notificationApi, setRecipient]); + + const { + gasFeeInfo, + isLoading: isFeeLoading, + refundAmount, + resetMaxFeeInfo, + totalFeeToken, + totalFeeWei, + } = useFeeCalculation({ + activeRelayer, + recipientErrorMsg, + refundRecipientError: refundRecipientErrorMsg, + }); + + const receivingAmount = useMemo(() => { + if (!amount) { + return; + } + + const parsedAmount = parseFloat(amount); + if (!activeRelayer) { + return parsedAmount; + } + + if (typeof totalFeeWei !== 'bigint') { + return parsedAmount; + } + + const remain = parseEther(amount) - totalFeeWei; + return parseFloat(formatEther(remain)); + }, [activeRelayer, amount, totalFeeWei]); + + const remainingBalance = useMemo(() => { + if (!poolId || !srcTypedChainId) { + return; + } + + const balance = balances[poolId]?.[srcTypedChainId]; + if (typeof balance !== 'bigint') { + return; + } + + if (!amount) { + return Number(formatEther(balance)); + } + + const remain = balance - parseEther(amount); + if (remain < 0) { + return; + } + + return Number(formatEther(remain)); + }, [amount, balances, poolId, srcTypedChainId]); + + const { transferConfirmComponent, ...buttonProps } = useTransferButtonProps({ + balances, + receivingAmount, + isFeeLoading, + totalFeeWei, + feeToken: totalFeeToken, + activeRelayer, + refundAmount, + refundToken: destChainCfg?.nativeCurrency.symbol, + resetFeeInfo: resetMaxFeeInfo, + refundRecipientError: refundRecipientErrorMsg, + }); + + const lastPath = useMemo(() => pathname.split('/').pop(), [pathname]); + if (lastPath && !BRIDGE_TABS.find((tab) => lastPath === tab)) { + return ; + } + + if (transferConfirmComponent !== null) { + return ( + + {transferConfirmComponent} + + ); + } + + return ( + +
+
+ + + handleChainClick()} + /> + + + + handleTokenClick(), + }} + /> + + + + + + + handleChainClick(true)} + /> + } + onClick={() => navigate(SELECT_RELAYER_PATH)} + > + Relayer + + + + handleTokenClick(), + }} + /> + + +
+
+ + +
+ + + + + +
+
+ +
+
+ } + description={ + destChainCfg + ? `Get ${destChainCfg.nativeCurrency.symbol} on transactions on ${destChainCfg.name}` + : undefined + } + className="max-w-none" + switcherProps={{ + checked: !!hasRefund, + disabled: !activeRelayer, + onCheckedChange: () => + setHasRefund((prev) => (prev.length > 0 ? '' : '1')), + }} + /> + + , + value: parseFloat(formatEther(gasFeeInfo)), + tokenSymbol: srcChainCfg?.nativeCurrency.symbol, + } satisfies FeeItem) + : undefined, + ].filter((item) => Boolean(item)) as Array + } + /> + + +
+ + {!isMobile ? ( +
+
+
+ ); +}; + +export default Transfer; + +type RecipientInputProps = { + error?: string; + value: string; + onValueChange: (value: string) => void; + onSendToSelfClick: () => void; + onPasteButtonClick: () => void; +}; + +const RecipientInput: FC = ({ + onPasteButtonClick, + onSendToSelfClick, + onValueChange, + value, + error, +}) => { + const { copy, isCopied } = useCopyable(); + + return ( + + onValueChange(e.target.value)} + /> + + + {value ? ( + } + content={isCopied ? 'Copied' : 'Copy'} + overrideTooltipTriggerProps={{ + onClick: isCopied ? undefined : () => copy(value), + }} + /> + ) : ( + <> + + } + content="Send to self" + overrideTooltipTriggerProps={{ + onClick: onSendToSelfClick, + }} + /> + } + content="Patse from clipboard" + overrideTooltipTriggerProps={{ + onClick: onPasteButtonClick, + }} + /> + + )} + + + ); +}; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useFeeCalculation.ts b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useFeeCalculation.ts new file mode 100644 index 0000000000..047bdc4201 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useFeeCalculation.ts @@ -0,0 +1,154 @@ +import { OptionalActiveRelayer } from '@webb-tools/abstract-api-provider/relayer/types'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import { useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + AMOUNT_KEY, + HAS_REFUND_KEY, + POOL_KEY, + RECIPIENT_KEY, + REFUND_RECIPIENT_KEY, + SOURCE_CHAIN_KEY, +} from '../../../../../constants'; +import { useMaxFeeInfo } from '../../../../../hooks/useMaxFeeInfo'; +import { formatEther, parseEther } from 'viem'; +import { numberToString } from '@webb-tools/webb-ui-components'; + +export type UseFeeCalculationArgs = { + activeRelayer?: OptionalActiveRelayer; + refundRecipientError?: string; + recipientErrorMsg?: string; +}; + +export default function useFeeCalculation(args: UseFeeCalculationArgs) { + const { activeRelayer, refundRecipientError, recipientErrorMsg } = args; + + const { apiConfig } = useWebContext(); + + const [searchParams] = useSearchParams(); + + const [amount, poolId, srcTypedChainId, recipient] = useMemo(() => { + return [ + searchParams.get(AMOUNT_KEY), + searchParams.get(POOL_KEY), + searchParams.get(SOURCE_CHAIN_KEY), + searchParams.get(RECIPIENT_KEY), + ]; + }, [searchParams]); + + const [hasRefund, refundRecipient] = useMemo(() => { + return [ + !!searchParams.get(HAS_REFUND_KEY), + searchParams.get(REFUND_RECIPIENT_KEY), + ]; + }, [searchParams]); + + const fungibleCfg = useMemo(() => { + if (poolId) { + return apiConfig.currencies[parseInt(poolId)]; + } + }, [apiConfig.currencies, poolId]); + + const srcChainCfg = useMemo(() => { + if (srcTypedChainId) { + return apiConfig.chains[parseInt(srcTypedChainId)]; + } + }, [apiConfig.chains, srcTypedChainId]); + + const feeArgs = useMemo( + () => ({ + fungibleCurrencyId: fungibleCfg?.id, + }), + [fungibleCfg?.id] + ); + + const { isLoading, feeInfo, fetchFeeInfo, resetMaxFeeInfo } = + useMaxFeeInfo(feeArgs); + + const gasFeeInfo = useMemo(() => { + if (typeof feeInfo === 'bigint') { + return feeInfo; + } + + return undefined; + }, [feeInfo]); + + const relayerFeeInfo = useMemo(() => { + if (typeof feeInfo === 'object' && feeInfo != null) { + return feeInfo; + } + + return undefined; + }, [feeInfo]); + + const refundAmount = useMemo(() => { + if (!relayerFeeInfo) { + return; + } + + return relayerFeeInfo.maxRefund; + }, [relayerFeeInfo]); + + const totalFeeWei = useMemo(() => { + if (typeof gasFeeInfo === 'bigint') { + return gasFeeInfo; + } + + if (!relayerFeeInfo) { + return; + } + + let total = relayerFeeInfo.estimatedFee; + if (hasRefund && refundAmount) { + const parsedRefund = parseFloat(formatEther(refundAmount)); + const parsedExchangeRate = parseFloat( + formatEther(relayerFeeInfo.refundExchangeRate) + ); + + const refundCost = parsedRefund * parsedExchangeRate; + total += parseEther(numberToString(refundCost)); + } + + return total; + }, [gasFeeInfo, hasRefund, refundAmount, relayerFeeInfo]); + + const totalFeeToken = useMemo(() => { + if (activeRelayer) { + return fungibleCfg?.symbol; + } + + return srcChainCfg?.nativeCurrency.symbol; + }, [activeRelayer, fungibleCfg?.symbol, srcChainCfg?.nativeCurrency.symbol]); + + // Side effect for auto fetching fee info + // when all inputs are filled and valid + useEffect( + () => { + if (!amount || !fungibleCfg) { + return; + } + + if (!recipient || recipientErrorMsg) { + return; + } + + // If refund is enabled, refund recipient must be filled and valid + if (hasRefund && (!refundRecipient || refundRecipientError)) { + return; + } + + fetchFeeInfo(hasRefund ? activeRelayer : undefined); + }, + // prettier-ignore + [activeRelayer, amount, fetchFeeInfo, fungibleCfg, hasRefund, recipient, recipientErrorMsg, refundRecipient, refundRecipientError] + ); + + return { + gasFeeInfo, + isLoading, + refundAmount, + resetMaxFeeInfo, + totalFeeToken, + totalFeeWei, + }; +} diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useInputs.ts b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useInputs.ts new file mode 100644 index 0000000000..ca6643d159 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useInputs.ts @@ -0,0 +1,73 @@ +import isValidAddress from '@webb-tools/dapp-types/utils/isValidAddress'; +import isValidPublicKey from '@webb-tools/dapp-types/utils/isValidPublicKey'; +import { useEffect, useState } from 'react'; +import { + HAS_REFUND_KEY, + RECIPIENT_KEY, + REFUND_RECIPIENT_KEY, +} from '../../../../../constants'; +import useAmountWithRoute from '../../../../../hooks/useAmountWithRoute'; +import useStateWithRoute from '../../../../../hooks/useStateWithRoute'; + +const useInputs = () => { + const [amount, setAmount] = useAmountWithRoute(); + + const [recipient, setRecipient] = useStateWithRoute(RECIPIENT_KEY); + + const [hasRefund, setHasRefund] = useStateWithRoute(HAS_REFUND_KEY); + + const [refundRecipient, setRefundRecipient] = + useStateWithRoute(REFUND_RECIPIENT_KEY); + + const [recipientErrorMsg, setRecipientErrorMsg] = useState(''); + + const [refundRecipientErrorMsg, setRefundRecipientErrorMsg] = useState(''); + + // Reset the refund recipient if the user toggles the refund switch + useEffect(() => { + if (!hasRefund) { + setRefundRecipient(''); + } + }, [hasRefund, setRefundRecipient]); + + // Validate recipient input address after 0.5s + useEffect(() => { + const timeout = setTimeout(() => { + if (recipient && !isValidPublicKey(recipient)) { + setRecipientErrorMsg('Invalid shielded account'); + } else { + setRecipientErrorMsg(''); + } + }, 500); + + return () => clearTimeout(timeout); + }, [recipient]); + + // Validate refund recipient input address after 0.5s + useEffect(() => { + const timeout = setTimeout(() => { + if (refundRecipient && !isValidAddress(refundRecipient)) { + setRefundRecipientErrorMsg('Invalid wallet address'); + } else { + setRefundRecipientErrorMsg(''); + } + }, 500); + + return () => clearTimeout(timeout); + }, [refundRecipient]); + + return { + amount, + hasRefund, + recipient, + recipientErrorMsg, + refundRecipient, + refundRecipientErrorMsg, + setAmount, + setHasRefund, + setRecipient, + setRefundRecipient, + }; +}; + +export default useInputs; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useRelayerWithRoute.ts b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useRelayerWithRoute.ts new file mode 100644 index 0000000000..00b75d3ff5 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useRelayerWithRoute.ts @@ -0,0 +1,113 @@ +import { OptionalActiveRelayer } from '@webb-tools/abstract-api-provider/relayer/types'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + NO_RELAYER_KEY, + POOL_KEY, + RELAYER_ENDPOINT_KEY, + SOURCE_CHAIN_KEY, +} from '../../../../../constants'; +import useStateWithRoute from '../../../../../hooks/useStateWithRoute'; + +const useRelayerWithRoute = () => { + const [searchParams] = useSearchParams(); + + const { activeApi, apiConfig } = useWebContext(); + + // State for active selected relayer + const [relayer, setRelayer] = useStateWithRoute(RELAYER_ENDPOINT_KEY); + const [activeRelayer, setActiveRelayer] = + useState(null); + + const [srcTypedChainId, poolId, noRelayer] = useMemo(() => { + const srcTypedId = parseInt(searchParams.get(SOURCE_CHAIN_KEY) ?? ''); + const poolId = parseInt(searchParams.get(POOL_KEY) ?? ''); + + return [ + Number.isNaN(srcTypedId) ? undefined : srcTypedId, + Number.isNaN(poolId) ? undefined : poolId, + !!searchParams.get(NO_RELAYER_KEY), + ]; + }, [searchParams]); + + // Side effect for active relayer subsription + useEffect(() => { + if (!activeApi) { + return; + } + + const sub = activeApi.relayerManager.activeRelayerWatcher.subscribe( + (relayer) => { + // console.log('relayer', relayer); + setRelayer(relayer?.endpoint ?? ''); + setActiveRelayer(relayer); + } + ); + + return () => sub.unsubscribe(); + }, [activeApi, setRelayer]); + + // Side effect for reset the active relayer + // when no relayer is selected + useEffect(() => { + if (!noRelayer || !activeApi) { + return; + } + + const active = activeApi.relayerManager.activeRelayer; + if (active) { + activeApi.relayerManager.setActiveRelayer( + null, + activeApi.typedChainidSubject.getValue() + ); + } + }, [activeApi, noRelayer]); + + const hasSetDefaultRelayer = useRef(false); + + // Side effect for setting the default relayer + // when the relayer list is loaded and no active relayer + useEffect(() => { + if (!activeApi || noRelayer || hasSetDefaultRelayer.current) { + return; + } + + const sub = activeApi.relayerManager.listUpdated.subscribe(async () => { + const typedChainIdToUse = + srcTypedChainId ?? activeApi.typedChainidSubject.getValue(); + + const target = + typeof poolId === 'number' + ? apiConfig.anchors[poolId]?.[typedChainIdToUse] + : ''; + + const relayers = + await activeApi.relayerManager.getRelayersByChainAndAddress( + typedChainIdToUse, + target + ); + + const active = activeApi.relayerManager.activeRelayer; + if (!active && relayers.length > 0) { + activeApi.relayerManager.setActiveRelayer( + relayers[0], + typedChainIdToUse + ); + hasSetDefaultRelayer.current = true; + } + }); + + // trigger the relayer list update on mount + activeApi.relayerManager.listUpdated$.next(); + + return () => sub.unsubscribe(); + }, [activeApi, apiConfig.anchors, srcTypedChainId, noRelayer, poolId]); + + return { + relayer, + activeRelayer, + }; +}; + +export default useRelayerWithRoute; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useTransferButtonProps.tsx b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useTransferButtonProps.tsx new file mode 100644 index 0000000000..c86c74343b --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Transfer/private/useTransferButtonProps.tsx @@ -0,0 +1,486 @@ +import { Currency } from '@webb-tools/abstract-api-provider/currency'; +import { OptionalActiveRelayer } from '@webb-tools/abstract-api-provider/relayer/types'; +import utxoFromVAnchorNote from '@webb-tools/abstract-api-provider/utils/utxoFromVAnchorNote'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import chainsPopulated from '@webb-tools/dapp-config/chains/chainsPopulated'; +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; +import { WebbError, WebbErrorCodes } from '@webb-tools/dapp-types/WebbError'; +import { NoteManager } from '@webb-tools/note-manager/'; +import { useBalancesFromNotes } from '@webb-tools/react-hooks/currency/useBalancesFromNotes'; +import { useNoteAccount } from '@webb-tools/react-hooks/useNoteAccount'; +import { useVAnchor } from '@webb-tools/react-hooks/vanchor/useVAnchor'; +import { Keypair } from '@webb-tools/sdk-core'; +import { ComponentProps, useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useSearchParams } from 'react-router-dom'; +import { formatEther, parseEther, parseUnits } from 'viem'; +import { + AMOUNT_KEY, + BRIDGE_PATH, + DEST_CHAIN_KEY, + HAS_REFUND_KEY, + POOL_KEY, + RECIPIENT_KEY, + REFUND_RECIPIENT_KEY, + SOURCE_CHAIN_KEY, + TRANSFER_PATH, +} from '../../../../../constants'; +import TransferConfirmContainer from '../../../../../containers/TransferConfirmContainer/TransferConfirmContainer'; +import { useConnectWallet } from '../../../../../hooks/useConnectWallet'; +import handleTxError from '../../../../../utils/handleTxError'; +import validateNoteLeafIndex from '../../../../../utils/validateNoteLeafIndex'; + +export type UseTransferButtonPropsArgs = { + balances: ReturnType['balances']; + receivingAmount?: number; + isFeeLoading?: boolean; + totalFeeWei?: bigint; + feeToken?: string; + resetFeeInfo?: () => void; + activeRelayer: OptionalActiveRelayer; + refundAmount?: bigint; + refundToken?: string; + refundRecipientError?: string; +}; + +function useTransferButtonProps({ + balances, + receivingAmount, + isFeeLoading, + totalFeeWei, + feeToken, + refundAmount, + refundToken, + resetFeeInfo, + activeRelayer, + refundRecipientError, +}: UseTransferButtonPropsArgs) { + const navigate = useNavigate(); + + const [searchParams] = useSearchParams(); + + const { + activeApi, + activeChain, + apiConfig, + isConnecting, + loading, + switchChain, + activeWallet, + noteManager, + } = useWebContext(); + + const [amount, poolId, recipient] = useMemo(() => { + const amountStr = searchParams.get(AMOUNT_KEY) ?? ''; + + const poolId = searchParams.get(POOL_KEY) ?? ''; + + const recipientStr = searchParams.get(RECIPIENT_KEY) ?? ''; + + return [ + amountStr ? formatEther(BigInt(amountStr)) : undefined, + !Number.isNaN(parseInt(poolId)) ? parseInt(poolId) : undefined, + recipientStr ? recipientStr : undefined, + ]; + }, [searchParams]); + + const [hasRefund, refundRecipient] = useMemo(() => { + const hasRefund = searchParams.has(HAS_REFUND_KEY); + const refundRecipientStr = searchParams.get(REFUND_RECIPIENT_KEY) ?? ''; + + return [!!hasRefund, refundRecipientStr ? refundRecipientStr : undefined]; + }, [searchParams]); + + const [srcTypedChainId, destTypedChainId] = useMemo(() => { + const srcTypedChainId = searchParams.get(SOURCE_CHAIN_KEY) ?? ''; + const destTypedIdStr = searchParams.get(DEST_CHAIN_KEY) ?? ''; + + return [ + !Number.isNaN(parseInt(srcTypedChainId)) + ? parseInt(srcTypedChainId) + : undefined, + !Number.isNaN(parseInt(destTypedIdStr)) + ? parseInt(destTypedIdStr) + : undefined, + ]; + }, [searchParams]); + + const [fungibleCfg, srcChain, destChain] = useMemo( + () => { + return [ + typeof poolId === 'number' ? apiConfig.currencies[poolId] : undefined, + typeof srcTypedChainId === 'number' + ? chainsPopulated[srcTypedChainId] + : undefined, + typeof destTypedChainId === 'number' + ? chainsPopulated[destTypedChainId] + : undefined, + ]; + }, + // prettier-ignore + [apiConfig.currencies, destTypedChainId, poolId, srcTypedChainId] + ); + + const { hasNoteAccount, setOpenNoteAccountModal } = useNoteAccount(); + + const { isWalletConnected, toggleModal } = useConnectWallet(); + + const isValidAmount = useMemo(() => { + if (!fungibleCfg) { + return false; + } + + if (typeof srcTypedChainId !== 'number') { + return false; + } + + if (!amount || Number.isNaN(Number(amount)) || Number(amount) <= 0) { + return false; + } + + const balance = balances[fungibleCfg.id]?.[srcTypedChainId]; + if (typeof balance !== 'bigint') { + return true; + } + + if (typeof receivingAmount !== 'number') { + return false; + } + + return parseEther(amount) <= balance && receivingAmount >= 0; + }, [amount, balances, srcTypedChainId, fungibleCfg, receivingAmount]); + + const connCnt = useMemo(() => { + if (!activeApi) { + return 'Connect Wallet'; + } + + if (!hasNoteAccount) { + return 'Create Note Account'; + } + + const activeId = activeApi.typedChainidSubject.getValue(); + if (activeId !== srcTypedChainId) { + return 'Switch Chain'; + } + + return undefined; + }, [activeApi, srcTypedChainId, hasNoteAccount]); + + const inputCnt = useMemo( + () => { + if (!srcTypedChainId || !destTypedChainId) { + return 'Select chain'; + } + + if (!fungibleCfg) { + return 'Select pool'; + } + + if (!amount || Number.isNaN(Number(amount)) || Number(amount) <= 0) { + return 'Enter amount'; + } + + if (!recipient) { + return 'Enter recipient'; + } + + if (hasRefund && !refundRecipient) { + return 'Enter refund recipient'; + } + + if (!isValidAmount) { + return 'Insufficient balance'; + } + }, + // prettier-ignore + [srcTypedChainId, destTypedChainId, fungibleCfg, amount, recipient, hasRefund, refundRecipient, isValidAmount] + ); + + const btnText = useMemo(() => { + if (inputCnt) { + return inputCnt; + } + + if (connCnt) { + return connCnt; + } + + return 'Transfer'; + }, [connCnt, inputCnt]); + + const isDisabled = useMemo( + () => { + const allInputsFilled = + !!amount && + !!fungibleCfg && + !!recipient && + typeof destTypedChainId === 'number'; + + const refundFilled = hasRefund ? !!refundRecipient : true; + + const userInputValid = + allInputsFilled && + refundFilled && + isValidAmount && + !refundRecipientError; + + if (!userInputValid || isFeeLoading) { + return true; + } + + if (!isWalletConnected || !hasNoteAccount) { + return false; + } + + const isSrcChainActive = + srcChain && + srcChain.id === activeChain?.id && + srcChain.chainType === activeChain?.chainType; + + if (!activeChain || !isSrcChainActive) { + return false; + } + + return false; + }, + // prettier-ignore + [activeChain, amount, destTypedChainId, fungibleCfg, hasNoteAccount, hasRefund, isFeeLoading, isValidAmount, isWalletConnected, recipient, refundRecipient, refundRecipientError, srcChain] + ); + + const isLoading = useMemo(() => { + return loading || isConnecting; + }, [isConnecting, loading]); + + const { api: vAnchorApi } = useVAnchor(); + + const [transferConfirmComponent, setTransferConfirmComponent] = + useState, + typeof TransferConfirmContainer + > | null>(null); + + const handleSwitchChain = useCallback( + async () => { + if (typeof srcTypedChainId !== 'number') { + return; + } + + const nextChain = chainsPopulated[srcTypedChainId]; + if (!nextChain) { + throw WebbError.from(WebbErrorCodes.UnsupportedChain); + } + + const isNextChainActive = + activeChain?.id === nextChain.id && + activeChain?.chainType === nextChain.chainType; + + if (!isWalletConnected || !isNextChainActive) { + if (activeWallet && nextChain.wallets.includes(activeWallet.id)) { + await switchChain(nextChain, activeWallet); + } else { + toggleModal(true, nextChain); + } + return; + } + + if (!hasNoteAccount) { + setOpenNoteAccountModal(true); + } + }, + // prettier-ignore + [activeChain?.chainType, activeChain?.id, activeWallet, hasNoteAccount, isWalletConnected, setOpenNoteAccountModal, srcTypedChainId, switchChain, toggleModal] + ); + + const handleTransferBtnClick = useCallback( + async () => { + try { + if (connCnt) { + return await handleSwitchChain(); + } + + // For type assertion + const _validAmount = + isValidAmount && !!amount && typeof receivingAmount === 'number'; + + const allInputsFilled = + !!srcChain && + !!fungibleCfg && + !!srcTypedChainId && + !!destTypedChainId && + !!recipient && + (hasRefund ? !!refundRecipient : true) && + _validAmount; + + const doesApiReady = + !!activeApi?.state.activeBridge && !!vAnchorApi && !!noteManager; + + if (!allInputsFilled || !doesApiReady || !destChain) { + throw WebbError.from(WebbErrorCodes.ApiNotReady); + } + + if (activeApi.state.activeBridge?.currency.id !== fungibleCfg.id) { + throw WebbError.from(WebbErrorCodes.InvalidArguments); + } + + const anchorId = activeApi.state.activeBridge.targets[srcTypedChainId]; + if (!anchorId) { + throw WebbError.from(WebbErrorCodes.AnchorIdNotFound); + } + + const resourceId = await vAnchorApi.getResourceId( + anchorId, + srcChain.id, + srcChain.chainType + ); + + const avaiNotes = ( + noteManager.getNotesOfChain(resourceId.toString()) ?? [] + ).filter( + (note) => + note.note.tokenSymbol === fungibleCfg.symbol && + !!fungibleCfg.addresses.get(parseInt(note.note.targetChainId)) + ); + + const fungibleDecimals = fungibleCfg.decimals; + const amountBig = parseUnits(amount, fungibleDecimals); + + // Get the notes that will be spent for this withdraw + const inputNotes = NoteManager.getNotesFifo(avaiNotes, amountBig); + if (!inputNotes) { + throw WebbError.from(WebbErrorCodes.NoteParsingFailure); + } + + // Validate the input notes + const edges = await vAnchorApi.getLatestNeighborEdges( + fungibleCfg.id, + srcTypedChainId + ); + const nextIdx = await vAnchorApi.getNextIndex( + srcTypedChainId, + fungibleCfg.id + ); + + const valid = inputNotes.every((note) => { + if (note.note.targetChainId === srcTypedChainId.toString()) { + return note.note.index ? BigInt(note.note.index) < nextIdx : true; + } else { + return validateNoteLeafIndex(note, edges); + } + }); + + if (!valid) { + throw WebbError.from(WebbErrorCodes.NotesNotReady); + } + + // Sum up the amount of the input notes to calculate the change amount + const totalAmountInput = inputNotes.reduce( + (acc, note) => acc + BigInt(note.note.amount), + ZERO_BIG_INT + ); + + const changeAmount = totalAmountInput - amountBig; + if (changeAmount < 0) { + throw WebbError.from(WebbErrorCodes.InvalidArguments); + } + + const keypair = noteManager.getKeypair(); + if (!keypair.privkey) { + throw WebbError.from(WebbErrorCodes.KeyPairNotFound); + } + + // Setup the recipient's keypair. + const recipientKeypair = Keypair.fromString(recipient); + + const utxoAmount = + activeRelayer && typeof totalFeeWei == 'bigint' + ? amountBig - totalFeeWei + : amountBig; + + const transferUtxo = await activeApi.generateUtxo({ + curve: noteManager.defaultNoteGenInput.curve, + backend: activeApi.backend, + amount: utxoAmount.toString(), + chainId: destTypedChainId.toString(), + keypair: recipientKeypair, + originChainId: srcTypedChainId.toString(), + index: activeApi.state.defaultUtxoIndex.toString(), + }); + + const changeNote = + changeAmount > 0 + ? await noteManager.generateNote( + activeApi.backend, + srcTypedChainId, + anchorId, + srcTypedChainId, + anchorId, + fungibleCfg.symbol, + fungibleDecimals, + changeAmount + ) + : undefined; + + // Generate change utxo (or dummy utxo if the changeAmount is `0`) + const changeUtxo = changeNote + ? await utxoFromVAnchorNote( + changeNote.note, + changeNote.note.index ? parseInt(changeNote.note.index) : 0 + ) + : await activeApi.generateUtxo({ + curve: noteManager.defaultNoteGenInput.curve, + backend: activeApi.backend, + amount: changeAmount.toString(), + chainId: `${srcTypedChainId}`, + keypair, + originChainId: `${srcTypedChainId}`, + index: activeApi.state.defaultUtxoIndex.toString(), + }); + + setTransferConfirmComponent( + { + resetFeeInfo?.(); + setTransferConfirmComponent(null); + navigate(`/${BRIDGE_PATH}/${TRANSFER_PATH}`); + }} + onClose={() => { + setTransferConfirmComponent(null); + }} + refundAmount={refundAmount} + refundToken={refundToken} + refundRecipient={refundRecipient} + /> + ); + } catch (error) { + handleTxError(error, 'Transfer'); + } + }, + // prettier-ignore + [activeApi, activeRelayer, amount, connCnt, destChain, destTypedChainId, feeToken, fungibleCfg, handleSwitchChain, hasRefund, isValidAmount, navigate, noteManager, receivingAmount, recipient, refundAmount, refundRecipient, refundToken, resetFeeInfo, srcChain, srcTypedChainId, totalFeeWei, vAnchorApi] + ); + + return { + isLoading, + isDisabled, + children: btnText, + transferConfirmComponent, + onClick: handleTransferBtnClick, + }; +} + +export default useTransferButtonProps; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/index.tsx b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/index.tsx new file mode 100644 index 0000000000..f96b204595 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/index.tsx @@ -0,0 +1,523 @@ +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import { + AccountCircleLineIcon, + ArrowRight, + ClipboardLineIcon, + FileCopyLine, + GasStationFill, + SettingsFillIcon, +} from '@webb-tools/icons'; +import { useBalancesFromNotes } from '@webb-tools/react-hooks/currency/useBalancesFromNotes'; +import { calculateTypedChainId } from '@webb-tools/sdk-core/typed-chain-id'; +import { + Button, + ConnectWalletMobileButton, + FeeDetails, + IconWithTooltip, + TextField, + TitleWithInfo, + ToggleCard, + TransactionInputCard, + useCheckMobile, + useCopyable, + useWebbUI, +} from '@webb-tools/webb-ui-components'; +import { FeeItem } from '@webb-tools/webb-ui-components/components/FeeDetails/types'; +import { useCallback, useEffect, useMemo } from 'react'; +import { Outlet, useLocation, useSearchParams } from 'react-router-dom'; +import { formatEther, parseEther } from 'viem'; +import SlideAnimation from '../../../../components/SlideAnimation'; +import { + BRIDGE_TABS, + DEST_CHAIN_KEY, + NO_RELAYER_KEY, + POOL_KEY, + SELECT_DESTINATION_CHAIN_PATH, + SELECT_RELAYER_PATH, + SELECT_SHIELDED_POOL_PATH, + SELECT_TOKEN_PATH, + TOKEN_KEY, +} from '../../../../constants'; +import { + CUSTOM_AMOUNT_TOOLTIP_CONTENT, + FIXED_AMOUNT_TOOLTIP_CONTENT, +} from '../../../../constants/tooltipContent'; +import BridgeTabsContainer from '../../../../containers/BridgeTabsContainer'; +import TxInfoContainer from '../../../../containers/TxInfoContainer'; +import useNavigateWithPersistParams from '../../../../hooks/useNavigateWithPersistParams'; +import useFeeCalculation from './private/useFeeCalculation'; +import useInputs from './private/useInputs'; +import useRelayerWithRoute from './private/useRelayerWithRoute'; +import useWithdrawButtonProps from './private/useWithdrawButtonProps'; + +const Withdraw = () => { + const { pathname } = useLocation(); + + const navigate = useNavigateWithPersistParams(); + + const { isMobile } = useCheckMobile(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const { balances, initialized } = useBalancesFromNotes(); + + const { + apiConfig, + activeApi, + activeAccount, + activeChain, + loading, + isConnecting, + } = useWebContext(); + + const { notificationApi } = useWebbUI(); + + const { copy, isCopied } = useCopyable(); + + const { + amount, + hasRefund, + isCustom, + recipient, + recipientErrorMsg, + setAmount, + setHasRefund, + setIsCustom, + setRecipient, + } = useInputs(); + + const { activeRelayer } = useRelayerWithRoute(); + + const [destTypedChainId, poolId, tokenId, noRelayer] = useMemo(() => { + const destTypedId = parseInt(searchParams.get(DEST_CHAIN_KEY) ?? ''); + + const poolId = parseInt(searchParams.get(POOL_KEY) ?? ''); + const tokenId = parseInt(searchParams.get(TOKEN_KEY) ?? ''); + + const noRelayer = searchParams.get(NO_RELAYER_KEY); + + return [ + Number.isNaN(destTypedId) ? undefined : destTypedId, + Number.isNaN(poolId) ? undefined : poolId, + Number.isNaN(tokenId) ? undefined : tokenId, + Boolean(noRelayer), + ]; + }, [searchParams]); + + const [fungibleCfg, wrappableCfg] = useMemo(() => { + return [ + typeof poolId === 'number' ? apiConfig.currencies[poolId] : undefined, + typeof tokenId === 'number' ? apiConfig.currencies[tokenId] : undefined, + ]; + }, [poolId, tokenId, apiConfig.currencies]); + + const fungibleMaxAmount = useMemo(() => { + if (!destTypedChainId) { + return; + } + + if (fungibleCfg && balances[fungibleCfg.id]?.[destTypedChainId]) { + return Number(formatEther(balances[fungibleCfg.id][destTypedChainId])); + } + }, [balances, destTypedChainId, fungibleCfg]); + + const activeBridge = useMemo(() => { + return activeApi?.state.activeBridge; + }, [activeApi?.state.activeBridge]); + + const destChainCfg = useMemo(() => { + if (typeof destTypedChainId !== 'number') { + return; + } + + return apiConfig.chains[destTypedChainId]; + }, [apiConfig.chains, destTypedChainId]); + + // Set default poolId and destTypedChainId on first render + useEffect( + () => { + if (loading || isConnecting || !initialized) { + return; + } + + if (typeof destTypedChainId === 'number' && typeof poolId === 'number') { + return; + } + + const entries = Object.entries(balances); + if (entries.length > 0) { + // Find first pool & destTypedChainId from balances + const [currencyId, balanceRecord] = entries[0]; + const [typedChainId] = Object.entries(balanceRecord)?.[0] ?? []; + + if (currencyId && typedChainId) { + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + + if (typeof destTypedChainId !== 'number') { + params.set(DEST_CHAIN_KEY, typedChainId); + } + + if (typeof poolId !== 'number') { + params.set(POOL_KEY, currencyId); + } + + return params; + }); + return; + } + } + + if (activeChain && activeBridge) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + + const typedChainId = calculateTypedChainId( + activeChain.chainType, + activeChain.id + ); + + if (typeof destTypedChainId !== 'number') { + next.set(DEST_CHAIN_KEY, `${typedChainId}`); + } + + if (typeof poolId !== 'number') { + next.set(POOL_KEY, `${activeBridge.currency.id}`); + } + + return next; + }); + return; + } + + // Here is when no balances and active connection + const [defaultPool, anchors] = Object.entries(apiConfig.anchors)[0]; + const [defaultTypedId] = Object.entries(anchors)[0]; + + const nextParams = new URLSearchParams(); + if (typeof poolId !== 'number' && defaultPool) { + nextParams.set(POOL_KEY, defaultPool); + } + + if (typeof destTypedChainId !== 'number' && defaultTypedId) { + nextParams.set(DEST_CHAIN_KEY, defaultTypedId); + } + + setSearchParams(nextParams); + }, + // prettier-ignore + [activeBridge, activeChain, apiConfig.anchors, balances, destTypedChainId, initialized, isConnecting, loading, poolId, setSearchParams] + ); + + // If no active relayer, reset refund states + useEffect(() => { + if (!activeRelayer && hasRefund) { + setHasRefund(''); + } + }, [activeRelayer, hasRefund, setHasRefund]); + + const handleChainClick = useCallback(() => { + navigate(SELECT_DESTINATION_CHAIN_PATH); + }, [navigate]); + + const handleTokenClick = useCallback( + (isShielded?: boolean) => { + navigate(isShielded ? SELECT_SHIELDED_POOL_PATH : SELECT_TOKEN_PATH); + }, + [navigate] + ); + + const handlePasteButtonClick = useCallback(async () => { + try { + const addr = await window.navigator.clipboard.readText(); + + setRecipient(addr); + } catch (e) { + notificationApi({ + message: 'Failed to read clipboard', + secondaryMessage: + 'Please change your browser settings to allow clipboard access.', + variant: 'warning', + }); + } + }, [notificationApi, setRecipient]); + + const handleSendToSelfClick = useCallback(() => { + if (!activeAccount) { + notificationApi({ + message: 'Failed to get active account', + secondaryMessage: 'Please check your wallet connection and try again.', + variant: 'warning', + }); + return; + } + + setRecipient(activeAccount.address); + }, [activeAccount, notificationApi, setRecipient]); + + const { + gasFeeInfo, + isLoading: isFeeLoading, + refundAmount, + resetMaxFeeInfo, + totalFeeToken, + totalFeeWei, + } = useFeeCalculation({ activeRelayer, recipientErrorMsg }); + + const receivingAmount = useMemo(() => { + if (!amount) { + return; + } + + const parsedAmount = parseFloat(amount); + if (!activeRelayer) { + return parsedAmount; + } + + if (typeof totalFeeWei !== 'bigint') { + return parsedAmount; + } + + const remain = parseEther(amount) - totalFeeWei; + return parseFloat(formatEther(remain)); + }, [activeRelayer, amount, totalFeeWei]); + + const remainingBalance = useMemo(() => { + if (!poolId || !destTypedChainId) { + return; + } + + const balance = balances[poolId]?.[destTypedChainId]; + if (typeof balance !== 'bigint') { + return; + } + + if (!amount) { + return Number(formatEther(balance)); + } + + const remain = balance - parseEther(amount); + if (remain < 0) { + return; + } + + return Number(formatEther(remain)); + }, [amount, balances, destTypedChainId, poolId]); + + const { withdrawConfirmComponent, ...buttonProps } = useWithdrawButtonProps({ + balances, + receivingAmount, + isFeeLoading, + totalFeeWei, + refundAmount, + resetFeeInfo: resetMaxFeeInfo, + }); + + const lastPath = useMemo(() => pathname.split('/').pop(), [pathname]); + if (lastPath && !BRIDGE_TABS.find((tab) => lastPath === tab)) { + return ; + } + + if (withdrawConfirmComponent !== null) { + return ( + + {withdrawConfirmComponent} + + ); + } + + return ( + +
+
+ + setIsCustom((prev) => (prev.length > 0 ? '' : '1')) + } + > + + + + + + handleTokenClick(true), + }} + fixedAmountProps={{ + step: 0.01, + }} + /> + + + + + + + + setIsCustom((prev) => (prev.length > 0 ? '' : '1')) + } + > + + + } + onClick={() => navigate(SELECT_RELAYER_PATH)} + > + {noRelayer ? 'No Relayer' : 'Relayer'} + + + + handleTokenClick(), + }} + /> + + +
+
+ + + + setRecipient(e.target.value)} + /> + + + {recipient ? ( + + } + content={isCopied ? 'Copied' : 'Copy'} + overrideTooltipTriggerProps={{ + onClick: isCopied ? undefined : () => copy(recipient), + }} + /> + ) : ( + <> + + } + content="Send to self" + overrideTooltipTriggerProps={{ + onClick: handleSendToSelfClick, + }} + /> + + } + content="Patse from clipboard" + overrideTooltipTriggerProps={{ + onClick: handlePasteButtonClick, + }} + /> + + )} + + +
+
+
+ +
+
+ } + description={ + destChainCfg + ? `Get ${destChainCfg.nativeCurrency.symbol} on transactions on ${destChainCfg.name}` + : undefined + } + className="max-w-none" + switcherProps={{ + checked: !!hasRefund, + disabled: !activeRelayer, + onCheckedChange: () => + setHasRefund((prev) => (prev.length > 0 ? '' : '1')), + }} + /> + + , + value: parseFloat(formatEther(gasFeeInfo)), + tokenSymbol: destChainCfg?.nativeCurrency.symbol, + } satisfies FeeItem) + : undefined, + ].filter((item) => Boolean(item)) as Array + } + /> + + +
+ + {!isMobile ? ( +
+
+
+ ); +}; + +export default Withdraw; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useFeeCalculation.ts b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useFeeCalculation.ts new file mode 100644 index 0000000000..7ecc23e5a8 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useFeeCalculation.ts @@ -0,0 +1,146 @@ +import { OptionalActiveRelayer } from '@webb-tools/abstract-api-provider/relayer/types'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import numberToString from '@webb-tools/webb-ui-components/utils/numberToString'; +import { useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { formatEther, parseEther } from 'viem'; +import { + AMOUNT_KEY, + DEST_CHAIN_KEY, + HAS_REFUND_KEY, + POOL_KEY, + RECIPIENT_KEY, + TOKEN_KEY, +} from '../../../../../constants'; +import { useMaxFeeInfo } from '../../../../../hooks/useMaxFeeInfo'; + +export type UseFeeCalculationArgs = { + activeRelayer?: OptionalActiveRelayer; + recipientErrorMsg?: string; +}; + +export default function useFeeCalculation(args: UseFeeCalculationArgs) { + const { activeRelayer, recipientErrorMsg } = args; + + const { apiConfig } = useWebContext(); + + const [searchParams] = useSearchParams(); + + const [amount, poolId, tokenId, destChainId] = useMemo(() => { + return [ + searchParams.get(AMOUNT_KEY), + searchParams.get(POOL_KEY), + searchParams.get(TOKEN_KEY), + searchParams.get(DEST_CHAIN_KEY), + ]; + }, [searchParams]); + + const [hasRefund, recipient] = useMemo( + () => [searchParams.get(HAS_REFUND_KEY), searchParams.get(RECIPIENT_KEY)], + [searchParams] + ); + + const fungibleCfg = useMemo(() => { + if (poolId) { + return apiConfig.currencies[parseInt(poolId)]; + } + }, [apiConfig.currencies, poolId]); + + const destChainCfg = useMemo(() => { + if (destChainId) { + return apiConfig.chains[parseInt(destChainId)]; + } + }, [apiConfig.chains, destChainId]); + + const feeArgs = useMemo( + () => ({ + fungibleCurrencyId: fungibleCfg?.id, + }), + [fungibleCfg?.id] + ); + + const { isLoading, feeInfo, fetchFeeInfo, resetMaxFeeInfo } = + useMaxFeeInfo(feeArgs); + + const gasFeeInfo = useMemo(() => { + if (typeof feeInfo === 'bigint') { + return feeInfo; + } + + return undefined; + }, [feeInfo]); + + const relayerFeeInfo = useMemo(() => { + if (typeof feeInfo === 'object' && feeInfo != null) { + return feeInfo; + } + + return undefined; + }, [feeInfo]); + + const refundAmount = useMemo(() => { + if (!relayerFeeInfo) { + return; + } + + return relayerFeeInfo.maxRefund; + }, [relayerFeeInfo]); + + const totalFeeWei = useMemo(() => { + if (typeof gasFeeInfo === 'bigint') { + return gasFeeInfo; + } + + if (!relayerFeeInfo) { + return; + } + + let total = relayerFeeInfo.estimatedFee; + if (hasRefund && refundAmount) { + const parsedRefund = parseFloat(formatEther(refundAmount)); + const parsedExchangeRate = parseFloat( + formatEther(relayerFeeInfo.refundExchangeRate) + ); + + const refundCost = parsedRefund * parsedExchangeRate; + total += parseEther(numberToString(refundCost)); + } + + return total; + }, [gasFeeInfo, hasRefund, refundAmount, relayerFeeInfo]); + + const totalFeeToken = useMemo(() => { + if (activeRelayer) { + return fungibleCfg?.symbol; + } + + return destChainCfg?.nativeCurrency.symbol; + }, [activeRelayer, destChainCfg?.nativeCurrency.symbol, fungibleCfg?.symbol]); + + // Side effect for auto fetching fee info + // when all inputs are filled and valid + useEffect( + () => { + if (!amount || !fungibleCfg || !tokenId) { + return; + } + + if (!destChainCfg || !recipient || recipientErrorMsg) { + return; + } + + fetchFeeInfo(hasRefund ? activeRelayer : undefined); + }, + // prettier-ignore + [activeRelayer, amount, destChainCfg, fetchFeeInfo, fungibleCfg, hasRefund, recipient, recipientErrorMsg, tokenId] + ); + + return { + gasFeeInfo, + isLoading, + refundAmount, + resetMaxFeeInfo, + totalFeeToken, + totalFeeWei, + }; +} diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useInputs.ts b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useInputs.ts new file mode 100644 index 0000000000..34332f0130 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useInputs.ts @@ -0,0 +1,48 @@ +import isValidAddress from '@webb-tools/dapp-types/utils/isValidAddress'; +import { useEffect, useState } from 'react'; +import { + HAS_REFUND_KEY, + IS_CUSTOM_AMOUNT_KEY, + RECIPIENT_KEY, +} from '../../../../../constants'; +import useAmountWithRoute from '../../../../../hooks/useAmountWithRoute'; +import useStateWithRoute from '../../../../../hooks/useStateWithRoute'; + +const useInputs = () => { + const [amount, setAmount] = useAmountWithRoute(); + + const [recipient, setRecipient] = useStateWithRoute(RECIPIENT_KEY); + + const [hasRefund, setHasRefund] = useStateWithRoute(HAS_REFUND_KEY); + + const [isCustom, setIsCustom] = useStateWithRoute(IS_CUSTOM_AMOUNT_KEY); + + const [recipientErrorMsg, setRecipientErrorMsg] = useState(''); + + // Validate recipient input address after 1s + useEffect(() => { + const timeout = setTimeout(() => { + if (recipient && !isValidAddress(recipient)) { + setRecipientErrorMsg('Invalid address'); + } else { + setRecipientErrorMsg(''); + } + }, 500); + + return () => clearTimeout(timeout); + }, [recipient]); + + return { + amount, + setAmount, + recipient, + setRecipient, + hasRefund, + setHasRefund, + isCustom, + setIsCustom, + recipientErrorMsg, + }; +}; + +export default useInputs; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useRelayerWithRoute.ts b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useRelayerWithRoute.ts new file mode 100644 index 0000000000..bbfcac1048 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useRelayerWithRoute.ts @@ -0,0 +1,113 @@ +import { useSearchParams } from 'react-router-dom'; +import useStateWithRoute from '../../../../../hooks/useStateWithRoute'; +import { + DEST_CHAIN_KEY, + NO_RELAYER_KEY, + POOL_KEY, + RELAYER_ENDPOINT_KEY, +} from '../../../../../constants'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useWebContext } from '@webb-tools/api-provider-environment/webb-context'; +import { OptionalActiveRelayer } from '@webb-tools/abstract-api-provider/relayer/types'; + +const useRelayerWithRoute = () => { + const [searchParams] = useSearchParams(); + + const { activeApi, apiConfig } = useWebContext(); + + // State for active selected relayer + const [relayer, setRelayer] = useStateWithRoute(RELAYER_ENDPOINT_KEY); + const [activeRelayer, setActiveRelayer] = + useState(null); + + const [destTypedChainId, poolId, noRelayer] = useMemo(() => { + const destTypedId = parseInt(searchParams.get(DEST_CHAIN_KEY) ?? ''); + const poolId = parseInt(searchParams.get(POOL_KEY) ?? ''); + + return [ + Number.isNaN(destTypedId) ? undefined : destTypedId, + Number.isNaN(poolId) ? undefined : poolId, + !!searchParams.get(NO_RELAYER_KEY), + ]; + }, [searchParams]); + + // Side effect for active relayer subsription + useEffect(() => { + if (!activeApi) { + return; + } + + const sub = activeApi.relayerManager.activeRelayerWatcher.subscribe( + (relayer) => { + // console.log('relayer', relayer); + setRelayer(relayer?.endpoint ?? ''); + setActiveRelayer(relayer); + } + ); + + return () => sub.unsubscribe(); + }, [activeApi, setRelayer]); + + // Side effect for reset the active relayer + // when no relayer is selected + useEffect(() => { + if (!noRelayer || !activeApi) { + return; + } + + const active = activeApi.relayerManager.activeRelayer; + if (active) { + activeApi.relayerManager.setActiveRelayer( + null, + activeApi.typedChainidSubject.getValue() + ); + } + }, [activeApi, noRelayer]); + + const hasSetDefaultRelayer = useRef(false); + + // Side effect for setting the default relayer + // when the relayer list is loaded and no active relayer + useEffect(() => { + if (!activeApi || noRelayer || hasSetDefaultRelayer.current) { + return; + } + + const sub = activeApi.relayerManager.listUpdated.subscribe(async () => { + const typedChainIdToUse = + destTypedChainId ?? activeApi.typedChainidSubject.getValue(); + + const target = + typeof poolId === 'number' + ? apiConfig.anchors[poolId]?.[typedChainIdToUse] + : ''; + + const relayers = + await activeApi.relayerManager.getRelayersByChainAndAddress( + typedChainIdToUse, + target + ); + + const active = activeApi.relayerManager.activeRelayer; + if (!active && relayers.length > 0) { + activeApi.relayerManager.setActiveRelayer( + relayers[0], + typedChainIdToUse + ); + hasSetDefaultRelayer.current = true; + } + }); + + // trigger the relayer list update on mount + activeApi.relayerManager.listUpdated$.next(); + + return () => sub.unsubscribe(); + }, [activeApi, apiConfig.anchors, destTypedChainId, noRelayer, poolId]); + + return { + relayer, + activeRelayer, + }; +}; + +export default useRelayerWithRoute; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useWithdrawButtonProps.tsx b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useWithdrawButtonProps.tsx new file mode 100644 index 0000000000..66ee3ad74d --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/Withdraw/private/useWithdrawButtonProps.tsx @@ -0,0 +1,494 @@ +import { Currency } from '@webb-tools/abstract-api-provider/currency/currency'; +import utxoFromVAnchorNote from '@webb-tools/abstract-api-provider/utils/utxoFromVAnchorNote'; +import { useWebContext } from '@webb-tools/api-provider-environment'; +import chainsPopulated from '@webb-tools/dapp-config/chains/chainsPopulated'; +import { ZERO_BIG_INT } from '@webb-tools/dapp-config/constants'; +import { CurrencyRole } from '@webb-tools/dapp-types/Currency'; +import { WebbError, WebbErrorCodes } from '@webb-tools/dapp-types/WebbError'; +import { NoteManager } from '@webb-tools/note-manager/note-manager'; +import { + useBalancesFromNotes, + useCurrencyBalance, + useNoteAccount, + useVAnchor, +} from '@webb-tools/react-hooks'; +import { ComponentProps, useCallback, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { formatEther, formatUnits, parseEther, parseUnits } from 'viem'; +import { + AMOUNT_KEY, + BRIDGE_PATH, + DEST_CHAIN_KEY, + HAS_REFUND_KEY, + POOL_KEY, + RECIPIENT_KEY, + TOKEN_KEY, + WITHDRAW_PATH, +} from '../../../../../constants'; +import WithdrawConfirmContainer from '../../../../../containers/WithdrawConfirmContainer/WithdrawConfirmContainer'; +import { useConnectWallet } from '../../../../../hooks/useConnectWallet'; +import handleTxError from '../../../../../utils/handleTxError'; +import validateNoteLeafIndex from '../../../../../utils/validateNoteLeafIndex'; + +export type UseWithdrawButtonPropsArgs = { + balances: ReturnType['balances']; + receivingAmount?: number; + refundAmount?: bigint; + isFeeLoading?: boolean; + totalFeeWei?: bigint; + resetFeeInfo?: () => void; +}; + +function useWithdrawButtonProps({ + balances, + isFeeLoading, + receivingAmount, + refundAmount, + resetFeeInfo, + totalFeeWei, +}: UseWithdrawButtonPropsArgs) { + const navigate = useNavigate(); + + const [searchParams] = useSearchParams(); + + const { + activeApi, + activeChain, + apiConfig, + isConnecting, + loading, + switchChain, + activeWallet, + noteManager, + } = useWebContext(); + + const [amount, destTypedChainId, poolId, tokenId] = useMemo(() => { + const amountStr = searchParams.get(AMOUNT_KEY) ?? ''; + const destTypedIdStr = searchParams.get(DEST_CHAIN_KEY) ?? ''; + const poolId = searchParams.get(POOL_KEY) ?? ''; + const tokenId = searchParams.get(TOKEN_KEY) ?? ''; + + return [ + amountStr ? formatEther(BigInt(amountStr)) : undefined, + !Number.isNaN(parseInt(destTypedIdStr)) + ? parseInt(destTypedIdStr) + : undefined, + !Number.isNaN(parseInt(poolId)) ? parseInt(poolId) : undefined, + !Number.isNaN(parseInt(tokenId)) ? parseInt(tokenId) : undefined, + ]; + }, [searchParams]); + + const [recipient, hasRefund] = useMemo(() => { + const recipientStr = searchParams.get(RECIPIENT_KEY) ?? ''; + const hasRefundStr = searchParams.get(HAS_REFUND_KEY) ?? ''; + + return [recipientStr ? recipientStr : undefined, !!hasRefundStr]; + }, [searchParams]); + + const [fungibleCfg, wrappableCfg, destChainCfg] = useMemo(() => { + return [ + typeof poolId === 'number' ? apiConfig.currencies[poolId] : undefined, + typeof tokenId === 'number' ? apiConfig.currencies[tokenId] : undefined, + typeof destTypedChainId === 'number' ? apiConfig.chains[destTypedChainId] : undefined + ]; + }, [apiConfig.chains, apiConfig.currencies, destTypedChainId, poolId, tokenId]); // prettier-ignore + + const fungibleAddress = useMemo(() => { + if (typeof destTypedChainId !== 'number' || !fungibleCfg) { + return undefined; + } + + return fungibleCfg.addresses.get(destTypedChainId); + }, [destTypedChainId, fungibleCfg]); + + const liquidityPool = useCurrencyBalance( + wrappableCfg && wrappableCfg.role !== CurrencyRole.Governable + ? wrappableCfg.id + : undefined, + fungibleAddress, + destTypedChainId + ); + + const { hasNoteAccount, setOpenNoteAccountModal } = useNoteAccount(); + + const { isWalletConnected, toggleModal } = useConnectWallet(); + + const isSucficientLiq = useMemo(() => { + if (!wrappableCfg) { + // No wrappable selected, no need to check + return true; + } + + // Wrappable is the same as fungible, no unwrap + if (wrappableCfg.id === fungibleCfg?.id) { + return true; + } + + // No amount, no need to check + if (!amount) { + return true; + } + + const amountFloat = Number(amount); + + if (typeof liquidityPool !== 'number' && amountFloat > 0) { + return true; + } + + if (!liquidityPool) { + return false; + } + + return liquidityPool >= amountFloat; + }, [amount, fungibleCfg?.id, liquidityPool, wrappableCfg]); + + const isValidAmount = useMemo(() => { + if (!fungibleCfg) { + return false; + } + + if (typeof destTypedChainId !== 'number') { + return false; + } + + if (!amount) { + return false; + } + + const amountFloat = parseFloat(amount); + const balance = balances[fungibleCfg.id]?.[destTypedChainId]; + if (typeof balance !== 'bigint' && amountFloat > 0) { + return true; + } + + if (!balance || amountFloat <= 0) { + return false; + } + + if (typeof receivingAmount !== 'number') { + return false; + } + + return parseEther(amount) <= balance && receivingAmount >= 0; + }, [amount, balances, destTypedChainId, fungibleCfg, receivingAmount]); + + const connCnt = useMemo(() => { + if (!activeApi) { + return 'Connect Wallet'; + } + + if (!hasNoteAccount) { + return 'Create Note Account'; + } + + const activeId = activeApi.typedChainidSubject.getValue(); + if (activeId !== destTypedChainId) { + return 'Switch Chain'; + } + + return undefined; + }, [activeApi, destTypedChainId, hasNoteAccount]); + + const inputCnt = useMemo( + () => { + if (!destTypedChainId) { + return 'Select chain'; + } + + if (!fungibleCfg) { + return 'Select pool'; + } + + if (!amount) { + return 'Enter amount'; + } + + if (!wrappableCfg) { + return 'Select withdraw token'; + } + + if (!recipient) { + return 'Enter recipient'; + } + + if (!isSucficientLiq) { + return 'Insufficient liquidity'; + } + + if (!isValidAmount) { + return 'Insufficient balance'; + } + }, + // prettier-ignore + [amount, destTypedChainId, fungibleCfg, isSucficientLiq, isValidAmount, recipient, wrappableCfg] + ); + + const btnText = useMemo(() => { + if (inputCnt) { + return inputCnt; + } + + if (connCnt) { + return connCnt; + } + + if (fungibleCfg && fungibleCfg.id !== wrappableCfg?.id) { + return 'Withdraw and Unwrap'; + } + + return 'Withdraw'; + }, [connCnt, fungibleCfg, inputCnt, wrappableCfg?.id]); + + const isDisabled = useMemo( + () => { + const allInputsFilled = + !!amount && !!fungibleCfg && !!wrappableCfg && !!recipient; + + const userInputValid = + allInputsFilled && isSucficientLiq && isValidAmount; + + if (!userInputValid || isFeeLoading) { + return true; + } + + if (!isWalletConnected || !hasNoteAccount) { + return false; + } + + const isDestChainActive = + destChainCfg && + destChainCfg.id === activeChain?.id && + destChainCfg.chainType === activeChain?.chainType; + if (!activeChain || !isDestChainActive) { + return false; + } + + return false; + }, + // prettier-ignore + [activeChain, amount, destChainCfg, fungibleCfg, hasNoteAccount, isFeeLoading, isSucficientLiq, isValidAmount, isWalletConnected, recipient, wrappableCfg] + ); + + const isLoading = useMemo(() => { + return loading || isConnecting; + }, [isConnecting, loading]); + + const { api: vAnchorApi } = useVAnchor(); + + const [withdrawConfirmComponent, setWithdrawConfirmComponent] = + useState, + typeof WithdrawConfirmContainer + > | null>(null); + + const handleSwitchChain = useCallback( + async () => { + if (typeof destTypedChainId !== 'number') { + return; + } + + const nextChain = chainsPopulated[destTypedChainId]; + if (!nextChain) { + throw WebbError.from(WebbErrorCodes.UnsupportedChain); + } + + const isNextChainActive = + activeChain?.id === nextChain.id && + activeChain?.chainType === nextChain.chainType; + + if (!isWalletConnected || !isNextChainActive) { + if (activeWallet && nextChain.wallets.includes(activeWallet.id)) { + await switchChain(nextChain, activeWallet); + } else { + toggleModal(true, nextChain); + } + return; + } + + if (!hasNoteAccount) { + setOpenNoteAccountModal(true); + } + }, + // prettier-ignore + [activeChain?.chainType, activeChain?.id, activeWallet, destTypedChainId, hasNoteAccount, isWalletConnected, setOpenNoteAccountModal, switchChain, toggleModal] + ); + + const handleWithdrawBtnClick = useCallback( + async () => { + try { + if (connCnt) { + return await handleSwitchChain(); + } + + // For type assertion + const _validAmount = + isValidAmount && !!amount && typeof receivingAmount === 'number'; + + const allInputsFilled = + !!destChainCfg && + !!fungibleCfg && + !!destTypedChainId && + !!recipient && + _validAmount; + + const doesApiReady = + !!activeApi?.state.activeBridge && !!vAnchorApi && !!noteManager; + + if (!allInputsFilled || !doesApiReady) { + throw WebbError.from(WebbErrorCodes.ApiNotReady); + } + + if (activeApi.state.activeBridge?.currency.id !== fungibleCfg.id) { + throw WebbError.from(WebbErrorCodes.InvalidArguments); + } + + const anchorId = activeApi.state.activeBridge.targets[destTypedChainId]; + if (!anchorId) { + throw WebbError.from(WebbErrorCodes.AnchorIdNotFound); + } + + const resourceId = await vAnchorApi.getResourceId( + anchorId, + destChainCfg.id, + destChainCfg.chainType + ); + + const avaiNotes = ( + noteManager.getNotesOfChain(resourceId.toString()) ?? [] + ).filter( + (note) => + note.note.tokenSymbol === fungibleCfg.symbol && + !!fungibleCfg.addresses.get(parseInt(note.note.targetChainId)) + ); + + const fungibleDecimals = fungibleCfg.decimals; + const amountFloat = parseFloat(amount); + const amountBig = parseUnits(amount, fungibleDecimals); + + // Get the notes that will be spent for this withdraw + const inputNotes = NoteManager.getNotesFifo(avaiNotes, amountBig); + if (!inputNotes) { + throw WebbError.from(WebbErrorCodes.NoteParsingFailure); + } + + // Validate the input notes + const edges = await vAnchorApi.getLatestNeighborEdges( + fungibleCfg.id, + destTypedChainId + ); + const nextIdx = await vAnchorApi.getNextIndex( + destTypedChainId, + fungibleCfg.id + ); + + const valid = inputNotes.every((note) => { + if (note.note.targetChainId === destTypedChainId.toString()) { + return note.note.index ? BigInt(note.note.index) < nextIdx : true; + } else { + return validateNoteLeafIndex(note, edges); + } + }); + + if (!valid) { + throw WebbError.from(WebbErrorCodes.NotesNotReady); + } + + // Sum up the amount of the input notes to calculate the change amount + const totalAmountInput = inputNotes.reduce( + (acc, note) => acc + BigInt(note.note.amount), + ZERO_BIG_INT + ); + + const changeAmount = totalAmountInput - amountBig; + if (changeAmount < 0) { + throw WebbError.from(WebbErrorCodes.InvalidArguments); + } + + const keypair = noteManager.getKeypair(); + if (!keypair.privkey) { + throw WebbError.from(WebbErrorCodes.KeyPairNotFound); + } + + const changeNote = + changeAmount > 0 + ? await noteManager.generateNote( + activeApi.backend, + destTypedChainId, + anchorId, + destTypedChainId, + anchorId, + fungibleCfg.symbol, + fungibleDecimals, + changeAmount + ) + : undefined; + + // Generate change utxo (or dummy utxo if the changeAmount is `0`) + const changeUtxo = changeNote + ? await utxoFromVAnchorNote( + changeNote.note, + changeNote.note.index ? parseInt(changeNote.note.index) : 0 + ) + : await activeApi.generateUtxo({ + curve: noteManager.defaultNoteGenInput.curve, + backend: activeApi.backend, + amount: changeAmount.toString(), + chainId: `${destTypedChainId}`, + keypair, + originChainId: `${destTypedChainId}`, + index: activeApi.state.defaultUtxoIndex.toString(), + }); + + setWithdrawConfirmComponent( + { + resetFeeInfo?.(); + setWithdrawConfirmComponent(null); + navigate(`/${BRIDGE_PATH}/${WITHDRAW_PATH}`); + }} + onClose={() => { + setWithdrawConfirmComponent(null); + }} + /> + ); + } catch (error) { + handleTxError(error, 'Withdraw'); + } + }, + // prettier-ignore + [activeApi, amount, connCnt, destChainCfg, destTypedChainId, fungibleCfg, handleSwitchChain, hasRefund, isValidAmount, navigate, noteManager, receivingAmount, recipient, refundAmount, resetFeeInfo, totalFeeWei, vAnchorApi, wrappableCfg] + ); + + return { + isLoading, + isDisabled, + children: btnText, + withdrawConfirmComponent, + onClick: handleWithdrawBtnClick, + }; +} + +export default useWithdrawButtonProps; diff --git a/apps/bridge-dapp/src/pages/Hubble/Bridge/index.tsx b/apps/bridge-dapp/src/pages/Hubble/Bridge/index.tsx new file mode 100644 index 0000000000..c32a702a80 --- /dev/null +++ b/apps/bridge-dapp/src/pages/Hubble/Bridge/index.tsx @@ -0,0 +1,286 @@ +import { ErrorBoundary } from '@sentry/react'; +import { useWebContext } from '@webb-tools/api-provider-environment'; +import { ChainConfig } from '@webb-tools/dapp-config'; +import { ArrowRightUp } from '@webb-tools/icons'; +import { useNoteAccount } from '@webb-tools/react-hooks'; +import { Note } from '@webb-tools/sdk-core'; +import { + ErrorFallback, + TabContent, + TableAndChartTabs, + Typography, + useWebbUI, +} from '@webb-tools/webb-ui-components'; +import { STATS_URL } from '@webb-tools/webb-ui-components/constants'; +import cx from 'classnames'; +import { FC, useCallback, useMemo, useState } from 'react'; +import { Outlet } from 'react-router-dom'; +import { InteractiveFeedbackView, WalletModal } from '../../../components'; +import { FilterButton, ManageButton } from '../../../components/tables'; +import { + CreateAccountModal, + DeleteNotesModal, + UploadSpendNoteModal, +} from '../../../containers'; +import { + ShieldedAssetsTableContainer, + SpendNotesTableContainer, +} from '../../../containers/note-account-tables'; +import { NoteAccountTableContainerProps } from '../../../containers/note-account-tables/types'; +import { + useShieldedAssets, + useSpendNotes, + useTryAnotherWalletWithView, +} from '../../../hooks'; +import { BridgeTabType } from '../../../types'; +import { downloadNotes } from '../../../utils'; + +const shieldedAssetsTab = 'Shielded Assets'; +const spendNotesTab = 'Available Spend Notes'; + +const Bridge: FC = () => { + // State for the tabs + const [, setActiveTab] = useState('Deposit'); + + const { activeFeedback, noteManager } = useWebContext(); + + // Upload modal state + const [isUploadModalOpen, setUploadModalIsOpen] = useState(false); + + // Callback to open upload modal + const handleOpenUploadModal = useCallback(() => { + setUploadModalIsOpen(true); + }, []); + + // Callback to change the active tab + const handleChangeTab = useCallback( + (tab: 'Deposit' | 'Withdraw' | 'Transfer') => { + setActiveTab(tab); + }, + [] + ); + + const { + allNotes, + isOpenNoteAccountModal, + isSuccessfullyCreatedNoteAccount, + setOpenNoteAccountModal, + setSuccessfullyCreatedNoteAccount, + } = useNoteAccount(); + + const { notificationApi } = useWebbUI(); + + const [deleteNotes, setDeleteNotes] = useState(undefined); + + // download all notes + const handleDownloadAllNotes = useCallback(async () => { + if (!allNotes.size) { + notificationApi({ + variant: 'error', + message: 'No notes to download', + }); + return; + } + + // Serialize all notes to array of string + const notes = Array.from(allNotes.values()).reduce((acc, curr) => { + return acc.concat(curr); + }, [] as Note[]); + + downloadNotes(notes); + }, [allNotes, notificationApi]); + + const [globalSearchText, setGlobalSearchText] = useState(''); + + const sharedNoteAccountTableContainerProps = + useMemo( + () => ({ + onActiveTabChange: handleChangeTab, + onUploadSpendNote: handleOpenUploadModal, + onDeleteNotesChange: (notes) => setDeleteNotes(notes), + globalSearchText: globalSearchText, + }), + [handleChangeTab, handleOpenUploadModal, globalSearchText] + ); + + // Shielded assets table data + const shieldedAssetsTableData = useShieldedAssets(); + + // Spend notes table data + const spendNotesTableData = useSpendNotes(); + + const [activeTable, setActiveTable] = useState< + 'Shielded Assets' | 'Available Spend Notes' + >('Shielded Assets'); + + const destinationChains = useMemo(() => { + return shieldedAssetsTableData.map((asset) => asset.chain); + }, [shieldedAssetsTableData]); + + const [selectedChains, setSelectedChains] = useState< + 'all' | [string, ChainConfig][] + >('all'); + + const shieldedAssetsFilteredTableData = useMemo(() => { + if (selectedChains === 'all') { + return shieldedAssetsTableData; + } + return shieldedAssetsTableData.filter((asset) => + selectedChains.some( + (chain: any) => + chain['1'].name.toLowerCase() === asset.chain.toLowerCase() + ) + ); + }, [selectedChains, shieldedAssetsTableData]); + + const spendNotesFilteredTableData = useMemo(() => { + if (selectedChains === 'all') { + return spendNotesTableData; + } + return spendNotesTableData.filter((note) => + selectedChains.some( + (chain: any) => + chain['1'].name.toLowerCase() === note.chain.toLowerCase() + ) + ); + }, [selectedChains, spendNotesTableData]); + + const clearAllFilters = useCallback(() => { + setSelectedChains('all'); + setGlobalSearchText(''); + }, []); + + // Try again for try another wallet link + // in the token list + const { TryAnotherWalletModal } = useTryAnotherWalletWithView(); + + const noteAccountTabsRightButtons = useMemo( + () => ( +
+ + +
+ ), + [ + activeTable, + destinationChains, + globalSearchText, + selectedChains, + clearAllFilters, + setGlobalSearchText, + handleOpenUploadModal, + handleDownloadAllNotes, + ] + ); + + return ( + <> + }> +
+ {/** Bridge tabs */} + + + + + Explore Stats + + + + {/*
+ {isDisplayTxQueueCard && ( + + )} + +
*/} +
+
+ + {/** Account stats table */} + {noteManager && ( +
+ setActiveTable(val as typeof activeTable)} + filterComponent={noteAccountTabsRightButtons} + > + {/* Shielded Assets Table */} + + + + + {/* Spend Notes Table */} + + + + +
+ )} + + {/** Last login */} + + setUploadModalIsOpen(isOpen)} + /> + + + + setDeleteNotes(notes)} + /> + + + + setOpenNoteAccountModal(isOpen)} + isSuccess={isSuccessfullyCreatedNoteAccount} + onIsSuccessChange={(success) => + setSuccessfullyCreatedNoteAccount(success) + } + /> + + + + ); +}; + +export default Bridge; diff --git a/apps/bridge-dapp/src/routes/index.tsx b/apps/bridge-dapp/src/routes/index.tsx index 0c71b104bb..5aa7b5b70d 100644 --- a/apps/bridge-dapp/src/routes/index.tsx +++ b/apps/bridge-dapp/src/routes/index.tsx @@ -1,8 +1,31 @@ -import { RouterConfigData } from '@webb-tools/api-provider-environment'; import { BareProps } from '@webb-tools/dapp-types'; import { Spinner } from '@webb-tools/icons'; +import { AnimatePresence } from 'framer-motion'; import { FC, lazy, Suspense } from 'react'; +import { Navigate, Route, Routes } from 'react-router'; +import { HashRouter } from 'react-router-dom'; +import { + BRIDGE_PATH, + DEPOSIT_PATH, + ECOSYSTEM_PATH, + NOTE_ACCOUNT_PATH, + SELECT_DESTINATION_CHAIN_PATH, + SELECT_RELAYER_PATH, + SELECT_SHIELDED_POOL_PATH, + SELECT_SOURCE_CHAIN_PATH, + SELECT_TOKEN_PATH, + TRANSFER_PATH, + WITHDRAW_PATH, + WRAP_UNWRAP_PATH, +} from '../constants'; import { Layout } from '../containers'; +import Deposit from '../pages/Hubble/Bridge/Deposit'; +import SelectChain from '../pages/Hubble/Bridge/SelectChain'; +import SelectRelayer from '../pages/Hubble/Bridge/SelectRelayer'; +import SelectToken from '../pages/Hubble/Bridge/SelectToken'; +import Transfer from '../pages/Hubble/Bridge/Transfer'; +import Withdraw from '../pages/Hubble/Bridge/Withdraw'; +import SelectPool from '../pages/Hubble/Bridge/SelectPool'; const Bridge = lazy(() => import('../pages/Hubble/Bridge')); const WrapAndUnwrap = lazy(() => import('../pages/Hubble/WrapAndUnwrap')); @@ -23,51 +46,117 @@ const CSuspense: FC = ({ children }) => { ); }; -export const config: RouterConfigData[] = [ - { - children: [ - { - element: ( - - - - ), - path: 'bridge', - title: 'Bridge', - }, - { - element: ( - - - - ), - path: 'wrap-unwrap', - title: 'Wrap/Unwrap', - }, - { - element: ( - - - - ), - path: 'account', - title: 'Account', - }, - { - element: ( - - - - ), - path: 'ecosystem', - title: 'Ecosystem', - }, - { - path: '*', - redirectTo: 'bridge', - }, - ], - element: , - path: '*', - }, -].filter((elt) => elt.path !== 'null'); +const BridgeRoutes = () => { + return ( + + + + + + + } + > + + + + } + > + {/** Deposit */} + }> + } + /> + } + /> + } /> + } + /> + + + {/** Transfer */} + }> + } + /> + } + /> + } + /> + } /> + + + {/** Withdraw */} + }> + } + /> + } + /> + } /> + } /> + + + {/** Select connected chain */} + } + /> + + } /> + + + + + + } + /> + + + + + } + /> + + + + + } + /> + + } /> + + + + + ); +}; + +export default BridgeRoutes; diff --git a/apps/bridge-dapp/src/styles.css b/apps/bridge-dapp/src/styles.css index aa30857fd9..d25430cebe 100644 --- a/apps/bridge-dapp/src/styles.css +++ b/apps/bridge-dapp/src/styles.css @@ -5,3 +5,7 @@ [hidden] { display: none !important; } + +:root { + --card-height: 700px; +} diff --git a/apps/bridge-dapp/src/types/index.ts b/apps/bridge-dapp/src/types/index.ts index 30f2223ce8..b1788beaa6 100644 --- a/apps/bridge-dapp/src/types/index.ts +++ b/apps/bridge-dapp/src/types/index.ts @@ -1,3 +1,9 @@ // Shared types for the bridge dapp +import { DEST_CHAIN_KEY, SOURCE_CHAIN_KEY } from '../constants'; + export type BridgeTabType = 'Deposit' | 'Withdraw' | 'Transfer'; + +export type QueryParamsType = { + [key in typeof SOURCE_CHAIN_KEY | typeof DEST_CHAIN_KEY]: string | undefined; +}; diff --git a/apps/bridge-dapp/src/utils/downloadNotes.ts b/apps/bridge-dapp/src/utils/downloadNotes.ts index 34340478a7..3033b13fd0 100644 --- a/apps/bridge-dapp/src/utils/downloadNotes.ts +++ b/apps/bridge-dapp/src/utils/downloadNotes.ts @@ -1,6 +1,5 @@ import { downloadString } from '@webb-tools/browser-utils'; import { Note } from '@webb-tools/sdk-core'; -import React from 'react'; /** * Convert notes to strings and download them as json file diff --git a/apps/bridge-dapp/src/utils/getMessageFromTransactionState.ts b/apps/bridge-dapp/src/utils/getMessageFromTransactionState.ts index 03cddd743b..c7b42e2003 100644 --- a/apps/bridge-dapp/src/utils/getMessageFromTransactionState.ts +++ b/apps/bridge-dapp/src/utils/getMessageFromTransactionState.ts @@ -1,5 +1,4 @@ import { TransactionState } from '@webb-tools/abstract-api-provider'; -import React from 'react'; export const getMessageFromTransactionState = (state: TransactionState) => { switch (state) { diff --git a/apps/bridge-dapp/src/utils/getTokenURI.ts b/apps/bridge-dapp/src/utils/getTokenURI.ts index dcdae16c7d..1a178b7828 100644 --- a/apps/bridge-dapp/src/utils/getTokenURI.ts +++ b/apps/bridge-dapp/src/utils/getTokenURI.ts @@ -6,7 +6,7 @@ export const getTokenURI = (currency: CurrencyConfig, typedChainId: string) => { if (!explorerUrl) return '#'; - const addr = currency.addresses.get(+typedChainId); + const addr = currency.addresses.get(parseInt(typedChainId)); return new URL(`/address/${addr ?? ''}`, explorerUrl).toString(); }; diff --git a/apps/bridge-dapp/src/utils/handleMutateNoteIndex.ts b/apps/bridge-dapp/src/utils/handleMutateNoteIndex.ts new file mode 100644 index 0000000000..91427eb75a --- /dev/null +++ b/apps/bridge-dapp/src/utils/handleMutateNoteIndex.ts @@ -0,0 +1,25 @@ +import type { VAnchorActions } from '@webb-tools/abstract-api-provider/vanchor/vanchor-actions'; +import { Note } from '@webb-tools/sdk-core/note'; +import type { Hash } from 'viem'; + +const handleMutateNoteIndex = async ( + vanchorApi: VAnchorActions, // TODO: remove any + txHash: Hash, + note: Note, + indexBeforeInsert: number, + anchorId: string +) => { + const noteIndex = await vanchorApi.getLeafIndex( + txHash, + note, + indexBeforeInsert, + anchorId + ); + + const indexedNote = await Note.deserialize(note.serialize()); + indexedNote.mutateIndex(noteIndex.toString()); + + return indexedNote; +}; + +export default handleMutateNoteIndex; diff --git a/apps/bridge-dapp/src/utils/handleStoreNote.ts b/apps/bridge-dapp/src/utils/handleStoreNote.ts new file mode 100644 index 0000000000..4bf6568a9a --- /dev/null +++ b/apps/bridge-dapp/src/utils/handleStoreNote.ts @@ -0,0 +1,21 @@ +import downloadString from '@webb-tools/browser-utils/download/downloadString'; +import { Note } from '@webb-tools/sdk-core/note'; + +async function handleStoreNote( + note?: Note, + addNoteToNoteManager?: (note: Note) => Promise +) { + if (!note) { + return; + } + + const changeNoteStr = note.serialize(); + downloadString( + JSON.stringify(changeNoteStr), + changeNoteStr.slice(0, changeNoteStr.length - 10) + '.json' + ); + + await addNoteToNoteManager?.(note); +} + +export default handleStoreNote; diff --git a/apps/bridge-dapp/src/utils/handleTxError.ts b/apps/bridge-dapp/src/utils/handleTxError.ts new file mode 100644 index 0000000000..d10f09144b --- /dev/null +++ b/apps/bridge-dapp/src/utils/handleTxError.ts @@ -0,0 +1,25 @@ +import { type TransactionName } from '@webb-tools/abstract-api-provider/transaction'; +import { WebbError, WebbErrorCodes } from '@webb-tools/dapp-types/WebbError'; +import { notificationApi } from '@webb-tools/webb-ui-components/components/Notification/NotificationStacked'; + +function handleTxError(error: unknown, txType?: TransactionName) { + let displayErrorMessage = WebbError.getErrorMessage( + WebbErrorCodes.UnknownError + ).message; + + if (error instanceof WebbError) { + displayErrorMessage = error.message; + } else if (error instanceof Error) { + displayErrorMessage = error.message; + } else { + console.error('Detected unknown error', error); + } + + notificationApi({ + variant: 'error', + message: `${txType ?? 'Transaction'} failed`, + secondaryMessage: displayErrorMessage, + }); +} + +export default handleTxError; diff --git a/apps/bridge-dapp/src/utils/index.ts b/apps/bridge-dapp/src/utils/index.ts index 09b3ade8ec..edac1e2d5c 100644 --- a/apps/bridge-dapp/src/utils/index.ts +++ b/apps/bridge-dapp/src/utils/index.ts @@ -6,4 +6,8 @@ export * from './getDefaultConnection'; export * from './getMessageFromTransactionState'; export * from './getTokenURI'; export { default as getVAnchorActionClass } from './getVAnchorActionClass'; +export { default as handleMutateNoteIndex } from './handleMutateNoteIndex'; +export { default as handleStoreNote } from './handleStoreNote'; +export { default as handleTxError } from './handleTxError'; export * from './isValidNote'; +export { default as validateNoteLeafIndex } from './validateNoteLeafIndex'; diff --git a/apps/bridge-dapp/src/utils/validateNoteLeafIndex.ts b/apps/bridge-dapp/src/utils/validateNoteLeafIndex.ts new file mode 100644 index 0000000000..6b7852f68d --- /dev/null +++ b/apps/bridge-dapp/src/utils/validateNoteLeafIndex.ts @@ -0,0 +1,25 @@ +import { NeighborEdge } from '@webb-tools/abstract-api-provider/vanchor/types'; +import { Note } from '@webb-tools/sdk-core/note'; + +function validateNoteLeafIndex( + note: Note, + edges: ReadonlyArray +): boolean { + const { index, targetChainId } = note.note; + + // If the index is empty, we don't need to validate + if (!index) { + return true; + } + + // Find the edge by target/destination typed chain id + const edge = edges.find((e) => e.chainID === BigInt(targetChainId)); + + if (!edge) { + return false; + } + + return BigInt(index) <= edge.latestLeafIndex; +} + +export default validateNoteLeafIndex; diff --git a/apps/bridge-dapp/tailwind.config.js b/apps/bridge-dapp/tailwind.config.js index 9decb69f9c..93421933a0 100644 --- a/apps/bridge-dapp/tailwind.config.js +++ b/apps/bridge-dapp/tailwind.config.js @@ -1,10 +1,8 @@ -/** @type {import('tailwindcss').Config} */ - const preset = require('@webb-tools/tailwind-preset'); -const defaultTheme = require('tailwindcss/defaultTheme'); const { createGlobPatternsForDependencies } = require('@nx/react/tailwind'); const { join } = require('path'); +/** @type {import('tailwindcss').Config} */ module.exports = { presets: [preset], content: [ @@ -12,9 +10,10 @@ module.exports = { ...createGlobPatternsForDependencies(__dirname), ], theme: { - screens: { - mob: '481px', - ...defaultTheme.screens, + extend: { + screens: { + mob: '481px', + }, }, }, plugins: [], diff --git a/apps/bridge-dapp/webpack.base.js b/apps/bridge-dapp/webpack.base.js index 2dc167e553..b4d206e740 100644 --- a/apps/bridge-dapp/webpack.base.js +++ b/apps/bridge-dapp/webpack.base.js @@ -135,6 +135,7 @@ function createWebpack(env, mode = 'production') { ], plugins: [ isDevelopment && require.resolve('react-refresh/babel'), + 'transform-class-properties', ['@babel/plugin-transform-runtime', { loose: false }], ['@babel/plugin-proposal-class-properties', { loose: true }], [ diff --git a/apps/hubble-stats/components/PoolOverviewTable/PoolOverviewTable.tsx b/apps/hubble-stats/components/PoolOverviewTable/PoolOverviewTable.tsx index 4365e0c744..702f618e49 100644 --- a/apps/hubble-stats/components/PoolOverviewTable/PoolOverviewTable.tsx +++ b/apps/hubble-stats/components/PoolOverviewTable/PoolOverviewTable.tsx @@ -1,21 +1,21 @@ 'use client'; -import { FC, useMemo } from 'react'; import { - createColumnHelper, - useReactTable, - getCoreRowModel, ColumnDef, Table as RTTable, + createColumnHelper, + getCoreRowModel, + useReactTable, } from '@tanstack/react-table'; +import { chainsConfig } from '@webb-tools/dapp-config/chains'; +import { ShieldKeyholeLineIcon } from '@webb-tools/icons'; import { ChainChip, Table, - fuzzyFilter, Typography, + fuzzyFilter, } from '@webb-tools/webb-ui-components'; -import { ShieldedAssetLight, ShieldedAssetDark } from '@webb-tools/icons'; -import { chainsConfig } from '@webb-tools/dapp-config/chains'; +import { FC, useMemo } from 'react'; import { PoolOverviewDataType, PoolOverviewTableProps } from './types'; import { HeaderCell, NumberCell } from '../table'; @@ -28,8 +28,7 @@ const staticColumns: ColumnDef[] = [ header: () => null, cell: (props) => (
- - + = ({ } return ( -
+
= ({ className }) => { className )} > - - + shielded ); diff --git a/apps/hubble-stats/components/table/ShieldedCell.tsx b/apps/hubble-stats/components/table/ShieldedCell.tsx index aaffc64995..12a5e4756b 100644 --- a/apps/hubble-stats/components/table/ShieldedCell.tsx +++ b/apps/hubble-stats/components/table/ShieldedCell.tsx @@ -1,9 +1,8 @@ -import { FC } from 'react'; -import Link from 'next/link'; import { Typography } from '@webb-tools/webb-ui-components'; -import { ShieldedAssetDark, ShieldedAssetLight } from '@webb-tools/icons'; import { shortenHex } from '@webb-tools/webb-ui-components/utils'; - +import Link from 'next/link'; +import { FC } from 'react'; +import ShieldedAssetIcon from '@webb-tools/icons/ShieldedAssetIcon'; import { ShieldedCellProps } from './types'; const ShieldedCell: FC = ({ @@ -13,8 +12,7 @@ const ShieldedCell: FC = ({ }) => { return (
- - +
diff --git a/apps/hubble-stats/containers/PoolOverviewCardContainer/PoolOverviewCardContainer.tsx b/apps/hubble-stats/containers/PoolOverviewCardContainer/PoolOverviewCardContainer.tsx index 04a2075170..a58d79a2c8 100644 --- a/apps/hubble-stats/containers/PoolOverviewCardContainer/PoolOverviewCardContainer.tsx +++ b/apps/hubble-stats/containers/PoolOverviewCardContainer/PoolOverviewCardContainer.tsx @@ -1,12 +1,11 @@ -import cx from 'classnames'; +import { ShieldedAssetIcon } from '@webb-tools/icons'; import { Typography } from '@webb-tools/webb-ui-components'; import { shortenHex } from '@webb-tools/webb-ui-components/utils'; -import { ShieldedAssetLight, ShieldedAssetDark } from '@webb-tools/icons'; - +import cx from 'classnames'; import { - PoolTypeChip, - PoolOverviewCardItem, CopyIconWithTooltip, + PoolOverviewCardItem, + PoolTypeChip, } from '../../components'; import { getPoolOverviewCardData } from '../../data'; @@ -37,17 +36,7 @@ export default async function PoolOverviewCardContainer({
{/* Icon */} - - - + {/* Name */} diff --git a/apps/stats-dapp/src/containers/NavBlocksInfoContainer/NavBlocksInfoContainer.tsx b/apps/stats-dapp/src/containers/NavBlocksInfoContainer/NavBlocksInfoContainer.tsx index 9ced39f088..4e091fb3fa 100644 --- a/apps/stats-dapp/src/containers/NavBlocksInfoContainer/NavBlocksInfoContainer.tsx +++ b/apps/stats-dapp/src/containers/NavBlocksInfoContainer/NavBlocksInfoContainer.tsx @@ -2,15 +2,15 @@ import { useMemo } from 'react'; import { GridFillIcon, KeyIcon, - ShieldKeyholeIcon, + ShieldKeyholeLineIcon, TeamFillIcon, UserStarFillIcon, FoldersFillIcon, FileCodeLineIcon, GraphIcon, BlockIcon, - RefreshIcon, Spinner, + RefreshLineIcon, } from '@webb-tools/icons'; import { Breadcrumbs, @@ -76,7 +76,7 @@ export const NavBoxInfoContainer = () => { + ) : currentPage === 'authorities' && subPage !== 'history' ? ( ) : currentPage === 'authorities' && subPage === 'history' ? ( @@ -118,7 +118,10 @@ export const NavBoxInfoContainer = () => { )} - {' '} + {' '} {currentSessionNumber ? ( `Session: ${currentSessionNumber}` ) : ( diff --git a/libs/abstract-api-provider/src/relayer/webb-relayer-manager.ts b/libs/abstract-api-provider/src/relayer/webb-relayer-manager.ts index 832e675935..295ab78dfb 100644 --- a/libs/abstract-api-provider/src/relayer/webb-relayer-manager.ts +++ b/libs/abstract-api-provider/src/relayer/webb-relayer-manager.ts @@ -30,6 +30,8 @@ export abstract class WebbRelayerManager { private _listUpdated = new Subject(); public readonly listUpdated: Observable; + public readonly listUpdated$ = this._listUpdated; + protected relayers: WebbRelayer[]; public activeRelayer: OptionalActiveRelayer = null; @@ -39,11 +41,8 @@ export abstract class WebbRelayerManager { this.listUpdated = this._listUpdated.asObservable(); } - async setActiveRelayer( - relayer: WebbRelayer | null, - typedChainId: number - ): Promise { - const active = await this.mapRelayerIntoActive(relayer, typedChainId); + setActiveRelayer(relayer: WebbRelayer | null, typedChainId: number): void { + const active = this.mapRelayerIntoActive(relayer, typedChainId); this.activeRelayer = active; this.activeRelayerSubject.next(active); @@ -57,7 +56,7 @@ export abstract class WebbRelayerManager { abstract mapRelayerIntoActive( relayer: OptionalRelayer, typedChainId: number - ): Promise; + ): OptionalActiveRelayer; /* * get a list of the suitable relayers for a given query diff --git a/libs/abstract-api-provider/src/transaction.ts b/libs/abstract-api-provider/src/transaction.ts index 51f1018b1b..e41fbb358b 100644 --- a/libs/abstract-api-provider/src/transaction.ts +++ b/libs/abstract-api-provider/src/transaction.ts @@ -14,12 +14,12 @@ export interface NewNotesTxResult extends TXresultBase { outputNotes: Note[]; } +export type TransactionName = 'Deposit' | 'Transfer' | 'Withdraw'; + export enum TransactionState { Cancelling, // Withdraw canceled Ideal, // initial status where the instance is Idea and ready for a withdraw - PreparingTransaction, // Preparing the arguments for a transaction - FetchingFixtures, // Zero-knowledge files need to be obtained over the network and may take time. FetchingLeavesFromRelayer, // The leaves of the merkle tree need to be obtained from the relayer ValidatingLeaves, // The leaves of the merkle tree need to be validated @@ -61,8 +61,8 @@ export type FixturesProgress = { type LeavesProgress = { start: number; - currentRange: [number, number]; - end?: number; + current: number; + end: number; }; type IntermediateProgress = { @@ -75,12 +75,10 @@ type FailedTransaction = { txHash?: string; }; -type TransactionStatusMap = { +export type TransactionStatusMap = { [TransactionState.Cancelling]: undefined; [TransactionState.Ideal]: undefined; - [TransactionState.PreparingTransaction]: undefined; - [TransactionState.FetchingFixtures]: FixturesProgress; [TransactionState.FetchingLeavesFromRelayer]: undefined; @@ -137,15 +135,25 @@ type PromiseExec = ( export class Transaction extends Promise { cancelToken: CancellationToken = new CancellationToken(); + readonly id = String(Date.now() + Math.random()); readonly timestamp = new Date(); + private _txHash: BehaviorSubject = new BehaviorSubject< string | undefined >(undefined); + // Find the max step in the transactionStepMap + public readonly totalSteps = Object.values( + transactionStepMap[this.name] + ).reduce((prev, cur) => (cur > prev ? cur : prev), 0); + + // 0 for the initial step + public readonly stepSubject = new BehaviorSubject(0); + private constructor( executor: PromiseExec, - public readonly name: string, + public readonly name: TransactionName, public readonly metaData: TransactionMetaData, private readonly _status: BehaviorSubject< [ @@ -157,7 +165,10 @@ export class Transaction extends Promise { super(executor); } - static new(name: string, metadata: TransactionMetaData): Transaction { + static new( + name: TransactionName, + metadata: TransactionMetaData + ): Transaction { const status = new BehaviorSubject< [StatusKey, TransactionStatusMap[keyof TransactionStatusMap]] >([TransactionState.Ideal, undefined]); @@ -194,6 +205,19 @@ export class Transaction extends Promise { ) { console.log('Transaction update status', [status, data]); this._status.next([status, data]); + + if ( + status === TransactionState.Done || + status === TransactionState.Failed + ) { + this.stepSubject.next(this.totalSteps + 1); + return; + } + + const step = transactionStepMap[this.name][status]; + if (typeof step === 'number') { + this.stepSubject.next(step); + } } fail(error: string): void { @@ -241,3 +265,32 @@ export class Transaction extends Promise { } }; } + +export type TransactionMap = { + [key in TransactionName]: Partial>; +}; + +const transactionStepMap: TransactionMap = { + Deposit: { + [TransactionState.Intermediate]: 1, + [TransactionState.GeneratingZk]: 2, + [TransactionState.InitializingTransaction]: 3, + [TransactionState.SendingTransaction]: 4, + }, + Transfer: { + [TransactionState.FetchingLeavesFromRelayer]: 1, + [TransactionState.FetchingLeaves]: 2, + [TransactionState.GeneratingZk]: 3, + [TransactionState.InitializingTransaction]: 4, + [TransactionState.SendingTransaction]: 5, + [TransactionState.Intermediate]: 5, + }, + Withdraw: { + [TransactionState.FetchingLeavesFromRelayer]: 1, + [TransactionState.FetchingLeaves]: 2, + [TransactionState.GeneratingZk]: 3, + [TransactionState.InitializingTransaction]: 4, + [TransactionState.SendingTransaction]: 5, + [TransactionState.Intermediate]: 5, + }, +}; diff --git a/libs/abstract-api-provider/src/utils/calculateProgressPercentage.ts b/libs/abstract-api-provider/src/utils/calculateProgressPercentage.ts new file mode 100644 index 0000000000..78090f94ed --- /dev/null +++ b/libs/abstract-api-provider/src/utils/calculateProgressPercentage.ts @@ -0,0 +1,18 @@ +/** + * Calculates the percentage of the progress + * @param start the start of the progress + * @param end the end of the progress + * @param current the current progress + * @returns the percentage of the progress (0-100) + */ +function calculateProgressPercentage( + start: number, + end: number, + current: number +): number { + const percentage = ((current - start) / (end - start + 1)) * 100; + + return percentage >= 100 ? 100 : percentage; +} + +export default calculateProgressPercentage; diff --git a/libs/abstract-api-provider/src/utils/calculateProvingLeavesAndCommitmentIndex.ts b/libs/abstract-api-provider/src/utils/calculateProvingLeavesAndCommitmentIndex.ts index 133effcaf1..f2ad01cea2 100644 --- a/libs/abstract-api-provider/src/utils/calculateProvingLeavesAndCommitmentIndex.ts +++ b/libs/abstract-api-provider/src/utils/calculateProvingLeavesAndCommitmentIndex.ts @@ -42,6 +42,10 @@ function calculateProvingLeavesAndCommitmentIndex( worker.onmessage = (e) => { const { data } = e; + if (data.log) { + return console.log(data.log); + } + if (data.error) { reject(data.error); } else { diff --git a/libs/abstract-api-provider/src/vanchor/types.ts b/libs/abstract-api-provider/src/vanchor/types.ts new file mode 100644 index 0000000000..4936c80767 --- /dev/null +++ b/libs/abstract-api-provider/src/vanchor/types.ts @@ -0,0 +1,8 @@ +import type { Hex } from 'viem'; + +export type NeighborEdge = { + chainID: bigint; + root: bigint; + latestLeafIndex: bigint; + srcResourceID: Hex; +}; diff --git a/libs/abstract-api-provider/src/vanchor/vanchor-actions.ts b/libs/abstract-api-provider/src/vanchor/vanchor-actions.ts index fbace16df8..2d4736aee5 100644 --- a/libs/abstract-api-provider/src/vanchor/vanchor-actions.ts +++ b/libs/abstract-api-provider/src/vanchor/vanchor-actions.ts @@ -22,6 +22,7 @@ import type { WebbApiProvider, WebbProviderType, } from '../webb-provider.interface'; +import { NeighborEdge } from './types'; export type ParametersOfTransactMethod = Awaited['transact']>>; @@ -39,6 +40,8 @@ export type TransferTransactionPayloadType = { changeUtxo: Utxo; transferUtxo: Utxo; feeAmount: bigint; + refundAmount: bigint; + refundRecipient: string; }; // Union type of all the payloads that can be used in a transaction (Deposit, Transfer, Withdraw) @@ -109,7 +112,13 @@ export const isVAnchorTransferPayload = ( 'transferUtxo' in payload && payload['transferUtxo'] instanceof Utxo && 'feeAmount' in payload && - typeof payload['feeAmount'] === 'bigint' + typeof payload['feeAmount'] === 'bigint' && + ('refundAmount' in payload + ? typeof payload['refundAmount'] === 'bigint' + : true) && + ('refundRecipient' in payload + ? typeof payload['refundRecipient'] === 'string' + : true) ); }; @@ -205,7 +214,7 @@ export abstract class VAnchorActions< activeRelayer: ActiveWebbRelayer, txArgs: ParametersOfTransactMethod, changeNotes: Note[] - ): Promise; + ): Promise; /** * The transact function @@ -223,4 +232,11 @@ export abstract class VAnchorActions< wrapUnwrapToken: string, leavesMap: Record ): Promise; + + abstract waitForFinalization(hash: Hash): Promise; + + abstract getLatestNeighborEdges( + fungibleId: number, + typedChainId?: number + ): Promise>; } diff --git a/libs/api-provider-environment/src/RouterProvider.tsx b/libs/api-provider-environment/src/RouterProvider.tsx index 3c608e23d0..58db642bb0 100644 --- a/libs/api-provider-environment/src/RouterProvider.tsx +++ b/libs/api-provider-environment/src/RouterProvider.tsx @@ -1,16 +1,9 @@ import { AnimatePresence } from 'framer-motion'; +import { cloneElement, createElement, FC, ReactElement, useMemo } from 'react'; import { - cloneElement, - createElement, - FC, - ReactElement, - useEffect, - useMemo, -} from 'react'; -import { + Navigate, HashRouter as Router, useLocation, - useNavigate, useRoutes, } from 'react-router-dom'; @@ -38,14 +31,6 @@ export interface RouterConfigData { title?: string; } -export const Redirect: FC<{ to: string }> = ({ to }) => { - const navigate = useNavigate(); - - useEffect(() => navigate(to), [navigate, to]); - - return null; -}; - interface Props { config: RouterConfigData[]; setTitle?: StoreData['ui']['setTitle']; @@ -59,7 +44,7 @@ const Routes: FC = ({ config }) => { inner.forEach((item) => { // process redirect if (item.redirectTo) { - item.element = ; + item.element = ; } if (item.title && item.element) { @@ -72,7 +57,7 @@ const Routes: FC = ({ config }) => { const element = useRoutes(_config, { ...location, key: location.pathname }); - return {element};; + return {element}; }; export const RouterProvider: FC = ({ config }) => { @@ -89,7 +74,11 @@ export const RouterProvider: FC = ({ config }) => { // process redirect if (item.redirectTo) { - item.element = ; + item.element = ; + } + + if (item.title && item.element) { + item.element = createElement(withTitle(item.element, item.title)); } }); diff --git a/libs/api-provider-environment/src/WebbProvider.tsx b/libs/api-provider-environment/src/WebbProvider.tsx index cf56a82904..a5ff859cb0 100644 --- a/libs/api-provider-environment/src/WebbProvider.tsx +++ b/libs/api-provider-environment/src/WebbProvider.tsx @@ -53,6 +53,7 @@ import constants from './constants'; import { unsupportedChain } from './error'; import { insufficientApiInterface } from './error/interactive-errors/insufficient-api-interface'; import onChainDataJson from './generated/on-chain-config.json'; +import ModalQueueManagerProvider from './modal-queue-manager/ModalQueueManagerProvider'; import { StoreProvider } from './store'; import { useTxApiQueue } from './transaction'; import { WebbContext } from './webb-context'; @@ -298,7 +299,14 @@ const WebbProviderInner: FC = ({ children, appEvent }) => { const acs = await accounts.accounts(); const active = acs[0] || null; setAccounts(acs); - _setActiveAccount(active); + + if (active) { + setActiveAccount(active, { + networkStorage: _networkStorage ?? networkStorage, + chain, + activeApi: nextActiveApi, + }); + } }); } else { setActiveApi(nextActiveApi); @@ -306,7 +314,7 @@ const WebbProviderInner: FC = ({ children, appEvent }) => { _setActiveAccount(null); } }, - [setActiveAccount] + [networkStorage, setActiveAccount] ); /// Error handler for the `WebbError` @@ -535,8 +543,11 @@ const WebbProviderInner: FC = ({ children, appEvent }) => { updatedChainId, ]: number[]) => { const nextChain = Object.values(chains).find( - (chain) => chain.id === updatedChainId + (chain) => + chain.id === updatedChainId && + chain.chainType === ChainType.EVM ); + const activeChain = nextChain ? nextChain : chain; try { /// this will throw if the user switched to unsupported chain @@ -556,7 +567,7 @@ const WebbProviderInner: FC = ({ children, appEvent }) => { secondaryMessage: `Connection is switched to ${name} chain`, }); setActiveWallet(wallet); - setActiveChain(nextChain ? nextChain : chain); + setActiveChain(activeChain); const bridgeOptions: Record = {}; @@ -596,6 +607,14 @@ const WebbProviderInner: FC = ({ children, appEvent }) => { // set the available bridges of the new chain webbWeb3Provider.state.setBridgeOptions(bridgeOptions); webbWeb3Provider.state.activeBridge = defaultBridge; + + appEvent.send('networkSwitched', [ + { + chainType: activeChain.chainType, + chainId: activeChain.id, + }, + wallet.id, + ]); } catch (e) { /// set the chain to be undefined as this won't be usable // TODO mark the api as not ready @@ -831,7 +850,10 @@ const WebbProviderInner: FC = ({ children, appEvent }) => { // Set the default network to the last selected network const networkStorage = await netStorageFactory(); await Promise.all([ - networkStorage.set('defaultNetwork', chain.chainId), + networkStorage.set( + 'defaultNetwork', + calculateTypedChainId(chain.chainType, chain.chainId) + ), networkStorage.set('defaultWallet', wallet), ]); }); @@ -898,7 +920,9 @@ const WebbProviderInner: FC = ({ children, appEvent }) => { txQueue, }} > - {children} + + {children} + ); }; diff --git a/libs/api-provider-environment/src/index.ts b/libs/api-provider-environment/src/index.ts index eb6fed7013..985fcc898a 100644 --- a/libs/api-provider-environment/src/index.ts +++ b/libs/api-provider-environment/src/index.ts @@ -3,6 +3,7 @@ export * from './WebbProvider'; export * from './app-event'; export * from './constants'; export * from './error'; +export * from './modal-queue-manager'; export * from './store'; export * from './transaction'; export * from './webb-context'; diff --git a/libs/api-provider-environment/src/modal-queue-manager/ModalQueueManagerProvider.tsx b/libs/api-provider-environment/src/modal-queue-manager/ModalQueueManagerProvider.tsx new file mode 100644 index 0000000000..7593ebcdc5 --- /dev/null +++ b/libs/api-provider-environment/src/modal-queue-manager/ModalQueueManagerProvider.tsx @@ -0,0 +1,95 @@ +import type { FC, PropsWithChildren } from 'react'; +import { motion } from 'framer-motion'; +import { useCallback, useEffect, useState } from 'react'; +import ModalQueueManagerContext, { + ModalQueueItem, + type ModalQueueManagerContextType, +} from './context'; + +/** + * ModalQueueManagerProvider creates a Modal Queue Manager at the root + * of the dom tree + */ +const ModalQueueManagerProvider: FC = ({ children }) => { + const [queue, setQueue] = useState([]); + + // The current modal is tracked in state. + const [currentModal, setCurrentModal] = useState(null); + + const enqueue = useCallback( + (modal) => { + setQueue((prev) => [modal, ...prev]); + }, + [] + ); + enqueueModal = enqueue; + + const dequeue = useCallback(() => { + const newQueue = queue.slice(); + const modal = newQueue.pop(); + setQueue(newQueue); + setCurrentModal(null); + return modal; + }, [queue]); + dequeueModal = dequeue; + + useEffect(() => { + if (queue.length === 0 || currentModal) { + return; + } + + const nextModal = queue[queue.length - 1]; + if (typeof nextModal.props.onOpenChange === 'function') { + const onOpenChange = nextModal.props.onOpenChange.bind({}); + + nextModal.props.onOpenChange = (open: boolean) => { + if (!open) { + dequeue(); + } + onOpenChange(open); + }; + } + + setCurrentModal(nextModal); + }, [queue, currentModal, dequeue]); + + return ( + + {children} + + {currentModal && ( + + {currentModal} + + )} + + ); +}; + +export default ModalQueueManagerProvider; + +/** + * Utility function to enqueue a modal + * it must be used in the context of a modal queue manager + * @param modal The modal to enqueue + */ +let enqueueModal = (modal: ModalQueueItem) => { + return; +}; + +/** + * Utility function to dequeue a modal + * it must be used in the context of a modal queue manager + * @returns The first modal in the queue + */ +let dequeueModal = () => { + return; +}; + +export { dequeueModal, enqueueModal }; diff --git a/libs/api-provider-environment/src/modal-queue-manager/context.tsx b/libs/api-provider-environment/src/modal-queue-manager/context.tsx new file mode 100644 index 0000000000..cf76b5fc0d --- /dev/null +++ b/libs/api-provider-environment/src/modal-queue-manager/context.tsx @@ -0,0 +1,24 @@ +import type { DialogProps } from '@radix-ui/react-dialog'; +import noop from 'lodash/noop'; +import { createContext } from 'react'; + +type ModalQueueItem = React.ReactElement; + +type ModalQueueManagerContextType = { + queue: Array; + enqueue: (modal: ModalQueueItem) => void; + dequeue: () => ModalQueueItem | undefined; +}; + +/** + * ModalQueueManagerContext is a context that provides a queue of modals + */ +const ModalQueueManagerContext = createContext({ + queue: [], + enqueue: noop, + dequeue: () => undefined, +}); + +export default ModalQueueManagerContext; + +export type { ModalQueueItem, ModalQueueManagerContextType }; diff --git a/libs/api-provider-environment/src/modal-queue-manager/index.ts b/libs/api-provider-environment/src/modal-queue-manager/index.ts new file mode 100644 index 0000000000..1af060a98c --- /dev/null +++ b/libs/api-provider-environment/src/modal-queue-manager/index.ts @@ -0,0 +1,3 @@ +export { default as useModalQueueManager } from './useModalQueueManager'; +export { default as ModalQueueManagerContext } from './context'; +export { default as ModalQueueManagerProvider } from './ModalQueueManagerProvider'; diff --git a/libs/api-provider-environment/src/modal-queue-manager/useModalQueueManager.ts b/libs/api-provider-environment/src/modal-queue-manager/useModalQueueManager.ts new file mode 100644 index 0000000000..733b0f36be --- /dev/null +++ b/libs/api-provider-environment/src/modal-queue-manager/useModalQueueManager.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; +import ModalQueueManagerContext from './context'; + +const useModalQueueManager = () => { + const ctx = useContext(ModalQueueManagerContext); + if (!ctx) { + throw new Error( + 'useModalQueueManager must be used within a ModalQueueManagerProvider' + ); + } + return ctx; +}; + +export default useModalQueueManager; diff --git a/libs/api-provider-environment/src/transaction/useTransactionQueue.tsx b/libs/api-provider-environment/src/transaction/useTransactionQueue.tsx index 300b4e2fa9..506f6384a6 100644 --- a/libs/api-provider-environment/src/transaction/useTransactionQueue.tsx +++ b/libs/api-provider-environment/src/transaction/useTransactionQueue.tsx @@ -2,24 +2,24 @@ import { NewNotesTxResult, Transaction, TransactionState, + TransactionStatusMap, TransactionStatusValue, + WebbProviderType, } from '@webb-tools/abstract-api-provider'; -import { - ApiConfig, - ChainConfig, - CurrencyConfig, -} from '@webb-tools/dapp-config'; +import calculateProgressPercentage from '@webb-tools/abstract-api-provider/utils/calculateProgressPercentage'; +import { ApiConfig, ChainConfig } from '@webb-tools/dapp-config'; import { ChainIcon } from '@webb-tools/icons'; import { TransactionItemStatus, TransactionPayload, getRoundedAmountString, + toFixed, } from '@webb-tools/webb-ui-components'; import { useObservableState } from 'observable-hooks'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { BehaviorSubject } from 'rxjs'; -function transactionItemStatusFromTxStatus( +export function transactionItemStatusFromTxStatus( txStatus: TransactionState ): TransactionItemStatus { switch (txStatus) { @@ -34,6 +34,26 @@ function transactionItemStatusFromTxStatus( } } +export const getExplorerURI = ( + explorerUri: string, + addOrTxHash: string, + variant: 'tx' | 'address', + txProviderType: WebbProviderType +): URL => { + switch (txProviderType) { + case 'web3': + return new URL(`${variant}/${addOrTxHash}`, explorerUri); + + case 'polkadot': { + const path = variant === 'tx' ? `explorer/query/${addOrTxHash}` : ''; + return new URL(`${path}`, explorerUri); + } + + default: + return new URL(''); + } +}; + function mapTxToPayload( tx: Transaction, chainConfig: Record, @@ -54,26 +74,7 @@ function mapTxToPayload( const destChainName = chainConfig[wallets.dest]?.name; const txProviderType = tx.metaData.providerType; - - const getExplorerURI = ( - addOrTxHash: string, - variant: 'tx' | 'address' - ): string => { - explorerUri = explorerUri.endsWith('/') ? explorerUri : explorerUri + '/'; - - switch (txProviderType) { - case 'web3': - return `${explorerUri}${variant}/${addOrTxHash}`; - - case 'polkadot': { - const prefix = variant === 'tx' ? `explorer/query/${addOrTxHash}` : ''; - return `${explorerUri}${prefix}`; - } - - default: - return ''; - } - }; + const currentStep = tx.stepSubject.getValue(); return { id: tx.id, @@ -83,8 +84,15 @@ function mapTxToPayload( txHash: tx.txHash, recipient: tx.metaData.recipient, }, + currentStep, amount: getRoundedAmountString(amount), - getExplorerURI, + getExplorerURI: (addOrTxHash: string, variant: 'tx' | 'address') => + getExplorerURI( + explorerUri, + addOrTxHash, + variant, + txProviderType + ).toString(), timestamp: tx.timestamp, token, tokenURI, @@ -100,7 +108,7 @@ function mapTxToPayload( }; } -function getTxMessageFromStatus( +export function getTxMessageFromStatus( txStatus: Key, transactionStatusValue: TransactionStatusValue ): string { @@ -111,9 +119,6 @@ function getTxMessageFromStatus( case TransactionState.Ideal: return 'Transaction in-progress'; - case TransactionState.PreparingTransaction: - return 'Preparing transaction'; - case TransactionState.FetchingFixtures: return 'Fetching transaction fixtures'; @@ -246,7 +251,15 @@ export function useTxApiQueue(apiConfig: ApiConfig): TransactionQueueApi { } const nextStatus = transactionItemStatusFromTxStatus(nextTxState); - const nextMessage = getTxMessageFromStatus(nextTxState, nextTxData); + let nextMessage = getTxMessageFromStatus(nextTxState, nextTxData); + if (nextTxState === TransactionState.FetchingLeaves) { + const { current, end, start } = + nextTxData as TransactionStatusMap[TransactionState.FetchingLeaves]; + + const percentage = calculateProgressPercentage(start, end, current); + const formattedPercentage = toFixed(percentage); + nextMessage = `Fetching transaction leaves on chain... ${formattedPercentage}%`; + } if ( nextStatus === currentPayload.txStatus.status && @@ -273,7 +286,7 @@ export function useTxApiQueue(apiConfig: ApiConfig): TransactionQueueApi { } ); - // Substart to the transaction hash + // Subscribe to the transaction hash const hashSub = tx.$txHash.subscribe((nextTxHash) => { const payloads = txPayloads$.getValue(); const currentPayload = payloads.find((payload) => payload.id === tx.id); @@ -302,8 +315,34 @@ export function useTxApiQueue(apiConfig: ApiConfig): TransactionQueueApi { txPayloads$.next(nextPayloads); }); + // Subscribe to the transaction step + const stepSub = tx.stepSubject.subscribe((nextStep) => { + const payloads = txPayloads$.getValue(); + const currentPayload = payloads.find((payload) => payload.id === tx.id); + + if (!currentPayload) { + return; + } + + if (nextStep === currentPayload.currentStep) { + return; + } + + const nextPayloads = payloads.map((payload) => { + if (payload.id === tx.id) { + return { + ...payload, + currentStep: nextStep, + }; + } + return payload; + }); + + txPayloads$.next(nextPayloads); + }); + // Update the subscriptions ref - subscriptions.current.set(tx.id, [statusSub, hashSub]); + subscriptions.current.set(tx.id, [statusSub, hashSub, stepSub]); }, [] ); diff --git a/libs/dapp-config/src/api-config.ts b/libs/dapp-config/src/api-config.ts index b869700ed2..c6bbe48211 100644 --- a/libs/dapp-config/src/api-config.ts +++ b/libs/dapp-config/src/api-config.ts @@ -3,7 +3,7 @@ import { ApiPromise } from '@polkadot/api'; import { isEthereumAddress } from '@polkadot/util-crypto'; -import { CurrencyRole, CurrencyType } from '@webb-tools/dapp-types'; +import { CurrencyRole, CurrencyType } from '@webb-tools/dapp-types/Currency'; import { TypedChainId } from '@webb-tools/dapp-types/ChainId'; import { WebbError, WebbErrorCodes } from '@webb-tools/dapp-types/WebbError'; import { diff --git a/libs/dapp-config/src/chains/chain-config.interface.ts b/libs/dapp-config/src/chains/chain-config.interface.ts index 4b036ffbcb..762b8866a7 100644 --- a/libs/dapp-config/src/chains/chain-config.interface.ts +++ b/libs/dapp-config/src/chains/chain-config.interface.ts @@ -1,7 +1,7 @@ // Copyright 2022 @webb-tools/ // SPDX-License-Identifier: Apache-2.0 -import { ChainType } from '@webb-tools/sdk-core'; +import { ChainType } from '@webb-tools/sdk-core/typed-chain-id'; import type { Chain } from 'viem/chains'; import { AppEnvironment } from '../types'; diff --git a/libs/dapp-config/src/chains/chainsPopulated.ts b/libs/dapp-config/src/chains/chainsPopulated.ts new file mode 100644 index 0000000000..6697de42ad --- /dev/null +++ b/libs/dapp-config/src/chains/chainsPopulated.ts @@ -0,0 +1,30 @@ +import { calculateTypedChainId } from '@webb-tools/sdk-core/typed-chain-id'; +import { Chain, Wallet } from '../api-config'; +import { walletsConfig } from '../wallets/wallets-config'; +import { chainsConfig } from './chain-config'; + +const chainsPopulated = Object.values(chainsConfig).reduce( + (acc, chainsConfig) => { + const typedChainId = calculateTypedChainId( + chainsConfig.chainType, + chainsConfig.id + ); + + return { + ...acc, + [typedChainId]: { + ...chainsConfig, + wallets: Object.values(walletsConfig) + .filter(({ supportedChainIds }) => + supportedChainIds.includes(typedChainId) + ) + .reduce((acc, walletsConfig) => { + return Array.from(new Set([...acc, walletsConfig.id])); // dedupe + }, [] as Array), + }, + }; + }, + {} as Record +); + +export default chainsPopulated; diff --git a/libs/dapp-config/src/chains/evm/index.tsx b/libs/dapp-config/src/chains/evm/index.tsx index 686e6c442a..49aa9588ff 100644 --- a/libs/dapp-config/src/chains/evm/index.tsx +++ b/libs/dapp-config/src/chains/evm/index.tsx @@ -1,6 +1,11 @@ // Copyright 2022 @webb-tools/ // SPDX-License-Identifier: Apache-2.0 +import { EVMChainId, PresetTypedChainId } from '@webb-tools/dapp-types/ChainId'; +import { ChainType } from '@webb-tools/sdk-core/typed-chain-id'; +import cloneDeep from 'lodash/cloneDeep'; +import merge from 'lodash/merge'; +import mergeWith from 'lodash/mergeWith'; import { arbitrumGoerli, avalancheFuji, @@ -12,11 +17,6 @@ import { sepolia, type Chain, } from 'viem/chains'; -import { EVMChainId, PresetTypedChainId } from '@webb-tools/dapp-types'; -import { ChainType } from '@webb-tools/sdk-core/typed-chain-id'; -import merge from 'lodash/merge'; -import mergeWith from 'lodash/mergeWith'; -import cloneDeep from 'lodash/cloneDeep'; import { DEFAULT_EVM_CURRENCY } from '../../currencies'; import { ChainConfig, WebbExtendedChain } from '../chain-config.interface'; @@ -203,7 +203,7 @@ export const chainsConfig: Record = { [PresetTypedChainId.HermesOrbit]: { chainType: ChainType.EVM, id: EVMChainId.HermesOrbit, - name: 'Hermes Orbit', + name: 'Orbit Hermes', network: 'Orbit', group: 'orbit', tag: 'test', @@ -236,7 +236,7 @@ export const chainsConfig: Record = { [PresetTypedChainId.AthenaOrbit]: { chainType: ChainType.EVM, id: EVMChainId.AthenaOrbit, - name: 'Athena Orbit', + name: 'Orbit Athena', network: 'Orbit', group: 'orbit', tag: 'test', @@ -269,7 +269,7 @@ export const chainsConfig: Record = { [PresetTypedChainId.DemeterOrbit]: { chainType: ChainType.EVM, id: EVMChainId.DemeterOrbit, - name: 'Demeter Orbit', + name: 'Orbit Demeter', network: 'Orbit', group: 'orbit', tag: 'test', @@ -319,10 +319,24 @@ export const chainsConfig: Record = { }, rpcUrls: { default: { - http: ['https://tangle-standalone-archive.webb.tools'], + http: [ + 'https://tangle-standalone-archive.webb.tools', + 'https://tangle-standalone1.webb.tools', + 'https://tangle-standalone2.webb.tools', + 'https://tangle-standalone3.webb.tools', + 'https://tangle-standalone4.webb.tools', + 'https://tangle-standalone5.webb.tools', + ], }, public: { - http: ['https://tangle-standalone-archive.webb.tools'], + http: [ + 'https://tangle-standalone-archive.webb.tools', + 'https://tangle-standalone1.webb.tools', + 'https://tangle-standalone2.webb.tools', + 'https://tangle-standalone3.webb.tools', + 'https://tangle-standalone4.webb.tools', + 'https://tangle-standalone5.webb.tools', + ], }, }, env: ['development', 'test'], diff --git a/libs/dapp-config/src/index.ts b/libs/dapp-config/src/index.ts index 4272601159..c440a4eaf8 100644 --- a/libs/dapp-config/src/index.ts +++ b/libs/dapp-config/src/index.ts @@ -1,37 +1,8 @@ -import { calculateTypedChainId } from '@webb-tools/sdk-core'; -import { Chain, Wallet } from './api-config'; - -import { chainsConfig } from './chains/chain-config'; -import { walletsConfig } from './wallets/wallets-config'; - -export const chainsPopulated = Object.values(chainsConfig).reduce( - (acc, chainsConfig) => { - const typedChainId = calculateTypedChainId( - chainsConfig.chainType, - chainsConfig.id - ); - - return { - ...acc, - [typedChainId]: { - ...chainsConfig, - wallets: Object.values(walletsConfig) - .filter(({ supportedChainIds }) => - supportedChainIds.includes(typedChainId) - ) - .reduce((acc, walletsConfig) => { - return Array.from(new Set([...acc, walletsConfig.id])); // dedupe - }, [] as Array), - }, - }; - }, - {} as Record -); - export * from './anchors'; export * from './api-config'; export * from './bridges'; export * from './chains'; +export { default as chainsPopulated } from './chains/chainsPopulated'; export * from './constants'; export * from './currencies'; export * from './signature-bridges'; diff --git a/libs/dapp-config/src/relayer-config.ts b/libs/dapp-config/src/relayer-config.ts index 7041d4882c..dbaee3b4cf 100644 --- a/libs/dapp-config/src/relayer-config.ts +++ b/libs/dapp-config/src/relayer-config.ts @@ -22,9 +22,6 @@ export type RelayerConfig = { export type RelayerCMDBase = 'evm' | 'substrate'; export const relayerConfig: RelayerConfig[] = [ - { - endpoint: 'http://localhost:9955', - }, { endpoint: 'https://relayer1.webb.tools', }, diff --git a/libs/dapp-config/src/wallets/wallets-config.tsx b/libs/dapp-config/src/wallets/wallets-config.tsx index a2c812645b..e2696e768b 100644 --- a/libs/dapp-config/src/wallets/wallets-config.tsx +++ b/libs/dapp-config/src/wallets/wallets-config.tsx @@ -50,12 +50,6 @@ const ANY_SUBSTRATE = [ PresetTypedChainId.Polkadot, ]; -// if (!process.env['BRIDGE_DAPP_WALLET_CONNECT_PROJECT_ID']) { -// throw new Error( -// 'Missing BRIDGE_DAPP_WALLET_CONNECT_PROJECT_ID env variable.' -// ); -// } - export const walletsConfig: Record = { // TODO: Should move all hardcoded wallet configs to connectors // https://wagmi.sh/examples/custom-connector diff --git a/libs/dapp-types/src/WebbError.ts b/libs/dapp-types/src/WebbError.ts index 0e282df449..1d226cc5e8 100644 --- a/libs/dapp-types/src/WebbError.ts +++ b/libs/dapp-types/src/WebbError.ts @@ -53,8 +53,8 @@ export enum WebbErrorCodes { TransactionInProgress, // Not implemented NotImplemented, - /// The tree not found - TreeNotFound, + /// The anchor identifier is not found + AnchorIdNotFound, // Insufficient disk space InsufficientDiskSpace, // Invalid arguments @@ -69,6 +69,12 @@ export enum WebbErrorCodes { SwitchChainFailed, // Failed to send the transaction to the relayer FailedToSendTx, + // Key pair not found + KeyPairNotFound, + // Notes are not ready + NotesNotReady, + // Unknown error + UnknownError, } // An Error message with error metadata @@ -256,10 +262,10 @@ export class WebbError extends Error { message: `Missing endpoints in the configuration`, }; - case WebbErrorCodes.TreeNotFound: + case WebbErrorCodes.AnchorIdNotFound: return { code, - message: `Not found tree for the given tree id`, + message: `Not found the anchor identifier`, }; case WebbErrorCodes.NotImplemented: @@ -310,6 +316,19 @@ export class WebbError extends Error { message: 'Failed to send the transaction to the relayer', }; + case WebbErrorCodes.KeyPairNotFound: + return { + code, + message: 'Key pair not found', + }; + + case WebbErrorCodes.NotesNotReady: + return { + code, + message: + 'Some of the notes are not ready, maybe waiting for 5-20 minutes and try again', + }; + default: return { code, diff --git a/libs/dapp-types/src/utils/index.ts b/libs/dapp-types/src/utils/index.ts index e50349a2b5..158c24d8c5 100644 --- a/libs/dapp-types/src/utils/index.ts +++ b/libs/dapp-types/src/utils/index.ts @@ -1,2 +1,3 @@ export { default as isValidAddress } from './isValidAddress'; export { default as isValidPublicKey } from './isValidPublicKey'; +export { default as isValidUrl } from './isValidUrl'; diff --git a/libs/dapp-types/src/utils/isValidAddress.ts b/libs/dapp-types/src/utils/isValidAddress.ts index e0f0e16241..69c8166e99 100644 --- a/libs/dapp-types/src/utils/isValidAddress.ts +++ b/libs/dapp-types/src/utils/isValidAddress.ts @@ -1,5 +1,5 @@ import { decodeAddress, encodeAddress } from '@polkadot/keyring'; -import { isAddress, isEthereumAddress } from '@polkadot/util-crypto'; +import { isEthereumAddress } from '@polkadot/util-crypto'; /** * Check if the address is valid or not, diff --git a/libs/dapp-types/src/utils/isValidUrl.ts b/libs/dapp-types/src/utils/isValidUrl.ts new file mode 100644 index 0000000000..ea89eba239 --- /dev/null +++ b/libs/dapp-types/src/utils/isValidUrl.ts @@ -0,0 +1,15 @@ +/** + * Validate a url is valid or not + * @param url the url to check + * @returns true if the url is valid, false otherwise + */ +function isValidUrl(url: string) { + try { + new URL(url); + return true; + } catch { + return false; + } +} + +export default isValidUrl; diff --git a/libs/icons/src/AccountCircleLineIcon.tsx b/libs/icons/src/AccountCircleLineIcon.tsx new file mode 100644 index 0000000000..e452733977 --- /dev/null +++ b/libs/icons/src/AccountCircleLineIcon.tsx @@ -0,0 +1,12 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const AccountCircleLineIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2zm.16 14a6.981 6.981 0 00-5.147 2.256A7.966 7.966 0 0012 20c1.97 0 3.773-.712 5.167-1.892A6.979 6.979 0 0012.16 16zM12 4a8 8 0 00-6.384 12.821A8.975 8.975 0 0112.16 14a8.972 8.972 0 016.362 2.634A8 8 0 0012 4zm0 1a4 4 0 110 8 4 4 0 010-8zm0 2a2 2 0 100 4 2 2 0 000-4z', + displayName: 'AccountCircleLineIcon', + }); +}; + +export default AccountCircleLineIcon; diff --git a/libs/icons/src/AddCircleFillIcon.tsx b/libs/icons/src/AddCircleFillIcon.tsx new file mode 100644 index 0000000000..0fe49407e9 --- /dev/null +++ b/libs/icons/src/AddCircleFillIcon.tsx @@ -0,0 +1,10 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +export const AddCircleFillIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM11 11H7V13H11V17H13V13H17V11H13V7H11V11Z', + displayName: 'AddCircleFillIcon', + }); +}; diff --git a/libs/icons/src/AddCircleLineIcon.tsx b/libs/icons/src/AddCircleLineIcon.tsx new file mode 100644 index 0000000000..5d5b477427 --- /dev/null +++ b/libs/icons/src/AddCircleLineIcon.tsx @@ -0,0 +1,10 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +export const AddCircleLineIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M11 11V7h2v4h4v2h-4v4h-2v-4H7v-2h4zm1 11C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 100-16 8 8 0 000 16z', + displayName: 'AddCircleLineIcon', + }); +}; diff --git a/libs/icons/src/AlertFill.tsx b/libs/icons/src/AlertFill.tsx index 25c556d5c6..22705c212e 100644 --- a/libs/icons/src/AlertFill.tsx +++ b/libs/icons/src/AlertFill.tsx @@ -4,7 +4,6 @@ import { IconBase } from './types'; export const AlertFill = (props: IconBase) => { return createIcon({ ...props, - viewBox: '0 0 22 22', d: 'M12.865 3.00017L22.3912 19.5002C22.6674 19.9785 22.5035 20.5901 22.0252 20.8662C21.8732 20.954 21.7008 21.0002 21.5252 21.0002H2.47266C1.92037 21.0002 1.47266 20.5525 1.47266 20.0002C1.47266 19.8246 1.51886 19.6522 1.60663 19.5002L11.1329 3.00017C11.4091 2.52187 12.0206 2.358 12.4989 2.63414C12.651 2.72191 12.7772 2.84815 12.865 3.00017ZM10.9989 16.0002V18.0002H12.9989V16.0002H10.9989ZM10.9989 9.00017V14.0002H12.9989V9.00017H10.9989Z', displayName: 'AlertFill', }); diff --git a/libs/icons/src/ChainIcon.tsx b/libs/icons/src/ChainIcon.tsx index cdc1c4f58c..d18c052b2a 100644 --- a/libs/icons/src/ChainIcon.tsx +++ b/libs/icons/src/ChainIcon.tsx @@ -1,13 +1,14 @@ import React, { cloneElement, useMemo } from 'react'; import { Spinner } from './Spinner'; +import StatusIndicator from './StatusIndicator'; +import { StatusIndicatorProps } from './StatusIndicator/types'; import { useDynamicSVGImport } from './hooks/useDynamicSVGImport'; import { TokenIconBase } from './types'; import { getIconSizeInPixel } from './utils'; -export const ChainIcon: React.FC = ({ - isActive, - ...props -}) => { +export const ChainIcon: React.FC< + TokenIconBase & { status?: StatusIndicatorProps['variant'] } +> = ({ status, ...props }) => { const { className, name: nameProp, @@ -41,17 +42,26 @@ export const ChainIcon: React.FC = ({ if (svgElement) { const sizeInPx = getIconSizeInPixel(size); + const sizeInNumber = parseInt(sizeInPx); const props: React.SVGProps = { className, - width: parseInt(sizeInPx), - height: parseInt(sizeInPx), + width: sizeInNumber, + height: sizeInNumber, ...restProps, }; - return isActive ? ( + return typeof status !== 'undefined' ? (
{cloneElement(svgElement, props)} - +
) : ( cloneElement(svgElement, props) diff --git a/libs/icons/src/CheckboxBlankCircleLine.tsx b/libs/icons/src/CheckboxBlankCircleLine.tsx index 5fcd8e9f33..e79441a3c9 100644 --- a/libs/icons/src/CheckboxBlankCircleLine.tsx +++ b/libs/icons/src/CheckboxBlankCircleLine.tsx @@ -4,7 +4,7 @@ import { IconBase } from './types'; export const CheckboxBlankCircleLine = (props: IconBase) => { return createIcon({ ...props, - d: 'M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 100-16.001A8 8 0 0012 20zm-.997-4L6.76 11.757l1.414-1.414 2.829 2.829 5.656-5.657 1.415 1.414L11.003 16z', + d: 'M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z', displayName: 'CheckboxBlankCircleLine', }); }; diff --git a/libs/icons/src/ClipboardLineIcon.tsx b/libs/icons/src/ClipboardLineIcon.tsx new file mode 100644 index 0000000000..0daaebcb3a --- /dev/null +++ b/libs/icons/src/ClipboardLineIcon.tsx @@ -0,0 +1,12 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const ClipboardLineIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M7 4V2h10v2h3.007c.548 0 .993.445.993.993v16.014a.994.994 0 01-.993.993H3.993A.993.993 0 013 21.007V4.993C3 4.445 3.445 4 3.993 4H7zm0 2H5v14h14V6h-2v2H7V6zm2-2v2h6V4H9z', + displayName: 'ClipboardLineIcon', + }); +}; + +export default ClipboardLineIcon; diff --git a/libs/icons/src/GasStationFill.tsx b/libs/icons/src/GasStationFill.tsx new file mode 100644 index 0000000000..e846664511 --- /dev/null +++ b/libs/icons/src/GasStationFill.tsx @@ -0,0 +1,12 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const GasStationFill = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M3 19V4C3 3.44772 3.44772 3 4 3H13C13.5523 3 14 3.44772 14 4V12H16C17.1046 12 18 12.8954 18 14V18C18 18.5523 18.4477 19 19 19C19.5523 19 20 18.5523 20 18V11H18C17.4477 11 17 10.5523 17 10V6.41421L15.3431 4.75736L16.7574 3.34315L21.7071 8.29289C21.9024 8.48816 22 8.74408 22 9V18C22 19.6569 20.6569 21 19 21C17.3431 21 16 19.6569 16 18V14H14V19H15V21H2V19H3ZM5 5V11H12V5H5Z', + displayName: 'GasStationFill', + }); +}; + +export default GasStationFill; diff --git a/libs/icons/src/IndeterminateCircleFillIcon.tsx b/libs/icons/src/IndeterminateCircleFillIcon.tsx new file mode 100644 index 0000000000..b01413a95f --- /dev/null +++ b/libs/icons/src/IndeterminateCircleFillIcon.tsx @@ -0,0 +1,10 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +export const IndeterminateCircleFillIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM7 11V13H17V11H7Z', + displayName: 'IndeterminateCircleFillIcon', + }); +}; diff --git a/libs/icons/src/IndeterminateCircleLineIcon.tsx b/libs/icons/src/IndeterminateCircleLineIcon.tsx new file mode 100644 index 0000000000..46bd499462 --- /dev/null +++ b/libs/icons/src/IndeterminateCircleLineIcon.tsx @@ -0,0 +1,10 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +export const IndeterminateCircleLineIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 100-16 8 8 0 000 16zm-5-9h10v2H7v-2z', + displayName: 'IndeterminateCircleLineIcon', + }); +}; diff --git a/libs/icons/src/RefreshIcon.tsx b/libs/icons/src/RefreshLineIcon.tsx similarity index 87% rename from libs/icons/src/RefreshIcon.tsx rename to libs/icons/src/RefreshLineIcon.tsx index eedbe72ea0..30ee720917 100644 --- a/libs/icons/src/RefreshIcon.tsx +++ b/libs/icons/src/RefreshLineIcon.tsx @@ -1,12 +1,14 @@ import { createIcon } from './create-icon'; import { IconBase } from './types'; -export const RefreshIcon = (props: IconBase) => { +const RefreshLineIcon = (props: IconBase) => { return createIcon({ ...props, path: ( ), - displayName: 'RefreshIcon', + displayName: 'RefreshLineIcon', }); }; + +export default RefreshLineIcon; diff --git a/libs/icons/src/SettingsFillIcon.tsx b/libs/icons/src/SettingsFillIcon.tsx new file mode 100644 index 0000000000..6d93f58c74 --- /dev/null +++ b/libs/icons/src/SettingsFillIcon.tsx @@ -0,0 +1,12 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const SettingsFillIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M2.132 13.63a9.942 9.942 0 01.001-3.26c1.101.026 2.092-.502 2.477-1.431.385-.93.058-2.003-.74-2.763a9.942 9.942 0 012.306-2.307c.76.798 1.834 1.125 2.763.74.93-.385 1.458-1.376 1.431-2.477a9.942 9.942 0 013.261 0c-.027 1.102.502 2.092 1.431 2.477.93.385 2.003.058 2.763-.74a9.939 9.939 0 012.307 2.306c-.798.76-1.125 1.834-.74 2.764.385.93 1.375 1.458 2.477 1.43a9.945 9.945 0 010 3.262c-1.102-.027-2.092.501-2.477 1.43-.385.93-.058 2.004.74 2.764a9.939 9.939 0 01-2.306 2.306c-.76-.798-1.834-1.125-2.764-.74-.93.385-1.458 1.376-1.43 2.478a9.94 9.94 0 01-3.262-.001c.027-1.101-.502-2.092-1.43-2.477-.93-.385-2.004-.058-2.764.74a9.943 9.943 0 01-2.306-2.306c.798-.76 1.125-1.834.74-2.763-.385-.93-1.376-1.458-2.478-1.431zM12.001 15a3 3 0 100-6 3 3 0 000 6z', + displayName: 'SettingsFillIcon', + }); +}; + +export default SettingsFillIcon; diff --git a/libs/icons/src/ShieldKeyholeFillIcon.tsx b/libs/icons/src/ShieldKeyholeFillIcon.tsx new file mode 100644 index 0000000000..66bca2d31c --- /dev/null +++ b/libs/icons/src/ShieldKeyholeFillIcon.tsx @@ -0,0 +1,12 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const ShieldKeyholeFillIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M12 1l8.217 1.826a1 1 0 01.783.976v9.987a6 6 0 01-2.672 4.992L12 23l-6.328-4.219A6 6 0 013 13.79V3.802a1 1 0 01.783-.976L12 1zm0 6a2 2 0 00-1 3.732V15h2l.001-4.268A2 2 0 0012 7z', + displayName: 'ShieldKeyholeFillIcon', + }); +}; + +export default ShieldKeyholeFillIcon; diff --git a/libs/icons/src/ShieldKeyholeIcon.tsx b/libs/icons/src/ShieldKeyholeIcon.tsx deleted file mode 100644 index 736c9cf858..0000000000 --- a/libs/icons/src/ShieldKeyholeIcon.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createIcon } from './create-icon'; -import { IconBase } from './types'; - -export const ShieldKeyholeIcon = (props: IconBase) => { - return createIcon({ - ...props, - path: ( - - ), - displayName: 'ShieldKeyhole', - }); -}; diff --git a/libs/icons/src/ShieldKeyholeLineIcon.tsx b/libs/icons/src/ShieldKeyholeLineIcon.tsx new file mode 100644 index 0000000000..b9429a6bbd --- /dev/null +++ b/libs/icons/src/ShieldKeyholeLineIcon.tsx @@ -0,0 +1,12 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const ShieldKeyholeLineIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M12 1l8.217 1.826a1 1 0 01.783.976v9.987a6 6 0 01-2.672 4.992L12 23l-6.328-4.219A6 6 0 013 13.79V3.802a1 1 0 01.783-.976L12 1zm0 2.049L5 4.604v9.185a4 4 0 001.781 3.328L12 20.597l5.219-3.48A4 4 0 0019 13.79V4.604L12 3.05zM12 7a2 2 0 011.001 3.732L13 15h-2v-4.268A2 2 0 0112 7z', + displayName: 'ShieldKeyholeLineIcon', + }); +}; + +export default ShieldKeyholeLineIcon; diff --git a/libs/icons/src/ShieldedAssetDark.tsx b/libs/icons/src/ShieldedAssetDark.tsx deleted file mode 100644 index 3dbe922188..0000000000 --- a/libs/icons/src/ShieldedAssetDark.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { createIcon } from './create-icon'; -import { IconBase } from './types'; - -export const ShieldedAssetDark = (props: IconBase) => { - return createIcon({ - width: 24, - height: 30, - viewBox: '0 0 24 30', - ...props, - path: ( - <> - - - - - - - - - ), - displayName: 'ShieldedAssetDark', - }); -}; diff --git a/libs/icons/src/ShieldedAssetIcon.tsx b/libs/icons/src/ShieldedAssetIcon.tsx new file mode 100644 index 0000000000..f61132a2b8 --- /dev/null +++ b/libs/icons/src/ShieldedAssetIcon.tsx @@ -0,0 +1,237 @@ +import cx from 'classnames'; +import { twMerge } from 'tailwind-merge'; +import { ChainIcon } from './ChainIcon'; +import { IconBase } from './types'; + +const getSizeProps = (size: IconBase['size']) => { + switch (size) { + case 'lg': + return { + width: 20, + height: 25, + }; + + case 'xl': + return { + width: 40, + height: 49, + }; + + default: + return { + width: 14, + height: 17, + }; + } +}; + +interface ShieldedAssetIconProps extends IconBase { + /** + * Whether to display the placeholder icon or not + */ + displayPlaceholder?: boolean; + + /** + * Chain name to display on the chain icon + */ + chainName?: string; +} + +const ShieldedAssetIconInner = ({ + displayPlaceholder, + ...props +}: Omit) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +const ShieldedAssetIcon = ({ + displayPlaceholder, + chainName, + ...props +}: ShieldedAssetIconProps) => { + if (typeof chainName === 'string' && !displayPlaceholder) { + return ( +
+ + +
+ ); + } + + return ( + + ); +}; + +export default ShieldedAssetIcon; diff --git a/libs/icons/src/ShieldedAssetLight.tsx b/libs/icons/src/ShieldedAssetLight.tsx deleted file mode 100644 index 5f44fedb91..0000000000 --- a/libs/icons/src/ShieldedAssetLight.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { createIcon } from './create-icon'; -import { IconBase } from './types'; - -export const ShieldedAssetLight = (props: IconBase) => { - return createIcon({ - width: 24, - height: 30, - viewBox: '0 0 24 30', - ...props, - path: ( - <> - - - - - - - - - ), - displayName: 'ShieldedAssetLight', - }); -}; diff --git a/libs/icons/src/ShieldedCheckLineIcon.tsx b/libs/icons/src/ShieldedCheckLineIcon.tsx new file mode 100644 index 0000000000..ffaae63543 --- /dev/null +++ b/libs/icons/src/ShieldedCheckLineIcon.tsx @@ -0,0 +1,12 @@ +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const ShieldedCheckLineIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M12 1l8.217 1.826a1 1 0 01.783.976v9.987a6 6 0 01-2.672 4.992L12 23l-6.328-4.219A6 6 0 013 13.79V3.802a1 1 0 01.783-.976L12 1zm0 2.049L5 4.604v9.185a4 4 0 001.781 3.328L12 20.597l5.219-3.48A4 4 0 0019 13.79V4.604L12 3.05zm4.452 5.173l1.415 1.414L11.503 16 7.26 11.757l1.414-1.414 2.828 2.828 4.95-4.95z', + displayName: 'ShieldedCheckLineIcon', + }); +}; + +export default ShieldedCheckLineIcon; diff --git a/libs/icons/src/StatusIndicator/StatusIndicator.tsx b/libs/icons/src/StatusIndicator/StatusIndicator.tsx new file mode 100644 index 0000000000..14911fab33 --- /dev/null +++ b/libs/icons/src/StatusIndicator/StatusIndicator.tsx @@ -0,0 +1,72 @@ +import cx from 'classnames'; +import { forwardRef } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { StatusIndicatorProps, StatusVariant } from './types'; + +const classes: { + [key in StatusVariant]: { + indicator: string; + stroke: string; + }; +} = { + success: { + indicator: cx('fill-green-70 dark:fill-green-50'), + stroke: cx('stroke-[#288E32] dark:stroke-[#4CB457]'), + }, + warning: { + indicator: cx('fill-yellow-70 darkcx(:fill-yellow-50'), + stroke: cx('stroke-[#EAB612] dark:stroke-[#F8D567]'), + }, + error: { + indicator: cx('fill-red-70 darkcx(:fill-red-50'), + stroke: cx('stroke-[#EF570D] dark:stroke-[#FF874D]'), + }, + info: { + indicator: cx('fill-blue-70 darkcx(:fill-blue-50'), + stroke: cx('stroke-[#23579D] dark:stroke-[#23579D]'), + }, +}; + +const StatusIndicator = forwardRef( + ({ animated, variant = 'info', size = 12, ...props }, ref) => { + // We use haft size to make sure the component is corectly centered + const haftSize = size / 2; + + return ( + + + + + ); + } +); + +export default StatusIndicator; diff --git a/libs/icons/src/StatusIndicator/index.ts b/libs/icons/src/StatusIndicator/index.ts new file mode 100644 index 0000000000..28b653ff2c --- /dev/null +++ b/libs/icons/src/StatusIndicator/index.ts @@ -0,0 +1,6 @@ +import StatusIndicator from './StatusIndicator'; + +export * from './StatusIndicator'; +export { default as StatusIndicator } from './StatusIndicator'; + +export default StatusIndicator; diff --git a/libs/icons/src/StatusIndicator/types.ts b/libs/icons/src/StatusIndicator/types.ts new file mode 100644 index 0000000000..d7bbda85b3 --- /dev/null +++ b/libs/icons/src/StatusIndicator/types.ts @@ -0,0 +1,22 @@ +import { ComponentProps } from 'react'; + +export type StatusVariant = 'success' | 'warning' | 'error' | 'info'; + +export interface StatusIndicatorProps extends ComponentProps<'svg'> { + /** + * The color variant of the status indicator. + * @default 'info' + */ + variant?: StatusVariant; + + /** + * The size of the status indicator. + * @default 12 + */ + size?: number; + + /** + * Whether the status indicator should be animated. + */ + animated?: boolean; +} diff --git a/libs/icons/src/TokenIcon.tsx b/libs/icons/src/TokenIcon.tsx index e29457a91f..95e9fe6ee1 100644 --- a/libs/icons/src/TokenIcon.tsx +++ b/libs/icons/src/TokenIcon.tsx @@ -1,5 +1,5 @@ import cx from 'classnames'; -import React, { cloneElement, useMemo } from 'react'; +import React, { MouseEventHandler, cloneElement, useMemo } from 'react'; import { Spinner } from './Spinner'; import { useDynamicSVGImport } from './hooks/useDynamicSVGImport'; @@ -27,12 +27,24 @@ export const TokenIcon: React.FC = ( }); const className = useMemo( - () => twMerge(cx({ 'cursor-copy': Boolean(onClick) }), classNameProp), + () => + twMerge( + cx({ + 'cursor-copy': Boolean(onClick), + [cx( + 'fill-mono-60 stroke-mono-60', + 'dark:fill-mono-140 dark:stroke-mono-140' + )]: typeof name === 'undefined', // Style for placeholder + }), + classNameProp + ), [classNameProp] ); // Prevent infinite loop when the passed onClick not use useCallback - const onClickRef = React.useRef(onClick); + const onClickRef = React.useRef< + MouseEventHandler | undefined + >(onClick); if (error) { return {error.message}; @@ -53,12 +65,12 @@ export const TokenIcon: React.FC = ( }; return isActive ? ( -
+
{cloneElement(svgElement, props)}
) : ( - cloneElement(svgElement, props) + cloneElement(svgElement, { ...props, onClick: onClickRef.current }) ); } diff --git a/libs/icons/src/WalletFillIcon.tsx b/libs/icons/src/WalletFillIcon.tsx new file mode 100644 index 0000000000..70b7548a88 --- /dev/null +++ b/libs/icons/src/WalletFillIcon.tsx @@ -0,0 +1,13 @@ +// +import { createIcon } from './create-icon'; +import { IconBase } from './types'; + +const WalletFillIcon = (props: IconBase) => { + return createIcon({ + ...props, + d: 'M2.005 9h19a1 1 0 011 1v10a1 1 0 01-1 1h-18a1 1 0 01-1-1V9zm1-6h15v4h-16V4a1 1 0 011-1zm12 11v2h3v-2h-3z', + displayName: 'WalletFillIcon', + }); +}; + +export default WalletFillIcon; diff --git a/libs/icons/src/WalletLineIcon.tsx b/libs/icons/src/WalletLineIcon.tsx index e4f9331871..951c7fb938 100644 --- a/libs/icons/src/WalletLineIcon.tsx +++ b/libs/icons/src/WalletLineIcon.tsx @@ -1,11 +1,12 @@ import { createIcon } from './create-icon'; import { IconBase } from './types'; -export const WalletLineIcon = (props: IconBase) => { +const WalletLineIcon = (props: IconBase) => { return createIcon({ ...props, - viewBox: '0 0 24 25', - d: 'M18 7.5h3a1 1 0 011 1v12a1 1 0 01-1 1H3a1 1 0 01-1-1v-16a1 1 0 011-1h15v4zm-14 2v10h16v-10H4zm0-4v2h12v-2H4zm11 8h3v2h-3v-2z', + d: 'M18.005 7h3a1 1 0 011 1v12a1 1 0 01-1 1h-18a1 1 0 01-1-1V4a1 1 0 011-1h15v4zm-14 2v10h16V9h-16zm0-4v2h12V5h-12zm11 8h3v2h-3v-2z', displayName: 'WalletLineIcon', }); }; + +export default WalletLineIcon; diff --git a/libs/icons/src/chains/athena-orbit.svg b/libs/icons/src/chains/orbit-athena.svg similarity index 100% rename from libs/icons/src/chains/athena-orbit.svg rename to libs/icons/src/chains/orbit-athena.svg diff --git a/libs/icons/src/chains/demeter-orbit.svg b/libs/icons/src/chains/orbit-demeter.svg similarity index 100% rename from libs/icons/src/chains/demeter-orbit.svg rename to libs/icons/src/chains/orbit-demeter.svg diff --git a/libs/icons/src/chains/hermes-orbit.svg b/libs/icons/src/chains/orbit-hermes.svg similarity index 100% rename from libs/icons/src/chains/hermes-orbit.svg rename to libs/icons/src/chains/orbit-hermes.svg diff --git a/libs/icons/src/hooks/useDynamicSVGImport.tsx b/libs/icons/src/hooks/useDynamicSVGImport.tsx index d1a80842fd..a43c74a796 100644 --- a/libs/icons/src/hooks/useDynamicSVGImport.tsx +++ b/libs/icons/src/hooks/useDynamicSVGImport.tsx @@ -32,7 +32,7 @@ export interface DynamicSVGImportOptions { * @returns `error`, `loading` and `SvgIcon` in an object */ export function useDynamicSVGImport( - name: string, + name?: string, options: DynamicSVGImportOptions = {} ) { const [importedIcon, setImportedIcon] = useState< @@ -43,7 +43,11 @@ export function useDynamicSVGImport( const { onCompleted, onError } = options; - const _name = useMemo(() => name.trim().toLowerCase(), [name]); + const _name = useMemo( + () => + typeof name === 'string' ? name.trim().toLowerCase() : 'placeholder', + [name] + ); const type = useMemo(() => options.type ?? 'token', [options]); useEffect(() => { diff --git a/libs/icons/src/index.ts b/libs/icons/src/index.ts index 8ddb1ee19c..25de1e134f 100644 --- a/libs/icons/src/index.ts +++ b/libs/icons/src/index.ts @@ -1,4 +1,7 @@ +export { default as AccountCircleLineIcon } from './AccountCircleLineIcon'; export * from './AddBoxLineIcon'; +export * from './AddCircleFillIcon'; +export * from './AddCircleLineIcon'; export * from './ArrowDropDownFill'; export * from './ArrowDropUpFill'; export * from './ArrowLeft'; @@ -12,23 +15,24 @@ export * from './BlockIcon'; export * from './BookOpenLineIcon'; export * from './ChainIcon'; export * from './CheckboxBlankCircleLine'; -export * from './CheckboxCircleLine'; export * from './CheckboxCircleFill'; +export * from './CheckboxCircleLine'; export * from './CheckboxFill'; export * from './ChevronDown'; export * from './ChevronLeft'; export * from './ChevronRight'; export * from './ChevronUp'; +export { default as ClipboardLineIcon } from './ClipboardLineIcon'; export * from './Close'; export * from './CloseCircleLineIcon'; export * from './CoinIcon'; export * from './Common2Icon'; export * from './CommonWealth'; -export * from './Copyright'; -export * from './CornerDownRightLine'; -export * from './CopyLinkFill'; export * from './ContrastLine'; export * from './ContrastTwoLine'; +export * from './CopyLinkFill'; +export * from './Copyright'; +export * from './CornerDownRightLine'; export * from './DatabaseLine'; export * from './DefaultTokenIcon'; export * from './DeleteBinIcon'; @@ -50,11 +54,14 @@ export * from './FilterIcon2'; export * from './FlaskLineIcon'; export * from './FoldersFillIcon'; export * from './ForumIcon'; +export { default as GasStationFill } from './GasStationFill'; export * from './GithubFill'; export * from './GraphIcon'; export * from './GridFillIcon'; export * from './HamburgerMenu'; export * from './HelpLineIcon'; +export * from './IndeterminateCircleFillIcon'; +export * from './IndeterminateCircleLineIcon'; export * from './InformationLine'; export * from './InformationLineFill'; export * from './KeyIcon'; @@ -65,14 +72,17 @@ export * from './Mail'; export * from './Memu'; export * from './MoonLine'; export * from './QRCode'; -export * from './RefreshIcon'; +export { default as RefreshLineIcon } from './RefreshLineIcon'; export * from './Save'; export * from './SaveWithBg'; export * from './Search'; export * from './SendPlanLineIcon'; -export * from './ShieldedAssetDark'; -export * from './ShieldedAssetLight'; -export * from './ShieldKeyholeIcon'; +export { default as SettingsFillIcon } from './SettingsFillIcon'; +export { default as ShieldKeyholeFillIcon } from './ShieldKeyholeFillIcon'; +export * from './ShieldKeyholeLineIcon'; +export { default as ShieldKeyholeLineIcon } from './ShieldKeyholeLineIcon'; +export { default as ShieldedAssetIcon } from './ShieldedAssetIcon'; +export { default as ShieldedCheckLineIcon } from './ShieldedCheckLineIcon'; export * from './ShuffleLine'; export * from './SosLineIcon'; export * from './SparklingIcon'; @@ -87,20 +97,24 @@ export * from './TwitterFill'; export * from './UploadCloudIcon'; export * from './UsageGuideIcon'; export * from './UserStarFillIcon'; -export * from './WalletLineIcon'; +export { default as WalletFillIcon } from './WalletFillIcon'; +export { default as WalletLineIcon } from './WalletLineIcon'; export * from './YouTubeFill'; // Wallet icons export * from './wallets'; // imagre URIs -export * from './AlertFill'; export * from './Alert'; +export * from './AlertFill'; export * from './PartyFill'; // Tangle Icons export * from './Tangle'; export * from './TangleIcon'; +export * from './StatusIndicator'; +export { default as StatusIndicator } from './StatusIndicator'; + // Proposal Badges export * from './ProposalBadge'; diff --git a/libs/icons/src/tokens/placeholder.svg b/libs/icons/src/tokens/placeholder.svg new file mode 100644 index 0000000000..7f056bf011 --- /dev/null +++ b/libs/icons/src/tokens/placeholder.svg @@ -0,0 +1,11 @@ + + + diff --git a/libs/icons/src/tokens/sep.svg b/libs/icons/src/tokens/sep.svg new file mode 100644 index 0000000000..0e2c496b34 --- /dev/null +++ b/libs/icons/src/tokens/sep.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/libs/icons/src/types.ts b/libs/icons/src/types.ts index 0efdc917f9..4b0992cdaa 100644 --- a/libs/icons/src/types.ts +++ b/libs/icons/src/types.ts @@ -26,5 +26,5 @@ export interface TokenIconBase /** * The symbol for the cryptocurrency to get the icon */ - name: string; + name?: string; } diff --git a/libs/note-manager/src/note-manager.ts b/libs/note-manager/src/note-manager.ts index bc0472c61e..69aa9e9226 100644 --- a/libs/note-manager/src/note-manager.ts +++ b/libs/note-manager/src/note-manager.ts @@ -19,7 +19,6 @@ import { import { hexToU8a } from '@webb-tools/utils'; import { Backend } from '@webb-tools/wasm-utils'; import { BehaviorSubject } from 'rxjs'; -import { parseUnits } from 'viem'; type DefaultNoteGenInput = Pick< NoteGenInput, @@ -149,7 +148,8 @@ export class NoteManager { let currentAmount = ZERO_BIG_INT; const currentNotes: Note[] = []; - for (const note of notes) { + const sortedNotes = NoteManager.sortNotes(notes); + for (const note of sortedNotes) { if (currentAmount >= targetAmount) { break; } @@ -166,6 +166,41 @@ export class NoteManager { return new Keypair(`0x${secrets[2]}`); } + /** + * Sort the notes by indx in ascending order + * and the zero and undefined index will be put at the end + * @param notes the notes to sort + */ + static sortNotes(notes: ReadonlyArray): ReadonlyArray { + return notes.slice().sort((a, b) => { + // Place undefined values at the end + if (!a.note.index) { + return 1; + } + + if (!b.note.index) { + return -1; + } + + const aIndex = BigInt(a.note.index); + const bIndex = BigInt(b.note.index); + + // Place zero values before undefined but after other numbers + if (aIndex === ZERO_BIG_INT) { + return 1; + } + + if (bIndex === ZERO_BIG_INT) { + return -1; + } + + // Regular ascending sort for other numbers + const idx = aIndex - bIndex; + + return idx > 0 ? 1 : idx < 0 ? -1 : 0; + }); + } + get $notesUpdated() { return this.notesUpdatedSubject.asObservable(); } @@ -291,6 +326,11 @@ export class NoteManager { const noteIndex = targetNotes.findIndex( (managedNote) => managedNote.serialize() === note.serialize() ); + + if (noteIndex === -1) { + return; + } + targetNotes.splice(noteIndex, 1); if (targetNotes.length != 0) { this.notesMap.set(resourceIdStr, targetNotes); diff --git a/libs/polkadot-api-provider/src/webb-provider.ts b/libs/polkadot-api-provider/src/webb-provider.ts index ee2c2aa0a5..434e1739e5 100644 --- a/libs/polkadot-api-provider/src/webb-provider.ts +++ b/libs/polkadot-api-provider/src/webb-provider.ts @@ -423,7 +423,8 @@ export class WebbPolkadot if (!leavesFromRelayers) { tx?.next(TransactionState.FetchingLeaves, { start: 0, // Dummy values - currentRange: [0, 0], // Dummy values + current: 0, // Dummy values + end: 0, }); // check if we already cached some values. @@ -586,7 +587,7 @@ export class WebbPolkadot const api = provider || this.api; const treeData = await api.query.merkleTreeBn254.trees(treeId); if (treeData.isNone) { - throw WebbError.from(WebbErrorCodes.TreeNotFound); + throw WebbError.from(WebbErrorCodes.AnchorIdNotFound); } const treeMedata = treeData.unwrap(); diff --git a/libs/polkadot-api-provider/src/webb-provider/relayer-manager.ts b/libs/polkadot-api-provider/src/webb-provider/relayer-manager.ts index c8888f8485..a9f95f049c 100644 --- a/libs/polkadot-api-provider/src/webb-provider/relayer-manager.ts +++ b/libs/polkadot-api-provider/src/webb-provider/relayer-manager.ts @@ -23,10 +23,10 @@ import { ChainType, Note, calculateTypedChainId } from '@webb-tools/sdk-core'; export class PolkadotRelayerManager extends WebbRelayerManager<'polkadot'> { supportedPallet = 'VAnchorBn254'; - async mapRelayerIntoActive( + mapRelayerIntoActive( relayer: OptionalRelayer, typedChainId: number - ): Promise { + ): OptionalActiveRelayer { if (!relayer) { return null; } diff --git a/libs/polkadot-api-provider/src/webb-provider/vanchor-actions.ts b/libs/polkadot-api-provider/src/webb-provider/vanchor-actions.ts index da18cd9c31..1a1bd9ea9d 100644 --- a/libs/polkadot-api-provider/src/webb-provider/vanchor-actions.ts +++ b/libs/polkadot-api-provider/src/webb-provider/vanchor-actions.ts @@ -48,6 +48,8 @@ import { firstValueFrom } from 'rxjs'; import * as snarkjs from 'snarkjs'; import { ApiPromise } from '@polkadot/api'; +import type { HexString } from '@polkadot/util/types'; +import { NeighborEdge } from '@webb-tools/abstract-api-provider/vanchor/types'; import { bridgeStorageFactory } from '@webb-tools/browser-utils'; import { ZERO_BIG_INT } from '@webb-tools/dapp-config'; import assert from 'assert'; @@ -97,8 +99,6 @@ export class PolkadotVAnchorActions extends VAnchorActions< payload: TransactionPayloadType, wrapUnwrapAssetId: string ): Promise> | never { - tx.next(TransactionState.PreparingTransaction, undefined); - // If the wrapUnwrapAssetId is empty, we use the bridge fungible token if (!wrapUnwrapAssetId) { const activeBridge = this.inner.state.activeBridge; @@ -130,7 +130,7 @@ export class PolkadotVAnchorActions extends VAnchorActions< activeRelayer: ActiveWebbRelayer, txArgs: ParametersOfTransactMethod<'polkadot'>, changeNotes: Note[] - ): Promise { + ): Promise { const [tx, anchorId, rawInputUtxos, rawOutputUtxos, ...restArgs] = txArgs; const relayedVAnchorWithdraw = await activeRelayer.initWithdraw('vAnchor'); @@ -178,8 +178,6 @@ export class PolkadotVAnchorActions extends VAnchorActions< }, } satisfies WithdrawRelayerArgs<'substrate', CMDSwitcher<'substrate'>>); - let txHash = ''; - // Subscribe to the relayer's transaction status. relayedVAnchorWithdraw.watcher.subscribe(async ([results, message]) => { switch (results) { @@ -230,12 +228,9 @@ export class PolkadotVAnchorActions extends VAnchorActions< // Send the transaction to the relayer. relayedVAnchorWithdraw.send(relayTxPayload, chainId); - const results = await relayedVAnchorWithdraw.await(); - if (results) { - const [, message] = results; - txHash = message ?? ''; - tx.txHash = txHash; - } + const [, txHash = ''] = await relayedVAnchorWithdraw.await(); + tx.txHash = txHash; + return ensureHex(txHash); } async transact( @@ -306,6 +301,10 @@ export class PolkadotVAnchorActions extends VAnchorActions< return ensureHex(txHash); } + async waitForFinalization(hash: `0x${string}`): Promise { + throw WebbError.from(WebbErrorCodes.NotImplemented); + } + async isPairRegistered( treeId: string, account: string, @@ -423,6 +422,13 @@ export class PolkadotVAnchorActions extends VAnchorActions< }; } + async getLatestNeighborEdges( + fungibleId: number, + typedChainId?: number | undefined + ): Promise { + throw WebbError.from(WebbErrorCodes.NotImplemented); + } + // ------------------ Private ------------------ private async prepareDepositTransaction( @@ -889,9 +895,9 @@ export class PolkadotVAnchorActions extends VAnchorActions< ): Promise<{ leafIndex: number; utxo: Utxo; amount: BN }> | never { if (tx) { tx.next(TransactionState.FetchingLeaves, { - end: undefined, - currentRange: [0, 1], - start: 0, + start: 0, // Dummy value + end: 0, // Dummy value + current: 0, // Dummy value }); } @@ -915,7 +921,7 @@ export class PolkadotVAnchorActions extends VAnchorActions< ).unwrapOr(null); if (!destTree) { - throw WebbError.from(WebbErrorCodes.TreeNotFound); + throw WebbError.from(WebbErrorCodes.AnchorIdNotFound); } destRelayedRoot = destTree.root.toHex(); diff --git a/libs/react-hooks/src/currency/useBalancesFromNotes.tsx b/libs/react-hooks/src/currency/useBalancesFromNotes.tsx index 861bbbcc1c..662a2ea2fa 100644 --- a/libs/react-hooks/src/currency/useBalancesFromNotes.tsx +++ b/libs/react-hooks/src/currency/useBalancesFromNotes.tsx @@ -5,14 +5,26 @@ import { CurrencyRole } from '@webb-tools/dapp-types'; import { ResourceId, calculateTypedChainId } from '@webb-tools/sdk-core'; import { hexToU8a } from '@webb-tools/utils'; import { useMemo } from 'react'; -import { formatUnits } from 'viem'; import { useNoteAccount } from '../useNoteAccount'; +/** + * The type of the balances from notes + * Record of balances (currencyId => Record) + */ +export type BalancesFromNotesType = { + [currencyId: number]: { + [typedChainId: number]: bigint; + }; +}; + /** * The return type of the useBalancesFromNotes * Record of balances (currencyId => Record) */ -type UseBalancesFromNotesReturnType = Record>; +type UseBalancesFromNotesReturnType = { + balances: BalancesFromNotesType; + initialized: boolean; +}; /** * Get the balances of each fungible currency @@ -22,7 +34,7 @@ type UseBalancesFromNotesReturnType = Record>; export const useBalancesFromNotes = (): UseBalancesFromNotesReturnType => { const { apiConfig } = useWebContext(); - const { allNotes } = useNoteAccount(); + const { allNotes, allNotesInitialized } = useNoteAccount(); const allFungibles = useMemo(() => { return Currency.fromArray( @@ -30,7 +42,7 @@ export const useBalancesFromNotes = (): UseBalancesFromNotesReturnType => { ); }, [apiConfig]); - return useMemo(() => { + const balances = useMemo(() => { return Array.from(allNotes.entries()).reduce( (acc, [resourceIdStr, notes]) => { try { @@ -74,13 +86,8 @@ export const useBalancesFromNotes = (): UseBalancesFromNotesReturnType => { // then create a new record with the amount of the note // on the current chain and return if (!existedRecord) { - const amount = +formatUnits( - BigInt(note.amount), - +note.denomination - ); - acc[fungible.id] = { - [typedChainId]: amount, + [typedChainId]: BigInt(note.amount), }; return; @@ -94,14 +101,9 @@ export const useBalancesFromNotes = (): UseBalancesFromNotesReturnType => { // then add the amount of the note to the existed amount // and return if (existedAmount) { - const amount = +formatUnits( - BigInt(note.amount), - +note.denomination - ); - acc[fungible.id] = { ...existedRecord, - [typedChainId]: existedAmount + amount, + [typedChainId]: existedAmount + BigInt(note.amount), }; return; @@ -110,14 +112,9 @@ export const useBalancesFromNotes = (): UseBalancesFromNotesReturnType => { // If the amount on the current chain does not exist // then create a new record with the amount of the note // on the current chain and return - const amount = +formatUnits( - BigInt(note.amount), - +note.denomination - ); - acc[fungible.id] = { ...existedRecord, - [typedChainId]: amount, + [typedChainId]: BigInt(note.amount), }; }); } catch (error) { @@ -126,7 +123,12 @@ export const useBalancesFromNotes = (): UseBalancesFromNotesReturnType => { return acc; }, - {} as Record> + {} as BalancesFromNotesType ); }, [allFungibles, allNotes, apiConfig]); + + return { + balances, + initialized: allNotesInitialized, + }; }; diff --git a/libs/react-hooks/src/currency/useCurrenciesBalances.tsx b/libs/react-hooks/src/currency/useCurrenciesBalances.tsx index b165b394ec..36e6743aa4 100644 --- a/libs/react-hooks/src/currency/useCurrenciesBalances.tsx +++ b/libs/react-hooks/src/currency/useCurrenciesBalances.tsx @@ -1,34 +1,48 @@ import { Currency } from '@webb-tools/abstract-api-provider'; import { useWebContext } from '@webb-tools/api-provider-environment'; import { calculateTypedChainId } from '@webb-tools/sdk-core'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +export type UseCurrenciesBalancesReturnType = { + balances: Record; + isLoading: boolean; +}; /** * Fetch the balances of the currencies list * @param currencies the currencies list to fetching the balance + * @param typedChainId the typed chain id (if not provided, will use the active chain) + * @param address the address to fetch the balance (if not provided, will use the active account) * @returns an object where the key is currency id and the value is currency balance */ export const useCurrenciesBalances = ( - currencies: Currency[] -): Record => { + currencies: Currency[], + typedChainId?: number, + address?: string +): UseCurrenciesBalancesReturnType => { const { activeApi, activeChain, activeAccount } = useWebContext(); // Balances object map currency id and its balance const [balances, setBalances] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + useEffect(() => { let isSubscribe = true; - if (!activeApi || !activeChain) { + const typedChainIdToUse = + typedChainId ?? + (activeChain && + calculateTypedChainId(activeChain.chainType, activeChain.id)); + + if (!activeApi || typeof typedChainIdToUse !== 'number') { + setIsLoading(false); return; } const subscriptions = currencies.map((currency) => { return activeApi.methods.chainQuery - .tokenBalanceByCurrencyId( - calculateTypedChainId(activeChain.chainType, activeChain.id), - currency.id - ) + .tokenBalanceByCurrencyId(typedChainIdToUse, currency.id, address) .subscribe((currencyBalance) => { if (isSubscribe) { setBalances((prev) => { @@ -49,7 +63,20 @@ export const useCurrenciesBalances = ( isSubscribe = false; subscriptions.forEach((subscription) => subscription.unsubscribe()); }; - }, [activeApi, activeChain, activeAccount, currencies]); + }, [activeApi, activeChain, activeAccount, currencies, typedChainId, address]); // prettier-ignore + + const isAllBalancesLoaded = useMemo( + () => + currencies.every((currency) => typeof balances[currency.id] === 'number'), + [balances, currencies] + ); + + useEffect(() => { + setIsLoading(!isAllBalancesLoaded); + }, [isAllBalancesLoaded]); - return balances; + return { + balances, + isLoading, + }; }; diff --git a/libs/react-hooks/src/currency/useCurrencyBalance.tsx b/libs/react-hooks/src/currency/useCurrencyBalance.tsx index aaf8ee2ed9..af46a58d47 100644 --- a/libs/react-hooks/src/currency/useCurrencyBalance.tsx +++ b/libs/react-hooks/src/currency/useCurrencyBalance.tsx @@ -4,28 +4,30 @@ import { calculateTypedChainId } from '@webb-tools/sdk-core'; import { useEffect, useState } from 'react'; export const useCurrencyBalance = ( - currency: Currency | null | undefined, - address?: string + currencyId: Currency['id'] | null | undefined, + address?: string, + typedChainId?: number ): number | null => { const { activeAccount, activeApi, activeChain, loading } = useWebContext(); + const [balance, setBalance] = useState(null); useEffect(() => { - if (!activeApi || !activeAccount || !activeChain || !currency || loading) { + if (!activeApi || !activeAccount || !activeChain || !currencyId || loading) { return; } + const typedChainIdToUse = + typedChainId ?? + calculateTypedChainId(activeChain.chainType, activeChain.id); + const subscription = activeApi.methods.chainQuery - .tokenBalanceByCurrencyId( - calculateTypedChainId(activeChain.chainType, activeChain.id), - currency.id, - address - ) + .tokenBalanceByCurrencyId(typedChainIdToUse, currencyId, address) .subscribe((currencyBalance) => { setBalance(Number(currencyBalance)); }); return () => subscription.unsubscribe(); - }, [activeAccount, activeApi, activeChain, address, currency, loading]); + }, [activeAccount, activeApi, activeChain, address, currencyId, loading, typedChainId]); // prettier-ignore return balance; }; diff --git a/libs/react-hooks/src/relayer/useRelayers.ts b/libs/react-hooks/src/relayer/useRelayers.ts index e98226e68e..e0cea37e60 100644 --- a/libs/react-hooks/src/relayer/useRelayers.ts +++ b/libs/react-hooks/src/relayer/useRelayers.ts @@ -1,19 +1,12 @@ -import { - OptionalActiveRelayer, - RelayersState, - WebbRelayer, -} from '@webb-tools/abstract-api-provider'; +import { RelayersState, WebbRelayer } from '@webb-tools/abstract-api-provider'; import { useWebContext } from '@webb-tools/api-provider-environment'; import { useCallback, useEffect, useState } from 'react'; -import { Subscription } from 'rxjs'; type UseRelayersProps = { typedChainId: number | undefined; target: string | number | undefined; }; -type SubscribeReturnType = Omit; - export const useRelayers = (props: UseRelayersProps) => { const { typedChainId, target } = props; const { activeApi } = useWebContext(); @@ -34,50 +27,44 @@ export const useRelayers = (props: UseRelayersProps) => { ); useEffect(() => { - let availableRelayersSubscription: SubscribeReturnType | null = null; - let activeRelayerSubscription: SubscribeReturnType | null = null; + if (!activeApi) { + return; + } - // Populate the relayersState - if (activeApi && typedChainId) { - activeApi.relayerManager - .getRelayersByChainAndAddress(typedChainId, String(target)) - .then((r: WebbRelayer[]) => { - setRelayersState((p) => ({ - ...p, - loading: false, - relayers: r, - })); - }); + const relayersSub = activeApi.relayerManager.listUpdated.subscribe( + async () => { + const typedChainIdToUse = + typedChainId ?? activeApi.typedChainidSubject.getValue(); - // Subscription used for listening to changes of the available relayers - availableRelayersSubscription = - activeApi?.relayerManager.listUpdated.subscribe(() => { - activeApi?.relayerManager - .getRelayersByChainAndAddress(typedChainId, String(target)) - .then((r: WebbRelayer[]) => { - setRelayersState((p) => ({ - ...p, - loading: false, - relayers: r, - })); - }); - }); + const relayers = + await activeApi.relayerManager.getRelayersByChainAndAddress( + typedChainIdToUse, + `${target ?? ''}` + ); - // Subscription used for listening to changes of the activeRelayer - activeRelayerSubscription = - activeApi?.relayerManager.activeRelayerWatcher.subscribe( - (next: OptionalActiveRelayer) => { - setRelayersState((p) => ({ - ...p, - activeRelayer: next, - })); - } - ); - } + setRelayersState((prev) => ({ + ...prev, + loading: false, + relayers, + })); + } + ); + + const activeSub = activeApi.relayerManager.activeRelayerWatcher.subscribe( + (next) => { + setRelayersState((prev) => ({ + ...prev, + activeRelayer: next, + })); + } + ); + + // trigger the relayer list update on mount + activeApi.relayerManager.listUpdated$.next(); return () => { - availableRelayersSubscription?.unsubscribe(); - activeRelayerSubscription?.unsubscribe(); + relayersSub.unsubscribe(); + activeSub.unsubscribe(); }; }, [activeApi, target, typedChainId]); diff --git a/libs/react-hooks/src/useNoteAccount.tsx b/libs/react-hooks/src/useNoteAccount.tsx index b6605d1c18..81dab1d0d6 100644 --- a/libs/react-hooks/src/useNoteAccount.tsx +++ b/libs/react-hooks/src/useNoteAccount.tsx @@ -21,10 +21,14 @@ type OnTryAgainCB = ( export type UseNoteAccountReturnType = { /** * The notes map Map - * */ allNotes: Map; + /** + * The flag to indicate if all notes are initialized + */ + allNotesInitialized: boolean; + /** * The flag to indicate if the user has a note account */ @@ -85,6 +89,7 @@ export const useNoteAccount = (): UseNoteAccountReturnType => { const [isSyncingNote, setIsSyncingNote] = useState(false); const [allNotes, setAllNotes] = useState>(new Map()); + const [allNotesInitialized, setAllNotesInitialized] = useState(false); const [isOpenNoteAccountModal, setIsOpenNoteAccountModal] = useState(false); @@ -234,12 +239,14 @@ export const useNoteAccount = (): UseNoteAccountReturnType => { // Effect to subscribe to noteManager useEffect(() => { if (!noteManager) { + setAllNotesInitialized(true); return; } // When the noteManager has its notes updated, update the react state for allNotes const noteUpdatedSub = noteManager.$notesUpdated.subscribe(() => { setAllNotes(new Map([...noteManager.getAllNotes()])); + setAllNotesInitialized(true); }); // Subscribe to the noteManager syncing state @@ -278,6 +285,7 @@ export const useNoteAccount = (): UseNoteAccountReturnType => { return { allNotes, + allNotesInitialized, hasNoteAccount, isOpenNoteAccountModal, isSuccessfullyCreatedNoteAccount, diff --git a/libs/react-hooks/src/vanchor/useVAnchor.tsx b/libs/react-hooks/src/vanchor/useVAnchor.tsx index 0eb79e1bf7..0b426fc8a8 100644 --- a/libs/react-hooks/src/vanchor/useVAnchor.tsx +++ b/libs/react-hooks/src/vanchor/useVAnchor.tsx @@ -9,14 +9,11 @@ export interface VAnchorAPI { addNoteToNoteManager(note: Note): Promise; removeNoteFromNoteManager(note: Note): Promise; cancel(): Promise; - error: string; api: VAnchorActions | null; startNewTransaction(): void; } export const useVAnchor = (): VAnchorAPI => { - const [error] = useState(''); - const { activeApi, txQueue: { api: txQueueApi }, @@ -98,7 +95,6 @@ export const useVAnchor = (): VAnchorAPI => { addNoteToNoteManager, api, cancel, - error, removeNoteFromNoteManager, startNewTransaction: txQueueApi.startNewTransaction, }; diff --git a/libs/relayer-manager-factory/src/relayer-manager-factory.ts b/libs/relayer-manager-factory/src/relayer-manager-factory.ts index 0a0321d48e..794cb97187 100644 --- a/libs/relayer-manager-factory/src/relayer-manager-factory.ts +++ b/libs/relayer-manager-factory/src/relayer-manager-factory.ts @@ -1,13 +1,11 @@ // Copyright 2022 @webb-tools/ // SPDX-License-Identifier: Apache-2.0 -import { WebbProviderType } from '@webb-tools/abstract-api-provider'; import { Capabilities, ChainNameIntoChainId, RelayerInfo, WebbRelayer, - WebbRelayerManager, } from '@webb-tools/abstract-api-provider/relayer'; import { LoggerService } from '@webb-tools/browser-utils'; import { @@ -116,7 +114,9 @@ export class WebbRelayerManagerFactory { return this.capabilities; } - public async fetchCapabilities(endpoint: string): Promise { + public async fetchCapabilities( + endpoint: string + ): Promise { try { const response = await fetch(`${endpoint}/api/v1/info`); const info: RelayerInfo = await response.json(); @@ -129,6 +129,8 @@ export class WebbRelayerManagerFactory { } catch (error) { console.error('Error fetching relayer info: ', error); } + + return null; } // Examine the data for saved (already fetched) capabilities. For easier @@ -184,10 +186,10 @@ export class WebbRelayerManagerFactory { }); switch (type) { - case 'evm': - return new Web3RelayerManager(relayers) as any; case 'substrate': return new PolkadotRelayerManager(relayers) as any; + default: + return new Web3RelayerManager(relayers) as any; } } } diff --git a/libs/tailwind-preset/index.js b/libs/tailwind-preset/index.js index 692917b734..6669e41420 100644 --- a/libs/tailwind-preset/index.js +++ b/libs/tailwind-preset/index.js @@ -225,6 +225,7 @@ const animation = { 'drawer-content-right-slide-out 150ms cubic-bezier(0.22, 1, 0.36, 1)', }; +/** @type {import('tailwindcss').Config} */ module.exports = { darkMode: 'class', content: [], @@ -233,6 +234,10 @@ module.exports = { colors, keyframes, animation, + boxShadow: { + 'webb-lg': '0px 8px 50px 0px rgba(0, 0, 0, 0.2)', + 'webb-lg-dark': '0px 8px 50px 0px rgba(0, 0, 0, 0.1)', + }, }, }, variants: { diff --git a/libs/web3-api-provider/src/webb-provider.ts b/libs/web3-api-provider/src/webb-provider.ts index f72de6a11e..0c09e0cb35 100644 --- a/libs/web3-api-provider/src/webb-provider.ts +++ b/libs/web3-api-provider/src/webb-provider.ts @@ -18,6 +18,7 @@ import { WebbState, calculateProvingLeavesAndCommitmentIndex, } from '@webb-tools/abstract-api-provider'; +import calculateProgressPercentage from '@webb-tools/abstract-api-provider/utils/calculateProgressPercentage'; import { EventBus } from '@webb-tools/app-util'; import { fetchVAnchorKeyFromAws, @@ -78,13 +79,13 @@ import { } from 'wagmi/actions'; import { MetaMaskConnector } from 'wagmi/connectors/metaMask'; import { WalletConnectConnector } from 'wagmi/connectors/walletConnect'; +import VAnchor from './VAnchor'; import { Web3Accounts } from './ext-provider'; import { Web3BridgeApi } from './webb-provider/bridge-api'; import { Web3ChainQuery } from './webb-provider/chain-query'; import { Web3RelayerManager } from './webb-provider/relayer-manager'; import { Web3VAnchorActions } from './webb-provider/vanchor-actions'; import { Web3WrapUnwrap } from './webb-provider/wrap-unwrap'; -import VAnchor from './VAnchor'; export class WebbWeb3Provider extends EventBus> @@ -356,11 +357,6 @@ export class WebbWeb3Provider // If unable to fetch leaves from the relayers, get them from chain if (!leavesFromRelayers || leavesFromRelayers.commitmentIndex === -1) { - tx?.next(TransactionState.FetchingLeaves, { - start: 0, // Dummy values - currentRange: [0, 0], // Dummy values - }); - const isLocal = LOCALNET_CHAIN_IDS.includes(+`${evmId}`); // check if we already cached some values in the storage and the chain id is not local @@ -388,7 +384,14 @@ export class WebbWeb3Provider BigInt(lastQueriedBlock + 1), ZERO_BIG_INT, getPublicClient({ chainId: +evmId.toString() }), - vAnchorContract + vAnchorContract, + (fromBlock, toBlock, currentBlock) => { + tx?.next(TransactionState.FetchingLeaves, { + start: +fromBlock.toString(), + end: +toBlock.toString(), + current: +currentBlock.toString(), + }); + } ); // Merge the leaves from chain with the stored leaves @@ -464,12 +467,11 @@ export class WebbWeb3Provider const toBlockNumber = +toBlock.toString(); const currenctBlockNumber = +currenctBlock.toString(); - const percentage = - ((currenctBlockNumber - fromBlockNumber) / - (toBlockNumber - fromBlockNumber + 1)) * - 100; - - const progress = percentage >= 100 ? 100 : percentage; + const progress = calculateProgressPercentage( + fromBlockNumber, + toBlockNumber, + currenctBlockNumber + ); NoteManager.syncNotesProgress = progress; } @@ -814,7 +816,12 @@ export class WebbWeb3Provider vAnchorContract: GetContractReturnType< typeof VAnchor__factory.abi, PublicClient - > + >, + onBlockProcessed?: ( + fromBlock: bigint, + toBlock: bigint, + currentBlock: bigint + ) => void ): Promise<{ lastQueriedBlock: bigint; newLeaves: Array }> { const latestBlock = finalBlockArg || (await publicClient.getBlockNumber()); @@ -822,7 +829,9 @@ export class WebbWeb3Provider publicClient, vAnchorContract, startingBlock, - latestBlock + latestBlock, + (currentBlock) => + onBlockProcessed?.(startingBlock, latestBlock, currentBlock) ); return { diff --git a/libs/web3-api-provider/src/webb-provider/relayer-manager.ts b/libs/web3-api-provider/src/webb-provider/relayer-manager.ts index 87b2c955b4..742d6fb603 100644 --- a/libs/web3-api-provider/src/webb-provider/relayer-manager.ts +++ b/libs/web3-api-provider/src/webb-provider/relayer-manager.ts @@ -28,10 +28,10 @@ import { LOCALNET_CHAIN_IDS } from '@webb-tools/dapp-config'; import { GetContractReturnType, PublicClient } from 'viem'; export class Web3RelayerManager extends WebbRelayerManager<'web3'> { - async mapRelayerIntoActive( + mapRelayerIntoActive( relayer: OptionalRelayer, typedChainId: number - ): Promise { + ): OptionalActiveRelayer { if (!relayer) { return null; } @@ -171,6 +171,10 @@ export class Web3RelayerManager extends WebbRelayerManager<'web3'> { tx?.cancelToken.abortSignal ); + console.log( + `Got ${leaves.length} leaves from relayer ${relayers[i].endpoint}` + ); + const result = await this.validateRelayerLeaves( treeHeight, leaves, diff --git a/libs/web3-api-provider/src/webb-provider/vanchor-actions.ts b/libs/web3-api-provider/src/webb-provider/vanchor-actions.ts index 7f5a30efce..ef487f2611 100644 --- a/libs/web3-api-provider/src/webb-provider/vanchor-actions.ts +++ b/libs/web3-api-provider/src/webb-provider/vanchor-actions.ts @@ -16,6 +16,7 @@ import { utxoFromVAnchorNote, VAnchorActions, } from '@webb-tools/abstract-api-provider'; +import { NeighborEdge } from '@webb-tools/abstract-api-provider/vanchor/types'; import { bridgeStorageFactory, registrationStorageFactory, @@ -93,8 +94,6 @@ export class Web3VAnchorActions extends VAnchorActions< payload: TransactionPayloadType, wrapUnwrapToken: string ): Promise> | never { - tx.next(TransactionState.PreparingTransaction, undefined); - if (isVAnchorDepositPayload(payload)) { // Get the wrapped token and check the balance and approvals const tokenWrapper = await this.getTokenWrapperContract(payload); @@ -152,7 +151,14 @@ export class Web3VAnchorActions extends VAnchorActions< leavesMap, // leavesMap ]); } else if (isVAnchorTransferPayload(payload)) { - const { changeUtxo, transferUtxo, notes, feeAmount } = payload; + const { + changeUtxo, + transferUtxo, + notes, + feeAmount, + refundAmount = ZERO_BIG_INT, + refundRecipient = ZERO_ADDRESS, + } = payload; const { inputUtxos, leavesMap } = await this.commitmentsSetup(notes, tx); @@ -161,6 +167,9 @@ export class Web3VAnchorActions extends VAnchorActions< // If no relayer is set, then the fee is 0, otherwise it is the fee amount const feeVal = relayer === ZERO_ADDRESS ? ZERO_BIG_INT : feeAmount; + const refund = relayer === ZERO_ADDRESS ? ZERO_BIG_INT : refundAmount; + const recipient = + relayer === ZERO_ADDRESS ? ZERO_ADDRESS : refundRecipient; // set the anchor to make the transfer on (where the notes are being spent for the transfer) return Promise.resolve([ @@ -169,8 +178,8 @@ export class Web3VAnchorActions extends VAnchorActions< inputUtxos, // inputs [changeUtxo, transferUtxo], // outputs feeVal, // fee - ZERO_BIG_INT, // refund - ZERO_ADDRESS, // recipient + refund, // refund + ensureHex(recipient), // recipient ensureHex(relayer), // relayer '', // wrapUnwrapToken (not used for transfers) leavesMap, // leavesMap, @@ -185,17 +194,15 @@ export class Web3VAnchorActions extends VAnchorActions< activeRelayer: ActiveWebbRelayer, txArgs: ParametersOfTransactMethod<'web3'>, changeNotes: Note[] - ): Promise | never { - let txHash = ''; - + ): Promise | never { const [tx, contractAddress, rawInputUtxos, rawOutputUtxos, ...restArgs] = txArgs; - const relayedVAnchorWithdraw = await activeRelayer.initWithdraw('vAnchor'); - const vAnchorContract = this.inner.getVAnchorContractByAddress(contractAddress); + tx.next(TransactionState.GeneratingZk, undefined); + const chainId = await vAnchorContract.read.getChainId(); const chainInfo: RelayedChainInput = { @@ -220,6 +227,10 @@ export class Web3VAnchorActions extends VAnchorActions< ...restArgs ); + tx.next(TransactionState.InitializingTransaction, undefined); + + const relayedVAnchorWithdraw = await activeRelayer.initWithdraw('vAnchor'); + const relayedDepositTxPayload = relayedVAnchorWithdraw.generateWithdrawRequest< typeof chainInfo, @@ -294,12 +305,9 @@ export class Web3VAnchorActions extends VAnchorActions< // Send the transaction to the relayer. relayedVAnchorWithdraw.send(relayedDepositTxPayload, +`${chainId}`); - const results = await relayedVAnchorWithdraw.await(); - if (results) { - const [, message] = results; - txHash = message ?? ''; - tx.txHash = txHash; - } + const [, txHash = ''] = await relayedVAnchorWithdraw.await(); + tx.txHash = txHash; + return ensureHex(txHash); } async transact( @@ -320,7 +328,6 @@ export class Web3VAnchorActions extends VAnchorActions< ); tx.txHash = ''; - tx.next(TransactionState.SendingTransaction, ''); const typedChainId = this.inner.typedChainId; @@ -349,10 +356,34 @@ export class Web3VAnchorActions extends VAnchorActions< tx.txHash = hash; + return hash; + } + + async waitForFinalization(hash: Hash): Promise { // Wait for the transaction to be finalized. await this.inner.publicClient.waitForTransactionReceipt({ hash }); + } - return hash; + async getLatestNeighborEdges( + fungibleId: number, + typedChainIdArg?: number | undefined + ): Promise> { + const typedChainId = typedChainIdArg ?? this.inner.typedChainId; + const anchorId = this.inner.config.getAnchorIdentifier( + fungibleId, + typedChainId + ); + + if (!anchorId) { + throw WebbError.from(WebbErrorCodes.AnchorIdNotFound); + } + + const vAnchorContract = this.inner.getVAnchorContractByAddressAndProvider( + anchorId, + getPublicClient({ chainId: parseTypedChainId(typedChainId).chainId }) + ); + + return vAnchorContract.read.getLatestNeighborEdges(); } // Check if the evm address and keyData pairing has already registered. @@ -760,6 +791,8 @@ export class Web3VAnchorActions extends VAnchorActions< } } + console.log('Note index: ', parsedNote.index); + console.log('Commitment index: ', commitmentIndex); const utxo = await utxoFromVAnchorNote(parsedNote, commitmentIndex); return { @@ -870,6 +903,13 @@ export class Web3VAnchorActions extends VAnchorActions< PublicClient > ): Promise | never { + tx.next(TransactionState.Intermediate, { + name: 'Checking approval', + data: { + tokenAddress: wrapUnwrapToken, + }, + }); + const { note } = payload; const { amount } = note; diff --git a/libs/webb-ui-components/.storybook/main.js b/libs/webb-ui-components/.storybook/main.js index 2ad37711c9..e34e8d25d4 100644 --- a/libs/webb-ui-components/.storybook/main.js +++ b/libs/webb-ui-components/.storybook/main.js @@ -1,4 +1,5 @@ const webpack = require('webpack'); +const path = require('path'); const remarkGfm = require('remark-gfm'); const rootMain = require('../../../.storybook/main'); @@ -8,9 +9,11 @@ module.exports = { '../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)', ], + features: { + storyStoreV7: false, // 👈 Opt out of on-demand story loading + }, addons: [ ...rootMain.addons, - '@nx/react/plugins/storybook', { name: '@storybook/addon-docs', options: { @@ -21,6 +24,8 @@ module.exports = { }, }, }, + '@storybook/addon-styling', + '@nx/react/plugins/storybook', ], webpackFinal: async (config, { configType }) => { // apply any global webpack configs that might have been specified in .storybook/main.js @@ -49,6 +54,21 @@ module.exports = { loader: require.resolve('@svgr/webpack'), }); + config.module.rules.push({ + test: /\.css$/, + use: [ + { + loader: 'postcss-loader', + options: { + postcssOptions: { + plugins: [require('tailwindcss'), require('autoprefixer')], + }, + }, + }, + ], + include: path.resolve(__dirname, '../'), + }); + return config; }, framework: { diff --git a/libs/webb-ui-components/.storybook/override.css b/libs/webb-ui-components/.storybook/override.css new file mode 100644 index 0000000000..de25b29f81 --- /dev/null +++ b/libs/webb-ui-components/.storybook/override.css @@ -0,0 +1,3 @@ +body { + background-color: #fff; +} diff --git a/libs/webb-ui-components/.storybook/preview.js b/libs/webb-ui-components/.storybook/preview.js index 0cf0d49e22..74e849ddd2 100644 --- a/libs/webb-ui-components/.storybook/preview.js +++ b/libs/webb-ui-components/.storybook/preview.js @@ -1,4 +1,6 @@ +import { withThemeByClassName } from '@storybook/addon-styling'; import '../src/tailwind.css'; +import './override.css'; export const parameters = { actions: { argTypesRegex: '^on[A-Z].*' }, @@ -10,3 +12,13 @@ export const parameters = { }, }, }; + +export const decorators = [ + withThemeByClassName({ + themes: { + light: '', + dark: 'dark', + }, + defaultTheme: 'light', + }), +]; diff --git a/libs/webb-ui-components/src/components/Accordion/AccordionButtonBase.tsx b/libs/webb-ui-components/src/components/Accordion/AccordionButtonBase.tsx new file mode 100644 index 0000000000..f5c323862e --- /dev/null +++ b/libs/webb-ui-components/src/components/Accordion/AccordionButtonBase.tsx @@ -0,0 +1,20 @@ +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { forwardRef } from 'react'; + +import { AccordionButtonBaseProps } from './types'; + +/** + * The wrapper around Radix Accordion Trigger, must use inside `` tag + */ +const AccordionButtonBase = forwardRef< + HTMLButtonElement, + AccordionButtonBaseProps +>((props, ref) => { + return ( + + + + ); +}); + +export default AccordionButtonBase; diff --git a/libs/webb-ui-components/src/components/Accordion/index.ts b/libs/webb-ui-components/src/components/Accordion/index.ts index c2a935b733..61a059e225 100644 --- a/libs/webb-ui-components/src/components/Accordion/index.ts +++ b/libs/webb-ui-components/src/components/Accordion/index.ts @@ -1,4 +1,5 @@ export * from './Accordion'; export * from './AccordionButton'; +export { default as AccordionButtonBase } from './AccordionButtonBase'; export * from './AccordionContent'; export * from './AccordionItem'; diff --git a/libs/webb-ui-components/src/components/Accordion/types.ts b/libs/webb-ui-components/src/components/Accordion/types.ts index 1e72f2f1d0..e31c69c400 100644 --- a/libs/webb-ui-components/src/components/Accordion/types.ts +++ b/libs/webb-ui-components/src/components/Accordion/types.ts @@ -15,6 +15,8 @@ export interface AccordionButtonProps PropsOf<'button'>, AccordionTriggerProps {} +export interface AccordionButtonBaseProps extends AccordionTriggerProps {} + export interface AccordionContentProps extends IWebbComponentBase, RdxAccordionContentProps {} diff --git a/libs/webb-ui-components/src/components/AmountMenu/AmountMenu.tsx b/libs/webb-ui-components/src/components/AmountMenu/AmountMenu.tsx index 2af68586a5..af87b535a0 100644 --- a/libs/webb-ui-components/src/components/AmountMenu/AmountMenu.tsx +++ b/libs/webb-ui-components/src/components/AmountMenu/AmountMenu.tsx @@ -3,7 +3,7 @@ import { forwardRef } from 'react'; import { twMerge } from 'tailwind-merge'; import { Typography } from '../../typography'; -import { Button } from '../Button'; +import { Button } from '../buttons'; import { Chip } from '../Chip'; import { Divider } from '../Divider'; import { AmountMenuProps } from './types'; diff --git a/libs/webb-ui-components/src/components/Badge/Badge.tsx b/libs/webb-ui-components/src/components/Badge/Badge.tsx new file mode 100644 index 0000000000..2673409c76 --- /dev/null +++ b/libs/webb-ui-components/src/components/Badge/Badge.tsx @@ -0,0 +1,64 @@ +import { CheckboxBlankCircleLine } from '@webb-tools/icons'; +import { cloneElement, forwardRef } from 'react'; +import { BadgeColor, BadgeProps } from './types'; +import cx from 'classnames'; +import { twMerge } from 'tailwind-merge'; + +const classNames: { + [key in BadgeColor]: { + icon: string; + wrapper: string; + }; +} = { + green: { + icon: cx('fill-green-90 dark:fill-green-30'), + wrapper: cx('fill-green-10 dark:fill-green-120'), + }, + blue: { + icon: cx('fill-blue-90 dark:fill-blue-30'), + wrapper: cx('fill-blue-10 dark:fill-blue-120'), + }, + purple: { + icon: cx('fill-purpose-90 dark:fill-purpose-30'), + wrapper: cx('fill-purpose-10 dark:fill-purpose-120'), + }, + red: { + icon: cx('fill-red-90 dark:fill-red-30'), + wrapper: cx('fill-red-10 dark:fill-red-120'), + }, + yellow: { + icon: cx('fill-yellow-90 dark:fill-yellow-30'), + wrapper: cx('fill-yellow-10 dark:fill-yellow-120'), + }, +}; + +const Badge = forwardRef( + ({ icon = , color = 'blue', ...props }, ref) => { + return ( + + + + {cloneElement(icon, { + ...icon.props, + className: twMerge(icon.props.className, classNames[color].icon), + size: 'md', + })} + + + ); + } +); + +export default Badge; diff --git a/libs/webb-ui-components/src/components/Badge/index.ts b/libs/webb-ui-components/src/components/Badge/index.ts new file mode 100644 index 0000000000..54c3c605b8 --- /dev/null +++ b/libs/webb-ui-components/src/components/Badge/index.ts @@ -0,0 +1,6 @@ +import Badge from './Badge'; + +export * from './Badge'; +export { default as Badge } from './Badge'; + +export default Badge; diff --git a/libs/webb-ui-components/src/components/Badge/types.ts b/libs/webb-ui-components/src/components/Badge/types.ts new file mode 100644 index 0000000000..7223042356 --- /dev/null +++ b/libs/webb-ui-components/src/components/Badge/types.ts @@ -0,0 +1,18 @@ +import { IconBase } from '@webb-tools/icons/types'; +import { PropsOf } from '../../types'; + +export type BadgeColor = 'green' | 'blue' | 'purple' | 'red' | 'yellow'; + +export interface BadgeProps extends PropsOf<'svg'> { + /** + * The icon to be used in the badge. + * @default + */ + icon?: React.ReactElement; + + /** + * The color of the badge. + * @default 'blue' + */ + color?: BadgeColor; +} diff --git a/libs/webb-ui-components/src/components/Banner/Banner.tsx b/libs/webb-ui-components/src/components/Banner/Banner.tsx index 1bda9f93c7..ad34b5bbc8 100644 --- a/libs/webb-ui-components/src/components/Banner/Banner.tsx +++ b/libs/webb-ui-components/src/components/Banner/Banner.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { twMerge } from 'tailwind-merge'; -import { Button } from '../Button'; +import { Button } from '../buttons'; import { BannerPropsType } from './types'; import { BlockIcon, Close, GraphIcon } from '@webb-tools/icons'; import { Typography } from '../../typography'; diff --git a/libs/webb-ui-components/src/components/Banner/types.ts b/libs/webb-ui-components/src/components/Banner/types.ts index 99372eb9c9..6a69dcb2ec 100644 --- a/libs/webb-ui-components/src/components/Banner/types.ts +++ b/libs/webb-ui-components/src/components/Banner/types.ts @@ -1,6 +1,6 @@ import { PropsOf, WebbComponentBase } from '../../types'; import { ComponentProps } from 'react'; -import { Button } from '../Button'; +import { Button } from '../buttons'; export interface BannerPropsType extends WebbComponentBase { /** diff --git a/libs/webb-ui-components/src/components/BottomDialog/BottomDialogPortal.tsx b/libs/webb-ui-components/src/components/BottomDialog/BottomDialogPortal.tsx index 151cd4ec62..206785012b 100644 --- a/libs/webb-ui-components/src/components/BottomDialog/BottomDialogPortal.tsx +++ b/libs/webb-ui-components/src/components/BottomDialog/BottomDialogPortal.tsx @@ -3,7 +3,7 @@ import { twMerge } from 'tailwind-merge'; import * as Dialog from '@radix-ui/react-dialog'; import { Close } from '@webb-tools/icons'; -import { Button } from '../Button'; +import { Button } from '../buttons'; import { Typography } from '../../typography'; import { BottomDialogPortalProps } from './types'; diff --git a/libs/webb-ui-components/src/components/BottomDialog/types.ts b/libs/webb-ui-components/src/components/BottomDialog/types.ts index 973c77eb9b..0e1f766728 100644 --- a/libs/webb-ui-components/src/components/BottomDialog/types.ts +++ b/libs/webb-ui-components/src/components/BottomDialog/types.ts @@ -7,7 +7,7 @@ import { } from '@radix-ui/react-dialog'; import { PropsOf, IWebbComponentBase } from '../../types'; -import { ButtonProps } from '../Button/types'; +import { ButtonProps } from '../buttons/types'; export interface BottomDialogProps extends PropsOf<'div'>, IWebbComponentBase { radixRootProps?: RdxDialogProps; diff --git a/libs/webb-ui-components/src/components/Breadcrumbs/Breadcrumbs.tsx b/libs/webb-ui-components/src/components/Breadcrumbs/Breadcrumbs.tsx index d4151b78dc..40103b4539 100644 --- a/libs/webb-ui-components/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/libs/webb-ui-components/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -13,7 +13,7 @@ import { BreadcrumbsSeparator } from './BreadcrumbsSeparator'; * * }>Tangle Explorer * }>Keys Overview - * } isLast>Keygen details + * } isLast>Keygen details * * ``` */ diff --git a/libs/webb-ui-components/src/components/BridgeInputs/AdjustAmount.tsx b/libs/webb-ui-components/src/components/BridgeInputs/AdjustAmount.tsx new file mode 100644 index 0000000000..4204561dee --- /dev/null +++ b/libs/webb-ui-components/src/components/BridgeInputs/AdjustAmount.tsx @@ -0,0 +1,145 @@ +import { + AddCircleFillIcon, + AddCircleLineIcon, + IndeterminateCircleFillIcon, + IndeterminateCircleLineIcon, +} from '@webb-tools/icons'; +import Decimal from 'decimal.js'; +import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; +import { twMerge } from 'tailwind-merge'; +import { Input } from '../Input'; +import { AdjustAmountProps } from './types'; + +/** + * The `AdjustAmount` component + * + * Props: + * + * - `id`: The `id` prop for label and input (defaults to "adjust-amount") + * - `value`: The value prop + * - `onChange`: The callback function to control the component + * - `min`: The minimum value + * - `max`: The maximum value + * - `step`: The step value (defaults to 0.5) + * + * @example + * + * ```jsx + * setValue(nextVal)} /> + * ``` + */ +export const AdjustAmount = forwardRef( + ( + { + className, + iconClassName, + id = 'adjust-amount', + isDisabled, + max, + min, + onChange, + step = 0.5, + value: valueProp = 0, + ...props + }, + ref + ) => { + const [value, setValue] = useState(valueProp); + + // Subscribes to the `value` prop + useEffect(() => { + setValue(valueProp); + }, [valueProp]); + + const sharedIconBtnClsx = useMemo( + () => + twMerge( + 'group', + 'fill-mono-160 hover:fill-mono-200 disabled:fill-mono-100', + 'dark:fill-mono-40 dark:disabled:fill-mono-120', + iconClassName + ), + [iconClassName] + ); + + const handleMinusClick = useCallback(() => { + const nextVal = new Decimal(value).minus(step); + + // If the component is disabled or `min` is defined and `nextVal` is less than `min`, + // then we don't need to update the value + if (isDisabled || (typeof min === 'number' && nextVal.lt(min))) { + return; + } + + setValue(nextVal.toNumber()); + onChange?.(nextVal.toNumber()); + }, [isDisabled, min, onChange, step, value]); + + const handlePlusClick = useCallback(() => { + const nextVal = new Decimal(value).plus(step); + + // If the component is disabled or `max` is defined and `nextVal` is greater than `max`, + // then we don't need to update the value + if (isDisabled || (typeof max === 'number' && nextVal.gt(max))) { + return; + } + + setValue(nextVal.toNumber()); + onChange?.(nextVal.toNumber()); + }, [isDisabled, max, onChange, step, value]); + + return ( +
+ + + + + +
+ ); + } +); diff --git a/libs/webb-ui-components/src/components/BridgeInputs/AmountInput.tsx b/libs/webb-ui-components/src/components/BridgeInputs/AmountInput.tsx index 6862879e20..29b896fd75 100644 --- a/libs/webb-ui-components/src/components/BridgeInputs/AmountInput.tsx +++ b/libs/webb-ui-components/src/components/BridgeInputs/AmountInput.tsx @@ -6,7 +6,7 @@ import { twMerge } from 'tailwind-merge'; import { Typography } from '../../typography/Typography'; import { AmountMenu } from '../AmountMenu'; import { InputWrapper } from '../BridgeInputs/InputWrapper'; -import { Button } from '../Button'; +import { Button } from '../buttons'; import { Dropdown, DropdownBody } from '../Dropdown'; import { Input } from '../Input/Input'; import { Label } from '../Label'; diff --git a/libs/webb-ui-components/src/components/BridgeInputs/FixedAmount.tsx b/libs/webb-ui-components/src/components/BridgeInputs/FixedAmount.tsx index 91d82df6a9..1aacc86862 100644 --- a/libs/webb-ui-components/src/components/BridgeInputs/FixedAmount.tsx +++ b/libs/webb-ui-components/src/components/BridgeInputs/FixedAmount.tsx @@ -2,7 +2,7 @@ import { ChevronDown } from '@webb-tools/icons'; import cx from 'classnames'; import { forwardRef, useCallback, useEffect, useState } from 'react'; import { AmountMenu } from '../AmountMenu'; -import { Button } from '../Button'; +import { Button } from '../buttons'; import { Label } from '../Label'; import { TitleWithInfo } from '../TitleWithInfo'; import { InputWrapper } from './InputWrapper'; @@ -28,7 +28,6 @@ import { Trigger as DropdownTrigger } from '@radix-ui/react-dropdown-menu'; *