From 6d25a072bde5858bc38138c2d42d3acaca42b9e0 Mon Sep 17 00:00:00 2001 From: aleka Date: Mon, 28 Aug 2023 17:51:03 -0400 Subject: [PATCH 1/2] Add instructions for deploying with vercel in readme --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e627075..217af5a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ - Node.js version 18 and `pnpm` installed on your system +For deploying with Vercel, create an account with [Vercel](https://vercel.com/signup) if you don't have one already. + For deploying to IPFS, choose one of the following: - **Option 1:** A free [web3.storage](https://web3.storage/) account @@ -51,7 +53,24 @@ pnpm ladle This will automatically open your default browser at `http://localhost:61000`. -## Part 3: Deploying to IPFS +## Part 3: Deploying with Vercel + +### Step 1: Connect your repository to Vercel + +Select "Import Git Repository" from your dashboard, and provide the URL of this repository or your forked repository. + +### Step 2: Configure your project + +For the "Build & Development Settings", we recommend the following: +- Framework Preset: `Vite` +- Build Command (override): `pnpm run build` + +If you wish to incorporate analytics via Amplitude and Bugsnag, you can use our scripts: +`pnpm run build:inject-amplitude` and `pnpm run build:inject-bugsnag`. You will need to provide your own API keys for these services. In the Environment Variables section, name the variables as `AMPLITUDE_API_KEY` and `BUGSNAG_API_KEY` and provide the respective keys as their values. + +For more details, check out Vercel's [official documentation](https://vercel.com/docs). + +## Part 4: Deploying to IPFS ### web3.storage: deploy to IPFS via web3.storage using the provided script @@ -74,7 +93,7 @@ ipfs add -r dist Save the CID provided in the output. -## Part 4: Accessing your content on IPFS +### Accessing your content on IPFS To access your content on IPFS: @@ -82,4 +101,4 @@ To access your content on IPFS: 2. **Public IPFS gateway:** Access your content via a public IPFS gateway, such as [https://dweb.link](https://dweb.link/) or [https://w3s.link/](https://w3s.link/). Use the gateway URL with your CID appended, like `https://dweb.link/ipfs/your_cid`. -Replace `your_cid` with the actual CID. \ No newline at end of file +Replace `your_cid` with the actual CID. From e6357784389857e78a4f9818aa331238060c9191 Mon Sep 17 00:00:00 2001 From: Bill Date: Tue, 29 Aug 2023 12:11:20 -0700 Subject: [PATCH 2/2] Deposit/Withdraw with abacus (#3) * Deposit/Withdraw with abacus * revert package * e2e working deposit/withdraws * deprecate usesquidrouter * remove unused types * fix chain select menu * fix switch network bug * working switch network deposits * fix withdraw chain select * address comments * address comments * address comments * fix clear bug * use diffarrow * address nits --- src/App.tsx | 6 +- src/components/SearchSelectMenu.tsx | 15 +- src/constants/abacus.ts | 3 + src/constants/dialogs.ts | 1 + src/constants/localization/app.ts | 2 + src/constants/localization/errors.ts | 3 + src/hooks/index.ts | 2 + src/hooks/useAccountBalance.ts | 23 +- src/hooks/useAccounts.tsx | 7 +- src/hooks/useDebounce.ts | 21 + src/hooks/useInitializePage.ts | 7 - src/hooks/useSquidRouter.tsx | 52 +++ src/hooks/useSubaccount.tsx | 53 ++- src/layout/DialogManager.tsx | 2 + src/lib/abacus/index.ts | 27 ++ src/lib/squidRouter.ts | 140 ------ src/lib/wagmi.ts | 27 +- src/localization/en/app.json | 4 +- src/localization/en/errors.json | 5 +- src/state/appMiddleware.ts | 6 - src/styles/popoverMixins.ts | 2 +- .../AccountInfo/AccountInfoConnectedState.tsx | 35 +- src/views/dialogs/DepositDialog.tsx | 8 +- src/views/dialogs/OnboardingDialog.tsx | 2 +- src/views/dialogs/WithdrawDialog.tsx | 56 +++ .../ChainSelectMenu.tsx | 44 +- .../AccountManagementForms/DepositForm.tsx | 342 ++++++++++++++ .../DepositForm/DepositButtonAndReceipt.tsx | 92 ++-- .../SlippageEditor.tsx | 9 +- .../TestnetDepositForm.tsx | 0 .../TokenSelectMenu.tsx | 95 ++++ .../AccountManagementForms/WithdrawForm.tsx | 358 +++++++++++++++ .../WithdrawForm/WithdrawButtonAndReceipt.tsx | 242 ++++++++++ src/views/forms/DepositForm.tsx | 428 ------------------ .../forms/DepositForm/TokenSelectMenu.tsx | 89 ---- src/views/forms/DepositForm/useSquidData.ts | 33 -- 36 files changed, 1406 insertions(+), 835 deletions(-) create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/hooks/useSquidRouter.tsx delete mode 100644 src/lib/squidRouter.ts create mode 100644 src/views/dialogs/WithdrawDialog.tsx rename src/views/forms/{DepositForm => AccountManagementForms}/ChainSelectMenu.tsx (51%) create mode 100644 src/views/forms/AccountManagementForms/DepositForm.tsx rename src/views/forms/{ => AccountManagementForms}/DepositForm/DepositButtonAndReceipt.tsx (73%) rename src/views/forms/{DepositForm => AccountManagementForms}/SlippageEditor.tsx (96%) rename src/views/forms/{ => AccountManagementForms}/TestnetDepositForm.tsx (100%) create mode 100644 src/views/forms/AccountManagementForms/TokenSelectMenu.tsx create mode 100644 src/views/forms/AccountManagementForms/WithdrawForm.tsx create mode 100644 src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx delete mode 100644 src/views/forms/DepositForm.tsx delete mode 100644 src/views/forms/DepositForm/TokenSelectMenu.tsx delete mode 100644 src/views/forms/DepositForm/useSquidData.ts diff --git a/src/App.tsx b/src/App.tsx index 1869616..db877bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,8 @@ import { DialogAreaProvider, useDialogArea } from './hooks/useDialogArea'; import { LocaleProvider } from './hooks/useLocaleSeparators'; import { NotificationsProvider } from './hooks/useNotifications'; import { SubaccountProvider } from './hooks/useSubaccount'; +import { SquidProvider } from '@/hooks/useSquidRouter'; + import { GuardedMobileRoute } from '@/components/GuardedMobileRoute'; import MarketsPage from '@/pages/markets/Markets'; @@ -106,7 +108,9 @@ const App = () => ( - + + + diff --git a/src/components/SearchSelectMenu.tsx b/src/components/SearchSelectMenu.tsx index 1fd1ef7..3a810d4 100644 --- a/src/components/SearchSelectMenu.tsx +++ b/src/components/SearchSelectMenu.tsx @@ -19,6 +19,7 @@ type ElementProps = { disabled?: boolean; label?: string; items: MenuConfig; + slotEmpty?: ReactNode; withSearch?: boolean; withReceiptItems?: DetailsItem[]; }; @@ -52,12 +53,10 @@ export const SearchSelectMenu = ({ const Trigger = asChild ? ( children ) : ( - <> - - {children} - + + {label ? {children} : children} - + ); return ( @@ -86,7 +85,7 @@ export const SearchSelectMenu = ({ const Styled: Record = {}; Styled.MenuTrigger = styled.div` - height: 3.5rem; + height: var(--form-input-height); ${layoutMixins.spacedRow} align-items: center; @@ -117,7 +116,7 @@ Styled.WithDetailsReceipt = styled(WithDetailsReceipt)` Styled.Popover = styled(Popover)` max-height: 30vh; margin-top: 1rem; - border: 1px solid var(--color-layer-6); + border: var(--border-width) solid var(--color-layer-6); border-radius: 0.5rem; z-index: 2; box-shadow: none; @@ -136,6 +135,8 @@ Styled.ComboboxMenu = styled(ComboboxMenu)` --comboboxMenu-item-checked-textColor: var(--color-text-2); --comboboxMenu-item-highlighted-textColor: var(--color-text-2); + --stickyArea1-topHeight: var(--form-input-height); + input:focus { outline: none; } diff --git a/src/constants/abacus.ts b/src/constants/abacus.ts index a769f95..8ba534b 100644 --- a/src/constants/abacus.ts +++ b/src/constants/abacus.ts @@ -98,6 +98,9 @@ export type ClosePositionInputs = Abacus.exchange.dydx.abacus.output.input.Close export type TradeInputSummary = Abacus.exchange.dydx.abacus.output.input.TradeInputSummary; export type TransferInputs = Abacus.exchange.dydx.abacus.output.input.TransferInput; export type InputError = Abacus.exchange.dydx.abacus.output.input.ValidationError; +export type TransferInputTokenResource = Abacus.exchange.dydx.abacus.output.input.TransferInputTokenResource; +export type TransferInputChainResource = Abacus.exchange.dydx.abacus.output.input.TransferInputChainResource; +export type SelectionOption = Abacus.exchange.dydx.abacus.output.input.SelectionOption; export const ErrorType = Abacus.exchange.dydx.abacus.output.input.ErrorType; export const InputSelectionOption = Abacus.exchange.dydx.abacus.output.input.SelectionOption; diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index 5655452..ed20d3f 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -12,6 +12,7 @@ export enum DialogTypes { Receive = 'Receive', Trade = 'Trade', Transfer = 'Transfer', + Withdraw = 'Withdraw', } export enum TradeBoxDialogTypes { diff --git a/src/constants/localization/app.ts b/src/constants/localization/app.ts index a392340..55263c8 100644 --- a/src/constants/localization/app.ts +++ b/src/constants/localization/app.ts @@ -474,6 +474,7 @@ export const APP_STRING_KEYS = { LEAVING_WEBSITE: 'LEGAL.LEAVING_WEBSITE', // Deposit Modal + BRIDGE_FEE: 'DEPOSIT_MODAL.BRIDGE_FEE', ENABLE_FOR_THIS_DEPOSIT: 'DEPOSIT_MODAL.ENABLE_FOR_THIS_DEPOSIT', ENABLE_SYMBOL_ON_DYDX: 'DEPOSIT_MODAL.ENABLE_SYMBOL_ON_DYDX', ENABLE_SYMBOL: 'DEPOSIT_MODAL.ENABLE_SYMBOL', @@ -930,6 +931,7 @@ export const APP_STRING_KEYS = { SLOW_WITHDRAW_PENDING: 'NOTIFICATIONS.SLOW_WITHDRAW_PENDING', SUSPICIOUS_TRADE: 'NOTIFICATIONS.SUSPICIOUS_TRADE', SUSPICIOUS_TRADE_BODY: 'NOTIFICATIONS.SUSPICIOUS_TRADE_BODY', + WITHDRAW_IN_PROGRESS: 'NOTIFICATIONS.WITHDRAW_IN_PROGRESS', // Email Notifications ACCOUNT_DESCRIPTION: 'EMAIL_NOTIFICATIONS.ACCOUNT_DESCRIPTION', diff --git a/src/constants/localization/errors.ts b/src/constants/localization/errors.ts index a8f931f..5c45f8d 100644 --- a/src/constants/localization/errors.ts +++ b/src/constants/localization/errors.ts @@ -78,6 +78,9 @@ export const ERROR_STRING_KEYS = { RESTRICTED_OPEN_POSITIONS_WITHDRAW: 'ERRORS.WITHDRAW_MODAL.RESTRICTED_OPEN_POSITIONS_WITHDRAW', WITHDRAW_MORE_THAN_FREE_DUE_TO_FEE: 'ERRORS.WITHDRAW_MODAL.WITHDRAW_MORE_THAN_FREE_DUE_TO_FEE', WITHDRAW_MORE_THAN_FREE: 'ERRORS.WITHDRAW_MODAL.WITHDRAW_MORE_THAN_FREE', + WITHDRAW_MUST_SPECIFY_ADDRESS: 'ERRORS.DEPOSIT_MODAL.WITHDRAW_MUST_SPECIFY_ADDRESS', + WITHDRAW_MUST_SPECIFY_ASSET: 'ERRORS.DEPOSIT_MODAL.WITHDRAW_MUST_SPECIFY_ASSET', + WITHDRAW_MUST_SPECIFY_CHAIN: 'ERRORS.DEPOSIT_MODAL.WITHDRAW_MUST_SPECIFY_CHAIN', // Transfer modal errors TRANSFER_INVALID_ETH_ADDRESS: 'ERRORS.TRANSFER_MODAL.TRANSFER_INVALID_ETH_ADDRESS', diff --git a/src/hooks/index.ts b/src/hooks/index.ts index bc0c3cd..d6c228d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -2,6 +2,7 @@ import { useApiState } from './useApiState'; import { useBreakpoints } from './useBreakpoints'; import { useCommandMenu } from './useCommandMenu'; import { useCurrentMarketId } from './useCurrentMarketId'; +import { useDebounce } from './useDebounce'; import { useInterval } from './useInterval'; import { useDocumentTitle } from './useDocumentTitle'; import { useDydxClient } from './useDydxClient'; @@ -25,6 +26,7 @@ export { useBreakpoints, useCommandMenu, useCurrentMarketId, + useDebounce, useDocumentTitle, useDydxClient, useAccountBalance, diff --git a/src/hooks/useAccountBalance.ts b/src/hooks/useAccountBalance.ts index 1885b90..ea9269a 100644 --- a/src/hooks/useAccountBalance.ts +++ b/src/hooks/useAccountBalance.ts @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import { useBalance } from 'wagmi'; import { StargateClient } from '@cosmjs/stargate'; import { useQuery } from 'react-query'; -import { formatUnits } from 'ethers'; +import { formatUnits } from 'viem'; import { CLIENT_NETWORK_CONFIGS, type DydxV4Network } from '@/constants/networks'; import { QUANTUM_MULTIPLIER } from '@/constants/numbers'; @@ -43,7 +43,7 @@ export const useAccountBalance = ({ assetSymbol, bech32AddrPrefix, chainId, - decimals, + decimals = 0, rpc, isCosmosChain, }: UseAccountBalanceProps = {}) => { @@ -66,7 +66,7 @@ export const useAccountBalance = ({ }); const cosmosQueryFn = useCallback(async () => { - if (dydxAddress && bech32AddrPrefix && rpc) { + if (dydxAddress && bech32AddrPrefix && rpc && addressOrDenom) { const address = convertBech32Address({ address: dydxAddress, bech32Prefix: bech32AddrPrefix, @@ -76,14 +76,7 @@ export const useAccountBalance = ({ const balanceAsCoin = await client.getBalance(address, addressOrDenom); await client.disconnect(); - /** - * Hardcode decimals because most cosmos coins default to 6 decimals. - * Squids testnet has some coins as having 18 decimals instead of 6. - * @url https://github1s.com/cosmos/cosmjs/blob/HEAD/packages/amino/src/coins.ts#L8-L16 - * - * @todo: change '6' to 'decimals' when Squid testnet data is updated. - * */ - return formatUnits(balanceAsCoin.amount, decimals); + return formatUnits(BigInt(balanceAsCoin.amount), decimals); } }, [addressOrDenom, chainId, rpc]); @@ -109,11 +102,7 @@ export const useAccountBalance = ({ return { balance, nativeTokenBalance, - isBalanceError: isCosmosChain - ? cosmosQuery.status === 'error' && !cosmosQuery.isFetching - : evmQuery.status === 'error' && evmQuery.fetchStatus === 'idle', - isBalanceLoading: isCosmosChain - ? cosmosQuery.status === 'loading' && cosmosQuery.isFetching - : evmQuery.status === 'loading' && evmQuery.fetchStatus === 'fetching', + queryStatus: isCosmosChain ? cosmosQuery.status : evmQuery.status, + isQueryFetching: isCosmosChain ? cosmosQuery.isFetching : evmQuery.fetchStatus === 'fetching', }; }; diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index feae33a..ba7f9dd 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -4,7 +4,6 @@ import { useDispatch } from 'react-redux'; import { AES, enc } from 'crypto-js'; import { LocalWallet, USDC_DENOM, type Subaccount } from '@dydxprotocol/v4-client'; -import { SubAccountHistoricalPNLs } from '@/constants/abacus'; import { OnboardingGuard, OnboardingState, type EvmDerivedAddresses } from '@/constants/account'; import { LocalStorageKey, LOCAL_STORAGE_VERSIONS } from '@/constants/localStorage'; import { DydxAddress, EthereumAddress, PrivateInformation } from '@/constants/wallets'; @@ -12,8 +11,6 @@ import { DydxAddress, EthereumAddress, PrivateInformation } from '@/constants/wa import { setOnboardingState, setOnboardingGuard, - setSubaccount, - setHistoricalPnl, } from '@/state/account'; import abacusStateManager from '@/lib/abacus'; @@ -63,6 +60,8 @@ const useAccountsContext = () => { forgetEvmSignature(previousEvmAddress); } + if (evmAddress) abacusStateManager.setTransfersSourceAddress(evmAddress); + setPreviousEvmAddress(evmAddress); }, [evmAddress]); @@ -224,7 +223,7 @@ const useAccountsContext = () => { // TODO: useAbacus({ dydxAddress }) useEffect(() => { if (dydxAddress) abacusStateManager.setAccount(dydxAddress); - else abacusStateManager.disconnectAccount(); + else abacusStateManager.attemptDisconnectAccount(); }, [dydxAddress]); // clear subaccounts when no dydxAddress is set diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..5b38cc4 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from 'react'; + +/** + * https://usehooks-ts.com/react-hook/use-debounce + * @param value + * @param delay + * @returns the debouncedValue + */ +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useInitializePage.ts b/src/hooks/useInitializePage.ts index f7f41dd..b9c66a6 100644 --- a/src/hooks/useInitializePage.ts +++ b/src/hooks/useInitializePage.ts @@ -6,7 +6,6 @@ import { LocalStorageKey } from '@/constants/localStorage'; import { DEFAULT_APP_ENVIRONMENT, DydxNetwork, - isDydxV4Network, isValidDydxNetwork, } from '@/constants/networks'; @@ -16,8 +15,6 @@ import { initializeLocalization } from '@/state/app'; import abacusStateManager from '@/lib/abacus'; -import squidRouter from '@/lib/squidRouter'; - export const useInitializePage = () => { const dispatch = useDispatch(); @@ -31,9 +28,5 @@ export const useInitializePage = () => { useEffect(() => { dispatch(initializeLocalization()); abacusStateManager.start({ network: localStorageNetwork }); - - if (isDydxV4Network(localStorageNetwork)) { - squidRouter.switchNetwork(localStorageNetwork); - } }, []); }; diff --git a/src/hooks/useSquidRouter.tsx b/src/hooks/useSquidRouter.tsx new file mode 100644 index 0000000..0266fc7 --- /dev/null +++ b/src/hooks/useSquidRouter.tsx @@ -0,0 +1,52 @@ +import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Squid } from '@0xsquid/sdk'; + +import { DydxV4Network, isDydxV4Network } from '@/constants/networks'; + +import { getSelectedNetwork } from '@/state/appSelectors'; + +const SQUID_BASE_URL: Record = { + [DydxV4Network.V4Testnet2]: 'https://squid-api-git-feat-cosmos-maintestnet-0xsquid.vercel.app', + [DydxV4Network.V4Staging]: undefined, + [DydxV4Network.V4Local]: undefined, + [DydxV4Network.V4Mainnet]: 'https://api.0xsquid.com', +}; + +const useSquidContext = () => { + const selectedNetwork = useSelector(getSelectedNetwork); + const [_, setInitialized] = useState(false); + + const initializeClient = async () => { + setInitialized(false); + if (!squid) return; + await squid.init(); + setInitialized(true); + }; + + const squid = useMemo( + () => + isDydxV4Network(selectedNetwork) + ? new Squid({ baseUrl: SQUID_BASE_URL[selectedNetwork as DydxV4Network] }) + : undefined, + [selectedNetwork] + ); + + useEffect(() => { + if (squid) { + initializeClient(); + } + }, [squid]); + + return squid; +}; + +type SquidContextType = ReturnType; +const SquidContext = createContext(undefined); +SquidContext.displayName = '0xSquid'; + +export const SquidProvider = ({ ...props }) => ( + +); + +export const useSquid = () => useContext(SquidContext); diff --git a/src/hooks/useSubaccount.tsx b/src/hooks/useSubaccount.tsx index 2ce92c5..a65082c 100644 --- a/src/hooks/useSubaccount.tsx +++ b/src/hooks/useSubaccount.tsx @@ -3,7 +3,7 @@ import { useDispatch } from 'react-redux'; import type { Nullable } from '@dydxprotocol/abacus'; import Long from 'long'; import type { IndexedTx } from '@cosmjs/stargate'; -import { type Coin } from '@cosmjs/proto-signing'; +import type { EncodeObject, Coin } from '@cosmjs/proto-signing'; import { Method } from '@cosmjs/tendermint-rpc'; import { @@ -89,6 +89,7 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo simulateTransferNativeToken, placeOrderForSubaccount, cancelOrderForSubaccount, + sendSquidWithdrawFromSubaccount, } = useMemo( () => ({ depositToSubaccount: async ({ @@ -301,6 +302,32 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo return result; }, + + sendSquidWithdrawFromSubaccount: async ({ + subaccountClient, + amount, + payload, + }: { + subaccountClient: SubaccountClient; + amount: number; + payload: string; + }) => { + if (!compositeClient) throw new Error('client not initialized'); + + const transaction = JSON.parse(payload); + + const msg = compositeClient.withdrawFromSubaccountMessage(subaccountClient, amount); + const ibcMsg: EncodeObject = { + typeUrl: transaction.msgTypeUrl, + value: transaction.msg, + }; + + return await compositeClient.send( + subaccountClient.wallet, + () => Promise.resolve([msg, ibcMsg]), + false + ); + }, }), [compositeClient] ); @@ -398,6 +425,28 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo [subaccountClient, simulateWithdrawFromSubaccount, simulateTransferNativeToken] ); + const simulateWithdraw = useCallback( + async (amount: number) => { + if (!subaccountClient) { + return; + } + + return await simulateWithdrawFromSubaccount({ subaccountClient, amount }); + }, + [subaccountClient, simulateWithdrawFromSubaccount] + ); + + const sendSquidWithdraw = useCallback( + async (amount: number, payload: string) => { + if (!subaccountClient) { + return; + } + + return await sendSquidWithdrawFromSubaccount({ subaccountClient, amount, payload }); + }, + [subaccountClient, sendSquidWithdrawFromSubaccount] + ); + // ------ Faucet Methods ------ // const requestFaucetFunds = useCallback(async () => { if (!dydxAddress) return; @@ -589,6 +638,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo // Transfer Methods simulateTransfer, transfer, + simulateWithdraw, + sendSquidWithdraw, // Trading Methods placeOrder, diff --git a/src/layout/DialogManager.tsx b/src/layout/DialogManager.tsx index 740e2a2..e647bc2 100644 --- a/src/layout/DialogManager.tsx +++ b/src/layout/DialogManager.tsx @@ -17,6 +17,7 @@ import { OnboardingDialog } from '@/views/dialogs/OnboardingDialog'; import { ReceiveDialog } from '@/views/dialogs/ReceiveDialog'; import { TradeDialog } from '@/views/dialogs/TradeDialog'; import { TransferDialog } from '@/views/dialogs/TransferDialog'; +import { WithdrawDialog } from '@/views/dialogs/WithdrawDialog'; import { OrderDetailsDialog } from '@/views/dialogs/DetailsDialog/OrderDetailsDialog'; import { FillDetailsDialog } from '@/views/dialogs/DetailsDialog/FillDetailsDialog'; @@ -53,5 +54,6 @@ export const DialogManager = () => { [DialogTypes.Receive]: , [DialogTypes.Trade]: , [DialogTypes.Transfer]: , + [DialogTypes.Withdraw]: , }[type]; }; diff --git a/src/lib/abacus/index.ts b/src/lib/abacus/index.ts index 1d43a86..d4ed16f 100644 --- a/src/lib/abacus/index.ts +++ b/src/lib/abacus/index.ts @@ -18,6 +18,7 @@ import { IOImplementations, UIImplementations, CoroutineTimer, + TransferType, } from '@/constants/abacus'; import { DEFAULT_MARKETID } from '@/constants/markets'; @@ -26,6 +27,7 @@ import { type DydxNetwork } from '@/constants/networks'; import type { RootStore } from '@/state/_store'; import { getInputTradeOptions } from '@/state/inputsSelectors'; +import { getTransferInputs } from '@/state/inputsSelectors'; import AbacusRest from './rest'; import AbacusWebsocket from './websocket'; @@ -90,6 +92,15 @@ class AbacusStateManager { disconnectAccount = () => { this.stateManager.accountAddress = null; }; + + attemptDisconnectAccount = () => { + const state = this.store?.getState(); + const { type: transferType }= (state && getTransferInputs(state)) || {}; + // we don't want to disconnect the account if we switch network during the deposit form + if (transferType?.rawValue !== TransferType.deposit.rawValue) { + this.disconnectAccount(); + } + } // ------ Input Values ------ // clearTradeInputValues = ({ shouldResetSize }: { shouldResetSize?: boolean } = {}) => { @@ -147,6 +158,10 @@ class AbacusStateManager { this.stateManager.accountAddress = walletAddress; }; + setTransfersSourceAddress = (evmAddress: string) => { + this.stateManager.sourceAddress = evmAddress; + }; + setSubaccountNumber = (subaccountNumber: number) => (this.stateManager.subaccountNumber = subaccountNumber); @@ -186,6 +201,18 @@ class AbacusStateManager { this.abacusFormatter.setLocaleSeparators({ group, decimal }); }; + setTransferStatus = ({ + hash, + fromChainId, + toChainId, + }: { + hash: string; + fromChainId?: string; + toChainId?: string; + }) => { + this.stateManager.transferStatus(hash, fromChainId, toChainId); + }; + // ------ Utils ------ // placeOrderPayload = (): Nullable => this.stateManager.placeOrderPayload(); diff --git a/src/lib/squidRouter.ts b/src/lib/squidRouter.ts deleted file mode 100644 index 67cd35f..0000000 --- a/src/lib/squidRouter.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { RouteResponse, Squid } from '@0xsquid/sdk'; -import type { GetRoute, ChainData, RouteData, TokenData } from '@0xsquid/sdk'; - -import { DEFAULT_APP_ENVIRONMENT, DydxV4Network } from '@/constants/networks'; - -import { log } from './telemetry'; - -/** - * @todo dYdX chain will eventually be added to squidSDK as an option for toChain and - * USDC from Noble chain will also be a possible toToken in the near future. We will - * need to eventually change these constants. - * */ -const SQUID_TESTNET_ROUTE_DEFAULTS = { - toChain: 'osmo-test-4', // Osmosis Testnet - toToken: 'uausdc', // aUSDC - enableForecall: true, // instant execution service, defaults to true - quoteOnly: false, // optional, defaults to false -}; - -// This config is for dev only! Used for testing General Message Passing (route provided by 0xSquid) -const SQUID_TESTNET_AVAX_TO_AXELAR_WITH_GMP = { - toChain: 'axelar-testnet-lisbon-3', - toToken: 'uosmo', - enableForecall: false, - quoteOnly: false, -}; - -const SQUID_MAINNET_ROUTE_DEFAULTS = { - toChain: 'osmosis-1', // Osmosis Testnet - toToken: 'uusdc', // aUSDC - enableForecall: true, // instant execution service, defaults to true - quoteOnly: false, // optional, defaults to false -}; - -class SquidFactory { - private client: Squid; - private isInitialized: boolean; - - constructor(baseUrl: string) { - this.client = new Squid({ baseUrl }); - this.isInitialized = false; - } - - async init() { - if (this.isInitialized) return; - - try { - await this.client.init(); - this.isInitialized = true; - } catch (error) { - log('SquidFactory/init', error); - } - } - - getClient = () => this.client; -} - -const squidClientTestnet = new SquidFactory('https://testnet.api.0xsquid.com'); // new SquidFactory('https://squid-2gwsw7ij1-0xsquid.vercel.app'); -const squidClientMainnet = new SquidFactory('https://api.0xsquid.com'); - -const SQUID_CLIENT_BY_NETWORK: Record = { - [DydxV4Network.V4Local]: squidClientTestnet, - [DydxV4Network.V4Mainnet]: squidClientMainnet, - [DydxV4Network.V4Staging]: squidClientTestnet, - [DydxV4Network.V4Testnet2]: squidClientTestnet, -}; - -class SquidRouter { - private client: Squid; - private network: DydxV4Network; - - constructor() { - this.network = DEFAULT_APP_ENVIRONMENT; - this.client = SQUID_CLIENT_BY_NETWORK[this.network].getClient(); - } - - /** - * @description the default destination chain/token and other hardcoded options - */ - get SQUID_ROUTE_DEFAULTS() { - return { - [DydxV4Network.V4Local]: SQUID_TESTNET_ROUTE_DEFAULTS, - [DydxV4Network.V4Staging]: SQUID_TESTNET_ROUTE_DEFAULTS, - [DydxV4Network.V4Testnet2]: SQUID_TESTNET_ROUTE_DEFAULTS, - [DydxV4Network.V4Mainnet]: SQUID_MAINNET_ROUTE_DEFAULTS, - }[this.network]; - } - - /** - * @description updates the client to the correct 0xSquid environment based on which dYdX v4 network is selected. - * @param network dYdX v4 network that is currently selected - */ - switchNetwork = (network: DydxV4Network) => { - this.client = SQUID_CLIENT_BY_NETWORK[network].getClient(); - }; - - /** - * @description initializes the Squid client if it was not previously initialized (API request) - */ - initializeClient = () => SQUID_CLIENT_BY_NETWORK[this.network].init(); - - /** - * @returns list of chains supported by Axelar - */ - getChains = (): ChainData[] => this.client.chains; - - /** - * @returns list of tokens supported by Axelar - */ - getTokens = (): TokenData[] => this.client.tokens; - - /** - * @param getRouteParams - * @returns 0xSquid RouteData - */ - getRoute = async (getRouteParams: GetRoute): Promise => { - const resp = await this.client.getRoute(getRouteParams); - return resp; - }; - - /** - * @params { - * @route the 0xSquid RouteData - * @signer the EVM/Cosmos Signer used to sign the Axelar bridge - * } - * @returns transaction information after the route has been executed - */ - executeRoute = async ({ route, signer }: { route: RouteData; signer: any }) => { - const tx = await this.client.executeRoute({ - signer, - route, - }); - - return await tx.wait(); - }; -} - -const squidRouter = new SquidRouter(); - -export default squidRouter; diff --git a/src/lib/wagmi.ts b/src/lib/wagmi.ts index 0c2c35d..1340cd0 100644 --- a/src/lib/wagmi.ts +++ b/src/lib/wagmi.ts @@ -162,7 +162,13 @@ const createWalletConnect2ConnectorWithId = (walletconnect2Id: string) => }); // Custom connector from wallet selection -import { type WalletConnection, WalletConnectionType, type WalletType, walletConnectionTypes, wallets } from '@/constants/wallets'; +import { + type WalletConnection, + WalletConnectionType, + type WalletType, + walletConnectionTypes, + wallets, +} from '@/constants/wallets'; export const resolveWagmiConnector = ({ walletType, @@ -174,15 +180,12 @@ export const resolveWagmiConnector = ({ const walletConfig = wallets[walletType]; const walletConnectionConfig = walletConnectionTypes[walletConnection.type]; - return ( - walletConnection.type === WalletConnectionType.InjectedEip1193 && walletConnection.provider ? - createInjectedConnectorWithProvider(walletConnection.provider) - : walletConnection.type === WalletConnectionType.WalletConnect1 && walletConfig.walletconnect1Name ? - createWalletConnect1ConnectorWithName(walletConfig.walletconnect1Name) - : walletConnection.type === WalletConnectionType.WalletConnect2 && walletConfig.walletconnect2Id ? - createWalletConnect2ConnectorWithId(walletConfig.walletconnect2Id) - : connectors.find( - ({ id }: { id: string }) => id === walletConnectionConfig.wagmiConnectorId - ) - ); + return walletConnection.type === WalletConnectionType.InjectedEip1193 && walletConnection.provider + ? createInjectedConnectorWithProvider(walletConnection.provider) + : walletConnection.type === WalletConnectionType.WalletConnect1 && + walletConfig.walletconnect1Name + ? createWalletConnect1ConnectorWithName(walletConfig.walletconnect1Name) + : walletConnection.type === WalletConnectionType.WalletConnect2 && walletConfig.walletconnect2Id + ? createWalletConnect2ConnectorWithId(walletConfig.walletconnect2Id) + : connectors.find(({ id }: { id: string }) => id === walletConnectionConfig.wagmiConnectorId); }; diff --git a/src/localization/en/app.json b/src/localization/en/app.json index a492d6b..95c49cd 100644 --- a/src/localization/en/app.json +++ b/src/localization/en/app.json @@ -479,6 +479,7 @@ "LEAVING_WEBSITE": "Leaving website" }, "DEPOSIT_MODAL": { + "BRIDGE_FEE": "Bridge Fee", "ENABLE_FOR_THIS_DEPOSIT": "Enable for this deposit", "ENABLE_SYMBOL_ON_DYDX": "Enable {SYMBOL} on dYdX", "ENABLE_SYMBOL": "Enable {SYMBOL}", @@ -944,7 +945,8 @@ "SLOW_WITHDRAW_PENDING_DESCRIPTION": "You have {AMOUNT_ELEMENT} in pending slow withdraws. You will receive a notification when your funds have been confirmed on Layer 2.", "SLOW_WITHDRAW_PENDING": "Slow withdraw(s) pending", "SUSPICIOUS_TRADE": "Suspicious activity", - "SUSPICIOUS_TRADE_BODY": "We have noticed suspicious trading activity related to your account on dYdX, including potential wash trading activity. We will be reviewing your activity on an ongoing basis. If we notice similar activity in the future, then you will be permanently blocked from performing transfers within the protocol and from placing orders other than market orders that reduce your positions." + "SUSPICIOUS_TRADE_BODY": "We have noticed suspicious trading activity related to your account on dYdX, including potential wash trading activity. We will be reviewing your activity on an ongoing basis. If we notice similar activity in the future, then you will be permanently blocked from performing transfers within the protocol and from placing orders other than market orders that reduce your positions.", + "WITHDRAW_IN_PROGRESS": "Withdrawal(s) in progress" }, "EMAIL_NOTIFICATIONS": { "ACCOUNT_DESCRIPTION": "Deposits, Withdrawals, Account Updates", diff --git a/src/localization/en/errors.json b/src/localization/en/errors.json index 74af281..6953eb0 100644 --- a/src/localization/en/errors.json +++ b/src/localization/en/errors.json @@ -65,7 +65,10 @@ "MIN_SLOW_WITHDRAW_AMOUNT": "Slow withdraw amount must be greater than or equal to 10, or your entire account balance.", "RESTRICTED_OPEN_POSITIONS_WITHDRAW": "Your account is restricted. You must close all your positions before you can withdraw.", "WITHDRAW_MORE_THAN_FREE_DUE_TO_FEE": "You cannot withdraw more than your free collateral. Make sure you account for the fast withdraw fee when entering an amount.", - "WITHDRAW_MORE_THAN_FREE": "You cannot withdraw more than your free collateral." + "WITHDRAW_MORE_THAN_FREE": "You cannot withdraw more than your free collateral.", + "WITHDRAW_MUST_SPECIFY_ADDRESS": "Please enter a destination address.", + "WITHDRAW_MUST_SPECIFY_ASSET": "Please select an asset to withdraw to.", + "WITHDRAW_MUST_SPECIFY_CHAIN": "Please select a source chain to withdraw to." }, "TRANSFER_MODAL": { "TRANSFER_INVALID_ETH_ADDRESS": "We could not find an account associated with this wallet address.", diff --git a/src/state/appMiddleware.ts b/src/state/appMiddleware.ts index 23b5258..c48c494 100644 --- a/src/state/appMiddleware.ts +++ b/src/state/appMiddleware.ts @@ -2,13 +2,11 @@ import type { PayloadAction } from '@reduxjs/toolkit'; import { AbacusApiStatus } from '@/constants/abacus'; import { DialogTypes } from '@/constants/dialogs'; -import { isDydxV4Network } from '@/constants/networks'; import { setApiState, setSelectedNetwork } from '@/state/app'; import { resetPerpetualsState } from '@/state/perpetuals'; import abacusStateManager from '@/lib/abacus'; -import squidRouter from '@/lib/squidRouter'; import { openDialog } from './dialogs'; import { getActiveDialog } from './dialogsSelectors'; @@ -22,10 +20,6 @@ export default (store: any) => (next: any) => async (action: PayloadAction) case setSelectedNetwork.type: { store.dispatch(resetPerpetualsState()); abacusStateManager.switchNetwork(payload); - - if (isDydxV4Network(payload)) { - squidRouter.switchNetwork(payload); - } break; } case setApiState.type: { diff --git a/src/styles/popoverMixins.ts b/src/styles/popoverMixins.ts index 47932cf..17be685 100644 --- a/src/styles/popoverMixins.ts +++ b/src/styles/popoverMixins.ts @@ -61,7 +61,7 @@ export const popoverMixins = { ${layoutMixins.spacedRow} grid-template-columns: 1fr auto; - padding-right: 1rem; + gap: 0; border: 1px solid var(--color-layer-6); `, diff --git a/src/views/AccountInfo/AccountInfoConnectedState.tsx b/src/views/AccountInfo/AccountInfoConnectedState.tsx index d04be9a..0cc7df9 100644 --- a/src/views/AccountInfo/AccountInfoConnectedState.tsx +++ b/src/views/AccountInfo/AccountInfoConnectedState.tsx @@ -72,16 +72,26 @@ export const AccountInfoConnectedState = () => { {!showHeader ? null : ( {stringGetter({ key: STRING_KEYS.ACCOUNT })} - - dispatch(openDialog({ type: DialogTypes.Deposit }))} - shape={ButtonShape.Pill} - size={ButtonSize.XSmall} - slotLeft={} - > - {stringGetter({ key: STRING_KEYS.MANAGE_FUNDS })} - + + {import.meta.env.MODE !== 'production' && ( + dispatch(openDialog({ type: DialogTypes.Withdraw }))} + shape={ButtonShape.Pill} + size={ButtonSize.XSmall} + > + {stringGetter({ key: STRING_KEYS.WITHDRAW })} + + )} + dispatch(openDialog({ type: DialogTypes.Deposit }))} + shape={ButtonShape.Pill} + size={ButtonSize.XSmall} + > + {stringGetter({ key: STRING_KEYS.DEPOSIT })} + + )} @@ -273,6 +283,11 @@ Styled.Header = styled.header` padding: 0 1.25rem; `; +Styled.TransferButtons = styled.div` + ${layoutMixins.inlineRow} + gap: 1rem; +`; + Styled.ConnectedAccountInfoContainer = styled.div<{ $showHeader?: boolean }>` ${layoutMixins.column} ${layoutMixins.withOuterAndInnerBorders} diff --git a/src/views/dialogs/DepositDialog.tsx b/src/views/dialogs/DepositDialog.tsx index 43cee62..9baa069 100644 --- a/src/views/dialogs/DepositDialog.tsx +++ b/src/views/dialogs/DepositDialog.tsx @@ -6,8 +6,8 @@ import { useBreakpoints, useStringGetter } from '@/hooks'; import { Dialog, DialogPlacement } from '@/components/Dialog'; -import { DepositForm } from '@/views/forms/DepositForm'; -import { TestnetDepositForm } from '@/views/forms/TestnetDepositForm'; +import { DepositForm } from '@/views/forms/AccountManagementForms/DepositForm'; +import { TestnetDepositForm } from '@/views/forms/AccountManagementForms/TestnetDepositForm'; import { layoutMixins } from '@/styles/layoutMixins'; @@ -32,12 +32,12 @@ export const DepositDialog = ({ setIsOpen }: ElementProps) => { > {showTestDeposit ? : } - {/* TODO TRCL-1693 - uncomment when deposit form is ready + {/* TODO TRCL-1693 - uncomment when deposit form is ready */} {import.meta.env.MODE !== 'production' && ( setShowTestDeposit(!showTestDeposit)}> {showTestDeposit ? 'Show deposit form (Under Construction)' : 'Show test faucet'} - )} */} + )} ); diff --git a/src/views/dialogs/OnboardingDialog.tsx b/src/views/dialogs/OnboardingDialog.tsx index 66079c9..11593f3 100644 --- a/src/views/dialogs/OnboardingDialog.tsx +++ b/src/views/dialogs/OnboardingDialog.tsx @@ -19,7 +19,7 @@ import { GreenCheckCircle } from '@/components/GreenCheckCircle'; import { Icon } from '@/components/Icon'; import { Ring } from '@/components/Ring'; -import { TestnetDepositForm } from '@/views/forms/TestnetDepositForm'; +import { TestnetDepositForm } from '@/views/forms/AccountManagementForms/TestnetDepositForm'; import { track } from '@/lib/analytics'; diff --git a/src/views/dialogs/WithdrawDialog.tsx b/src/views/dialogs/WithdrawDialog.tsx new file mode 100644 index 0000000..13ab110 --- /dev/null +++ b/src/views/dialogs/WithdrawDialog.tsx @@ -0,0 +1,56 @@ +import styled, { type AnyStyledComponent } from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; +import { useBreakpoints, useStringGetter } from '@/hooks'; + +import { Dialog, DialogPlacement } from '@/components/Dialog'; + +import { WithdrawForm } from '@/views/forms/AccountManagementForms/WithdrawForm'; + +import { layoutMixins } from '@/styles/layoutMixins'; + +type ElementProps = { + setIsOpen: (open: boolean) => void; +}; + +export const WithdrawDialog = ({ setIsOpen }: ElementProps) => { + const stringGetter = useStringGetter(); + const { isTablet } = useBreakpoints(); + + return ( + + + + + + ); +}; + +const Styled: Record = {}; + +Styled.TextToggle = styled.div` + ${layoutMixins.stickyFooter} + color: var(--color-accent); + cursor: pointer; + + margin-top: auto; + + &:hover { + text-decoration: underline; + } +`; + +Styled.Content = styled.div` + ${layoutMixins.stickyArea0} + --stickyArea0-bottomHeight: 2rem; + --stickyArea0-bottomGap: 1rem; + --stickyArea0-totalInsetBottom: 0.5rem; + + ${layoutMixins.flexColumn} + gap: 1rem; +`; diff --git a/src/views/forms/DepositForm/ChainSelectMenu.tsx b/src/views/forms/AccountManagementForms/ChainSelectMenu.tsx similarity index 51% rename from src/views/forms/DepositForm/ChainSelectMenu.tsx rename to src/views/forms/AccountManagementForms/ChainSelectMenu.tsx index 1d84f4c..9badaea 100644 --- a/src/views/forms/DepositForm/ChainSelectMenu.tsx +++ b/src/views/forms/AccountManagementForms/ChainSelectMenu.tsx @@ -1,31 +1,45 @@ -import type { ChainData } from '@0xsquid/sdk'; import styled, { type AnyStyledComponent } from 'styled-components'; +import { shallowEqual, useSelector } from 'react-redux'; +import { TransferType } from '@/constants/abacus'; import { STRING_KEYS } from '@/constants/localization'; import { useStringGetter } from '@/hooks'; -import { WithLabel } from '@/components/WithLabel'; import { SearchSelectMenu } from '@/components/SearchSelectMenu'; import { layoutMixins } from '@/styles/layoutMixins'; import { popoverMixins } from '@/styles/popoverMixins'; +import { getTransferInputs } from '@/state/inputsSelectors'; + type ElementProps = { - chains?: ChainData[]; - currentChain?: ChainData; - setCurrentChain: (chain: ChainData) => void; + label?: string; + selectedChain?: string; + onSelectChain: (chain: string) => void; }; -export const ChainSelectMenu = ({ chains = [], currentChain, setCurrentChain }: ElementProps) => { +export const ChainSelectMenu = ({ + label, + selectedChain, + onSelectChain, +}: ElementProps) => { const stringGetter = useStringGetter(); + const { type, depositOptions, withdrawalOptions, resources } = + useSelector(getTransferInputs, shallowEqual) || {}; + const chains = + (type === TransferType.deposit ? depositOptions : withdrawalOptions)?.chains?.toArray() || []; - const chainItems = Object.values(chains).map((chain: ChainData) => ({ - value: chain.chainId.toString(), - label: chain.chainName, - onSelect: () => setCurrentChain(chain), - slotBefore: , + const chainItems = Object.values(chains).map((chain) => ({ + value: chain.type, + label: chain.stringKey, + onSelect: () => { + onSelectChain(chain.type); + }, + slotBefore: , })); + const selectedOption = chains.find((item) => item.type === selectedChain); + return ( - {currentChain ? ( + {selectedChain ? ( <> - {currentChain?.chainName} + {selectedOption?.stringKey} ) : ( stringGetter({ key: STRING_KEYS.SELECT_CHAIN }) @@ -67,4 +81,4 @@ Styled.ChainRow = styled.div` gap: 0.5rem; color: var(--color-text-2); font: var(--font-base-book); -`; +`; \ No newline at end of file diff --git a/src/views/forms/AccountManagementForms/DepositForm.tsx b/src/views/forms/AccountManagementForms/DepositForm.tsx new file mode 100644 index 0000000..5366823 --- /dev/null +++ b/src/views/forms/AccountManagementForms/DepositForm.tsx @@ -0,0 +1,342 @@ +import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import styled, { type AnyStyledComponent } from 'styled-components'; +import { type NumberFormatValues } from 'react-number-format'; +import { shallowEqual, useSelector } from 'react-redux'; +import { TESTNET_CHAIN_ID } from '@dydxprotocol/v4-client'; +import { ethers } from 'ethers'; + +import { + TransferInputField, + TransferInputTokenResource, + TransferType, +} from '@/constants/abacus'; +import { AlertType } from '@/constants/alerts'; +import { ButtonSize } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { NumberSign } from '@/constants/numbers'; + +import { useAccounts, useDebounce, useStringGetter } from '@/hooks'; +import { useAccountBalance } from '@/hooks/useAccountBalance'; + +import { layoutMixins } from '@/styles/layoutMixins'; +import { formMixins } from '@/styles/formMixins'; + +import { AlertMessage } from '@/components/AlertMessage'; +import { Button } from '@/components/Button'; +import { DiffOutput } from '@/components/DiffOutput'; +import { FormInput } from '@/components/FormInput'; +import { InputType } from '@/components/Input'; +import { Link } from '@/components/Link'; +import { LoadingSpace } from '@/components/Loading/LoadingSpinner'; +import { OutputType } from '@/components/Output'; +import { Tag } from '@/components/Tag'; +import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; + +import { getTransferInputs } from '@/state/inputsSelectors'; + +import abacusStateManager from '@/lib/abacus'; +import { MustBigNumber } from '@/lib/numbers'; + +import { ChainSelectMenu } from './ChainSelectMenu'; +import { TokenSelectMenu } from './TokenSelectMenu'; + +import { DepositButtonAndReceipt } from './DepositForm/DepositButtonAndReceipt'; + +type DepositFormProps = { + onDeposit?: () => void; + onError?: () => void; +}; + +export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => { + const stringGetter = useStringGetter(); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const { signerWagmi } = useAccounts(); + + const { requestPayload, token, chain: chainIdStr, resources } = useSelector(getTransferInputs, shallowEqual) || {}; + const chainId = chainIdStr ? parseInt(chainIdStr) : undefined + + // User inputs + const sourceToken = useMemo( + () => (token ? resources?.tokenResources?.get(token) : undefined), + [token, resources] + ); + + const [fromAmount, setFromAmount] = useState(''); + const [slippage, setSlippage] = useState(0.01); // 1% slippage + const debouncedAmount = useDebounce(fromAmount, 500); + + // Async Data + const [transactionHash, setTransactionHash] = useState(); + + const { balance, queryStatus, isQueryFetching } = useAccountBalance({ + addressOrDenom: sourceToken?.address || undefined, + assetSymbol: sourceToken?.symbol || undefined, + chainId: chainId, + decimals: sourceToken?.decimals || undefined, + isCosmosChain: false, + }); + + // BN + const debouncedAmountBN = MustBigNumber(debouncedAmount); + const balanceBN = MustBigNumber(balance); + + useEffect(() => { + const hasInvalidInput = + debouncedAmountBN.isNaN() || debouncedAmountBN.lte(0) || debouncedAmountBN.gte(balanceBN); + + abacusStateManager.setTransferValue({ + value: hasInvalidInput ? 0 : debouncedAmount, + field: TransferInputField.size, + }); + + setError(null); + }, [debouncedAmountBN.toNumber()]); + + useEffect(() => { + abacusStateManager.setTransferValue({ + field: TransferInputField.type, + value: TransferType.deposit.rawValue, + }); + + return () => { + abacusStateManager.clearTransferInputValues(); + abacusStateManager.setTransferValue({ + field: TransferInputField.type, + value: null, + }); + }; + }, []); + + useEffect(() => { + if (error) onError?.(); + }, [error]); + + const onSelectChain = useCallback((chain: string) => { + if (chain) { + abacusStateManager.clearTransferInputValues(); + abacusStateManager.setTransferValue({ + field: TransferInputField.chain, + value: chain, + }); + setFromAmount(''); + } + }, []); + + const onSelectToken = useCallback((token: TransferInputTokenResource) => { + if (token) { + abacusStateManager.clearTransferInputValues(); + abacusStateManager.setTransferValue({ + field: TransferInputField.token, + value: token.address, + }); + setFromAmount(''); + } + }, []); + + const onChangeAmount = useCallback( + ({ value }: NumberFormatValues) => { + setFromAmount(value); + }, + [setFromAmount] + ); + + const onSetSlippage = useCallback( + (newSlippage: number) => { + setSlippage(newSlippage); + // TODO: to be implemented via abacus + }, + [setSlippage, debouncedAmount] + ); + + const onClickMax = useCallback(() => { + if (balance) { + setFromAmount(balanceBN.toString()); + } + }, [balance, setFromAmount]); + + const onSubmit = useCallback( + async (e: FormEvent) => { + try { + e.preventDefault(); + + if (!signerWagmi) { + throw new Error('Missing signer'); + } + if ( + !requestPayload?.targetAddress || + !requestPayload.data || + !requestPayload.value || + !requestPayload.gasLimit || + !requestPayload.gasPrice || + !requestPayload.routeType + ) { + throw new Error('Missing request payload'); + } + + setIsLoading(true); + + let tx = { + to: requestPayload.targetAddress as `0x${string}`, + data: requestPayload.data as `0x${string}`, + gasLimit: ethers.toBigInt(requestPayload.gasLimit), + value: + requestPayload.routeType !== 'SEND' ? ethers.toBigInt(requestPayload.value) : undefined, + }; + const txHash = await signerWagmi.sendTransaction(tx); + onDeposit?.(); + + if (txHash) { + setTransactionHash(txHash); + abacusStateManager.setTransferStatus({ + hash: txHash, + toChainId: TESTNET_CHAIN_ID, + fromChainId: chainId?.toString(), + }); + abacusStateManager.clearTransferInputValues(); + setFromAmount(''); + } + + } catch (error) { + setError(error); + } finally { + setIsLoading(false); + } + }, + [requestPayload, signerWagmi, chainId] + ); + + const amountInputReceipt = [ + { + key: 'amount', + label: ( + + {stringGetter({ key: STRING_KEYS.AVAILABLE })}{' '} + {sourceToken && {sourceToken.symbol}} + + ), + value: ( + + ), + }, + ]; + + const errorMessage = useMemo(() => { + if (error) { + return error?.message + ? stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { ERROR_MESSAGE: error.message }, + }) + : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }); + } + + if (fromAmount) { + if (!chainId) { + return stringGetter({ key: STRING_KEYS.MUST_SPECIFY_CHAIN }); + } else if (!sourceToken) { + return stringGetter({ key: STRING_KEYS.MUST_SPECIFY_ASSET }); + } + } + + if (MustBigNumber(fromAmount).gt(MustBigNumber(balance))) { + return stringGetter({ key: STRING_KEYS.DEPOSIT_MORE_THAN_BALANCE }); + } + + return undefined; + }, [error, balance, chainId, fromAmount, sourceToken]); + + const isDisabled = + Boolean(errorMessage) || + !sourceToken || + !chainId || + debouncedAmountBN.isNaN() || + debouncedAmountBN.isZero(); + + if (!resources) { + return ; + } + + return ( + + + + + + {stringGetter({ key: STRING_KEYS.MAX })} + + } + /> + + {errorMessage ? ( + {errorMessage} + ) : ( + transactionHash && ( + + + {stringGetter({ key: STRING_KEYS.DEPOSIT_IN_PROGRESS })} + + + ) + )} + + + ); +}; + +const Styled: Record = {}; + +Styled.Form = styled.form` + --form-input-height: 3.5rem; + + ${layoutMixins.flexColumn} + gap: 1.5rem; + + ${layoutMixins.stickyArea1} + min-height: calc(100% - var(--stickyArea0-bottomHeight)); +`; + +Styled.WithDetailsReceipt = styled(WithDetailsReceipt)` + --withReceipt-backgroundColor: var(--color-layer-2); +`; + +Styled.Link = styled(Link)` + color: var(--color-accent); + + &:visited { + color: var(--color-accent); + } +`; + +Styled.TransactionInfo = styled.span` + ${layoutMixins.row} +`; + +Styled.FormInputButton = styled(Button)` + ${formMixins.inputInnerButton} +`; diff --git a/src/views/forms/DepositForm/DepositButtonAndReceipt.tsx b/src/views/forms/AccountManagementForms/DepositForm/DepositButtonAndReceipt.tsx similarity index 73% rename from src/views/forms/DepositForm/DepositButtonAndReceipt.tsx rename to src/views/forms/AccountManagementForms/DepositForm/DepositButtonAndReceipt.tsx index f7595a1..f63e649 100644 --- a/src/views/forms/DepositForm/DepositButtonAndReceipt.tsx +++ b/src/views/forms/AccountManagementForms/DepositForm/DepositButtonAndReceipt.tsx @@ -1,8 +1,7 @@ -import { type Dispatch, type SetStateAction, useState } from 'react'; -import styled, { type AnyStyledComponent, css } from 'styled-components'; +import { type Dispatch, type SetStateAction, useState, type ReactNode } from 'react'; +import styled, { type AnyStyledComponent, } from 'styled-components'; import { shallowEqual, useSelector } from 'react-redux'; -import type { RouteData, TokenData } from '@0xsquid/sdk'; -import BigNumber from 'bignumber.js'; +import type { RouteData } from '@0xsquid/sdk'; import { ButtonAction, @@ -12,6 +11,7 @@ import { ButtonType, } from '@/constants/buttons'; +import { TransferInputTokenResource } from '@/constants/abacus'; import { STRING_KEYS } from '@/constants/localization'; import { NumberSign } from '@/constants/numbers'; @@ -30,11 +30,9 @@ import { ToggleButton } from '@/components/ToggleButton'; import { WithReceipt } from '@/components/WithReceipt'; import { getSubaccountBuyingPower, getSubaccountEquity } from '@/state/accountSelectors'; +import { getTransferInputs } from '@/state/inputsSelectors'; -import { BIG_NUMBERS, MustBigNumber } from '@/lib/numbers'; -import squidRouter from '@/lib/squidRouter'; - -import { SlippageEditor } from './SlippageEditor'; +import { SlippageEditor } from '../SlippageEditor'; type ElementProps = { isDisabled?: boolean; @@ -43,8 +41,9 @@ type ElementProps = { chainId?: string | number; setError?: Dispatch>; slippage: number; + slotError?: ReactNode; setSlippage: (slippage: number) => void; - sourceToken?: TokenData; + sourceToken?: TransferInputTokenResource; squidRoute?: RouteData; }; @@ -54,10 +53,10 @@ export const DepositButtonAndReceipt = ({ slippage, setSlippage, sourceToken, - squidRoute, isDisabled, isLoading, + slotError, }: ElementProps) => { const [showFeeBreakdown, setShowFeeBreakdown] = useState(false); const [isEditingSlippage, setIsEditingSlipapge] = useState(false); @@ -78,35 +77,23 @@ export const DepositButtonAndReceipt = ({ const { current: buyingPower, postOrder: newBuyingPower } = useSelector(getSubaccountBuyingPower, shallowEqual) || {}; - const { gasCosts, feeCosts } = squidRoute?.estimate || {}; + const { summary, requestPayload } = useSelector(getTransferInputs, shallowEqual) || {}; - const totalGasCosts = - gasCosts?.reduce( - (acc: BigNumber, { amountUSD }: { amountUSD: string }) => acc.plus(MustBigNumber(amountUSD)), - BIG_NUMBERS.ZERO - ) || BIG_NUMBERS.ZERO; + const feeSubitems: DetailsItem[] = []; - const totalFeeCosts = - feeCosts?.reduce( - (acc: BigNumber, { amountUSD }: { amountUSD: string }) => acc.plus(MustBigNumber(amountUSD)), - BIG_NUMBERS.ZERO - ) || BIG_NUMBERS.ZERO; - - const totalFees = totalGasCosts.plus(totalFeeCosts); - - const feeSubitems: DetailsItem[] = feeCosts - ? feeCosts.map(({ amountUSD, name }: { amountUSD: string; name: string }) => ({ - key: name, - label: {name}, - value: , - })) - : []; - - if (gasCosts) { + if (typeof summary?.gasFee === 'number') { feeSubitems.push({ key: 'gas-fees', label: {stringGetter({ key: STRING_KEYS.GAS_FEE })}, - value: , + value: , + }); + } + + if (typeof summary?.bridgeFee === 'number') { + feeSubitems.push({ + key: 'bridge-fees', + label: Bridge Fee, + value: , }); } @@ -128,9 +115,9 @@ export const DepositButtonAndReceipt = ({ ), }, @@ -154,27 +141,20 @@ export const DepositButtonAndReceipt = ({ { key: 'exchange-rate', label: {stringGetter({ key: STRING_KEYS.EXCHANGE_RATE })}, - value: sourceToken && squidRoute && ( + value: typeof summary?.exchangeRate === 'number' && ( - {sourceToken?.symbol}} - /> + = - {squidRouter.SQUID_ROUTE_DEFAULTS.toToken.toUpperCase()}} - /> + ), }, { key: 'total-fees', label: {stringGetter({ key: STRING_KEYS.TOTAL_FEES })}, - value: squidRoute && , + value: typeof summary?.bridgeFee === 'number' && typeof summary?.gasFee === 'number' && ( + + ), subitems: feeSubitems, }, { @@ -212,17 +192,25 @@ export const DepositButtonAndReceipt = ({ } + slotError={slotError} > {!isMatchingNetwork ? ( ) : ( - )} @@ -234,7 +222,7 @@ const Styled: Record = {}; Styled.ExchangeRate = styled.span` ${layoutMixins.row} - gap: 1ch; + gap: 0.5ch; `; Styled.WithReceipt = styled(WithReceipt)` diff --git a/src/views/forms/DepositForm/SlippageEditor.tsx b/src/views/forms/AccountManagementForms/SlippageEditor.tsx similarity index 96% rename from src/views/forms/DepositForm/SlippageEditor.tsx rename to src/views/forms/AccountManagementForms/SlippageEditor.tsx index f830e83..85263c9 100644 --- a/src/views/forms/DepositForm/SlippageEditor.tsx +++ b/src/views/forms/AccountManagementForms/SlippageEditor.tsx @@ -12,6 +12,7 @@ import { Icon, IconName } from '@/components/Icon'; import { Input, InputType } from '@/components/Input'; import { Output, OutputType } from '@/components/Output'; import { ToggleGroup } from '@/components/ToggleGroup'; +import { FormInput } from '@/components/FormInput'; enum EditorState { Viewing = 'Viewing', @@ -115,7 +116,7 @@ export const SlippageEditor = ({ slippage, setIsEditing, setSlippage }: Slippage ), [EditorState.Editing]: ( - void; +}; + +export const TokenSelectMenu = ({ selectedToken, onSelectToken }: ElementProps) => { + const stringGetter = useStringGetter(); + const { type, depositOptions, withdrawalOptions, resources } = + useSelector(getTransferInputs, shallowEqual) || {}; + const tokens = + (type === TransferType.deposit ? depositOptions : withdrawalOptions)?.assets?.toArray() || []; + + const tokenItems = Object.values(tokens).map((token) => ({ + value: token.type, + label: token.stringKey, + onSelect: () => { + const selectedToken = resources?.tokenResources?.get(token.type); + selectedToken && onSelectToken(selectedToken); + }, + slotBefore: , + })); + + return ( + + {type === TransferType.deposit ? selectedToken?.symbol : 'USDC'} + + {type === TransferType.deposit ? 'USDC' : selectedToken?.symbol} + + ), + tooltip: 'swap', + }, + ]} + > + + {selectedToken ? ( + <> + {selectedToken?.name}{' '} + {selectedToken?.symbol} + + ) : ( + stringGetter({ key: STRING_KEYS.SELECT_ASSET }) + )} + + + ); +}; + +const Styled: Record = {}; + +Styled.AssetRow = styled.div` + ${layoutMixins.row} + gap: 0.5rem; + color: var(--color-text-2); + font: var(--font-base-book); +`; + +Styled.Img = styled.img` + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; +`; + +Styled.Icon = styled(Icon)` + height: 0.5rem; +`; diff --git a/src/views/forms/AccountManagementForms/WithdrawForm.tsx b/src/views/forms/AccountManagementForms/WithdrawForm.tsx new file mode 100644 index 0000000..3c2f1f4 --- /dev/null +++ b/src/views/forms/AccountManagementForms/WithdrawForm.tsx @@ -0,0 +1,358 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ChangeEvent, FormEvent } from 'react'; +import styled, { type AnyStyledComponent } from 'styled-components'; +import type { NumberFormatValues } from 'react-number-format'; +import { shallowEqual, useSelector } from 'react-redux'; +import { TESTNET_CHAIN_ID } from '@dydxprotocol/v4-client'; + +import { TransferInputField, TransferInputTokenResource, TransferType } from '@/constants/abacus'; +import { AlertType } from '@/constants/alerts'; +import { ButtonSize } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { NumberSign, QUANTUM_MULTIPLIER } from '@/constants/numbers'; + +import { useDebounce, useStringGetter, useSubaccount } from '@/hooks'; + +import { layoutMixins } from '@/styles/layoutMixins'; +import { formMixins } from '@/styles/formMixins'; + +import { AlertMessage } from '@/components/AlertMessage'; +import { Button } from '@/components/Button'; +import { DiffOutput } from '@/components/DiffOutput'; +import { FormInput } from '@/components/FormInput'; +import { InputType } from '@/components/Input'; +import { Link } from '@/components/Link'; +import { OutputType } from '@/components/Output'; +import { Tag } from '@/components/Tag'; +import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; + +import { ChainSelectMenu } from '@/views/forms/AccountManagementForms/ChainSelectMenu'; + +import { getSubaccount } from '@/state/accountSelectors'; +import { getTransferInputs } from '@/state/inputsSelectors'; + +import abacusStateManager from '@/lib/abacus'; +import { MustBigNumber } from '@/lib/numbers'; + +import { TokenSelectMenu } from './TokenSelectMenu'; +import { WithdrawButtonAndReceipt } from './WithdrawForm/WithdrawButtonAndReceipt'; + +export const WithdrawForm = () => { + const stringGetter = useStringGetter(); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const { simulateWithdraw, sendSquidWithdraw } = useSubaccount(); + const { freeCollateral } = useSelector(getSubaccount, shallowEqual) || {}; + + // User input + const [withdrawAmount, setWithdrawAmount] = useState(''); + const [slippage, setSlippage] = useState(0.01); // 0.1% slippage + const debouncedAmount = useDebounce(withdrawAmount, 500); + + const { + requestPayload, + token, + chain: chainIdStr, + address: toAddress, + resources, + } = useSelector(getTransferInputs, shallowEqual) || {}; + + const toToken = useMemo( + () => (token ? resources?.tokenResources?.get(token) : undefined), + [token, resources] + ); + + // Async Data + const [transactionHash, setTransactionHash] = useState(); + + const debouncedAmountBN = MustBigNumber(debouncedAmount); + const withdrawAmountBN = MustBigNumber(withdrawAmount); + const freeCollateralBN = MustBigNumber(freeCollateral?.current); + + useEffect(() => { + abacusStateManager.setTransferValue({ + field: TransferInputField.type, + value: TransferType.withdrawal.rawValue, + }); + + return () => { + abacusStateManager.clearTransferInputValues(); + abacusStateManager.setTransferValue({ + field: TransferInputField.type, + value: null, + }); + }; + }, []); + + useEffect(() => { + const setTransferValue = async () => { + try { + setIsLoading(true); + const hasInvalidInput = debouncedAmountBN.isNaN() || debouncedAmountBN.lte(0); + if (hasInvalidInput) { + abacusStateManager.setTransferValue({ + value: 0, + field: TransferInputField.usdcSize, + }); + } else { + const stdFee = await simulateWithdraw(parseFloat(debouncedAmount)); + const amount = + parseFloat(debouncedAmount) - + parseFloat(stdFee?.amount[0]?.amount || '0') / QUANTUM_MULTIPLIER; + + abacusStateManager.setTransferValue({ + value: amount.toString(), + field: TransferInputField.usdcSize, + }); + + setError(null); + } + } catch (error) { + setError(error); + } finally { + setIsLoading(false); + } + }; + + setTransferValue(); + }, [debouncedAmountBN.toNumber()]); + + const onSubmit = useCallback( + async (e: FormEvent) => { + try { + e.preventDefault(); + + if (!requestPayload?.data || !debouncedAmountBN.toNumber()) { + throw new Error('Invalid request payload'); + } + + setIsLoading(true); + const txHash = await sendSquidWithdraw(debouncedAmountBN.toNumber(), requestPayload?.data); + + if (txHash?.hash) { + const hash = `0x${Buffer.from(txHash.hash).toString('hex')}`; + + setTransactionHash(hash); + abacusStateManager.setTransferStatus({ + hash, + fromChainId: TESTNET_CHAIN_ID, + toChainId: chainIdStr || undefined, + }); + abacusStateManager.clearTransferInputValues(); + setWithdrawAmount(''); + } + } catch (error) { + setError(error); + } finally { + setIsLoading(false); + } + }, + [setTransactionHash, requestPayload, debouncedAmountBN, chainIdStr] + ); + + const onChangeAddress = useCallback((e: ChangeEvent) => { + abacusStateManager.setTransferValue({ + field: TransferInputField.address, + value: e.target.value, + }); + }, []); + + const onChangeAmount = useCallback( + ({ value }: NumberFormatValues) => { + setWithdrawAmount(value); + }, + [setWithdrawAmount] + ); + + const onSetSlippage = useCallback( + (newSlippage: number) => { + setSlippage(newSlippage); + + // TODO: to be implemented via abacus + // if (MustBigNumber(newSlippage).gt(0) && debouncedAmountBN.gt(0)) { + // fetchRoute({ newAmount: debouncedAmount, newSlippage }); + // } + }, + [setSlippage, debouncedAmount] + ); + + const onClickMax = useCallback(() => { + setWithdrawAmount(freeCollateralBN.toString()); + }, [freeCollateral?.current, setWithdrawAmount]); + + const onSelectChain = useCallback((chain: string) => { + if (chain) { + abacusStateManager.clearTransferInputValues(); + abacusStateManager.setTransferValue({ + field: TransferInputField.chain, + value: chain, + }); + setWithdrawAmount(''); + } + }, []); + + const onSelectToken = useCallback((token: TransferInputTokenResource) => { + if (token) { + abacusStateManager.clearTransferInputValues(); + abacusStateManager.setTransferValue({ + field: TransferInputField.token, + value: token.address, + }); + setWithdrawAmount(''); + } + }, []); + + const amountInputReceipt = [ + { + key: 'freeCollateral', + label: ( + + {stringGetter({ key: STRING_KEYS.FREE_COLLATERAL })} USDC + + ), + value: ( + + ), + }, + ]; + + const errorMessage = useMemo(() => { + if (error) { + return error?.message + ? stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { ERROR_MESSAGE: error.message }, + }) + : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }); + } + + if (!toAddress) return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ADDRESS }); + + if (debouncedAmountBN) { + if (!chainIdStr) { + return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_CHAIN }); + } else if (!toToken) { + return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ASSET }); + } + } + + if (MustBigNumber(debouncedAmountBN).gt(MustBigNumber(freeCollateralBN))) { + return stringGetter({ key: STRING_KEYS.WITHDRAW_MORE_THAN_FREE }); + } + + return undefined; + }, [error, freeCollateralBN, chainIdStr, debouncedAmountBN, toToken]); + + const isDisabled = + !!errorMessage || + !toToken || + !chainIdStr || + !toAddress || + debouncedAmountBN.isNaN() || + debouncedAmountBN.isZero(); + + return ( + + + + + + + + + {stringGetter({ key: STRING_KEYS.MAX })} + + } + /> + + {errorMessage ? ( + {errorMessage} + ) : ( + transactionHash && ( + + + {stringGetter({ key: STRING_KEYS.WITHDRAW_IN_PROGRESS })} + + + ) + )} + + + ); +}; + +const Styled: Record = {}; + +Styled.DiffOutput = styled(DiffOutput)` + --diffOutput-valueWithDiff-fontSize: 1em; +`; + +Styled.Form = styled.form` + --form-input-height: 3.5rem; + + min-height: calc(100% - var(--stickyArea0-bottomHeight)); + + ${layoutMixins.flexColumn} + gap: 1.25rem; + + ${layoutMixins.stickyArea1} +`; + +Styled.DestinationRow = styled.div` + ${layoutMixins.spacedRow} + grid-template-columns: 1fr 1fr; + gap: 1rem; +`; + +Styled.WithDetailsReceipt = styled(WithDetailsReceipt)` + --withReceipt-backgroundColor: var(--color-layer-2); +`; + +Styled.Link = styled(Link)` + color: var(--color-accent); + + &:visited { + color: var(--color-accent); + } +`; + +Styled.TransactionInfo = styled.span` + ${layoutMixins.row} +`; + +Styled.FormInputButton = styled(Button)` + ${formMixins.inputInnerButton} +`; diff --git a/src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx b/src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx new file mode 100644 index 0000000..2047627 --- /dev/null +++ b/src/views/forms/AccountManagementForms/WithdrawForm/WithdrawButtonAndReceipt.tsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import styled, { type AnyStyledComponent } from 'styled-components'; + +import { TransferInputTokenResource } from '@/constants/abacus'; +import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons'; +import { STRING_KEYS } from '@/constants/localization'; +import { NumberSign } from '@/constants/numbers'; +import { useStringGetter } from '@/hooks'; +import { useAccountBalance } from '@/hooks/useAccountBalance'; +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Button } from '@/components/Button'; + +import { Details, DetailsItem } from '@/components/Details'; +import { DiffOutput } from '@/components/DiffOutput'; +import { Icon, IconName } from '@/components/Icon'; +import { Output, OutputType } from '@/components/Output'; +import { ToggleButton } from '@/components/ToggleButton'; +import { WithReceipt } from '@/components/WithReceipt'; + +import { getSubaccount } from '@/state/accountSelectors'; +import { getTransferInputs } from '@/state/inputsSelectors'; + +import { SlippageEditor } from '../SlippageEditor'; + +type ElementProps = { + setSlippage: (slippage: number) => void; + + slippage: number; + withdrawChain?: string; + withdrawToken?: TransferInputTokenResource; + + isDisabled?: boolean; + isLoading?: boolean; +}; + +export const WithdrawButtonAndReceipt = ({ + setSlippage, + + slippage, + withdrawChain, + withdrawToken, + + isDisabled, + isLoading, +}: ElementProps) => { + const [showFeeBreakdown, setShowFeeBreakdown] = useState(false); + const [isEditingSlippage, setIsEditingSlipapge] = useState(false); + const stringGetter = useStringGetter(); + + const { balance, queryStatus } = useAccountBalance({ + addressOrDenom: withdrawToken?.address || undefined, + assetSymbol: withdrawToken?.symbol || undefined, + chainId: withdrawChain ? parseInt(withdrawChain) : undefined, + decimals: withdrawToken?.decimals || undefined, + isCosmosChain: false, + }); + + // TODO: uncomment when we have a way to get token amount estimate from abacus + // const balanceBN = MustBigNumber(balance); + // const newBalance = + // // toAmountMin && withdrawToken && parseUnits(toAmountMin, withdrawToken.decimals).toString(); + + const { leverage } = useSelector(getSubaccount, shallowEqual) || {}; + const { summary, requestPayload } = useSelector(getTransferInputs, shallowEqual) || {}; + + const feeSubitems: DetailsItem[] = []; + + if (typeof summary?.gasFee === 'number') { + feeSubitems.push({ + key: 'gas-fees', + label: {stringGetter({ key: STRING_KEYS.GAS_FEE })}, + value: , + }); + } + + if (typeof summary?.bridgeFee === 'number') { + feeSubitems.push({ + key: 'bridge-fees', + label: {stringGetter({ key: STRING_KEYS.BRIDGE_FEE })}, + value: , + }); + } + + const hasSubitems = feeSubitems.length > 0; + + const showSubitemsToggle = showFeeBreakdown + ? stringGetter({ key: STRING_KEYS.HIDE_ALL_DETAILS }) + : stringGetter({ key: STRING_KEYS.SHOW_ALL_DETAILS }); + + const submitButtonReceipt = [ + { + key: 'total-fees', + label: {stringGetter({ key: STRING_KEYS.TOTAL_FEES })}, + value: typeof summary?.bridgeFee === 'number' && typeof summary?.gasFee === 'number' && ( + + ), + subitems: feeSubitems, + }, + { + key: 'leverage', + label: {stringGetter({ key: STRING_KEYS.LEVERAGE })}, + value: ( + + ), + }, + { + key: 'exchange-rate', + label: {stringGetter({ key: STRING_KEYS.EXCHANGE_RATE })}, + value: withdrawToken && typeof summary?.exchangeRate === 'number' && ( + + + = + + + ), + }, + { + key: 'slippage', + label: {stringGetter({ key: STRING_KEYS.SLIPPAGE })}, + value: ( + + ), + }, + // TODO: uncomment when we have a way to get token amount estimate from abacus + // { + // key: 'wallet', + // label: ( + // + // {stringGetter({ key: STRING_KEYS.WALLET })}{' '} + // {withdrawToken && {withdrawToken?.symbol}} + // + // ), + // value: ( + // + // ), + // }, + ]; + + const isFormValid = !isDisabled && !isEditingSlippage && queryStatus !== 'error'; + + return ( + + + {hasSubitems && ( + + } + > + {showSubitemsToggle} + + + )} + + } + > + + + ); +}; + +const Styled: Record = {}; + +Styled.DiffOutput = styled(DiffOutput)` + --diffOutput-valueWithDiff-fontSize: 1em; +`; + +Styled.ExchangeRate = styled.span` + ${layoutMixins.row} + gap: 0.5ch; +`; + +Styled.WithReceipt = styled(WithReceipt)` + --withReceipt-backgroundColor: var(--color-layer-2); +`; + +Styled.CollapsibleDetails = styled.div` + ${layoutMixins.column} + padding: 0.375rem 1rem 0.5rem; + gap: 0.5rem; +`; + +Styled.Details = styled(Details)` + font-size: 0.8125em; +`; + +Styled.DetailButtons = styled.div` + ${layoutMixins.spacedRow} +`; + +Styled.ToggleButton = styled(ToggleButton)` + --button-toggle-off-backgroundColor: transparent; + --button-toggle-on-backgroundColor: transparent; + --button-toggle-on-textColor: var(--color-text-0); + + svg { + width: 0.875em; + height: 0.875em; + } + + &[data-state='on'] { + svg { + transform: rotate(180deg); + } + } +`; diff --git a/src/views/forms/DepositForm.tsx b/src/views/forms/DepositForm.tsx deleted file mode 100644 index d64d522..0000000 --- a/src/views/forms/DepositForm.tsx +++ /dev/null @@ -1,428 +0,0 @@ -import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; -import { useSelector } from 'react-redux'; -import styled, { type AnyStyledComponent } from 'styled-components'; -import type { ChainData, CosmosChain, RouteData, TokenData } from '@0xsquid/sdk'; -import { type NumberFormatValues } from 'react-number-format'; -import debounce from 'lodash/debounce'; -import { parseUnits } from 'ethers'; - -import { TransferInputField } from '@/constants/abacus'; -import { AlertType } from '@/constants/alerts'; -import { ButtonSize } from '@/constants/buttons'; -import { StringGetterFunction, STRING_KEYS } from '@/constants/localization'; -import { NumberSign } from '@/constants/numbers'; -import { CLIENT_NETWORK_CONFIGS, type DydxV4Network } from '@/constants/networks'; - -import { useAccounts, useDydxClient, useStringGetter } from '@/hooks'; -import { useAccountBalance } from '@/hooks/useAccountBalance'; -import { layoutMixins } from '@/styles/layoutMixins'; - -import { AlertMessage } from '@/components/AlertMessage'; -import { Button } from '@/components/Button'; -import { DiffOutput } from '@/components/DiffOutput'; -import { FormInput } from '@/components/FormInput'; -import { InputType } from '@/components/Input'; -import { Link } from '@/components/Link'; -import { OutputType } from '@/components/Output'; -import { Tag } from '@/components/Tag'; -import { WithLabel } from '@/components/WithLabel'; -import { WithDetailsReceipt } from '@/components/WithDetailsReceipt'; - -import abacusStateManager from '@/lib/abacus'; -import { convertBech32Address } from '@/lib/addressUtils'; -import { MustBigNumber } from '@/lib/numbers'; -import squidRouter from '@/lib/squidRouter'; - -import { useSquidData } from './DepositForm/useSquidData'; -import { ChainSelectMenu } from './DepositForm/ChainSelectMenu'; -import { DepositButtonAndReceipt } from './DepositForm/DepositButtonAndReceipt'; -import { TokenSelectMenu } from './DepositForm/TokenSelectMenu'; -import { log } from '@/lib/telemetry'; - -import { getSelectedNetwork } from '@/state/appSelectors'; - -type DepositFormProps = { - onDeposit?: () => void; - onError?: () => void; -}; - -// debounced function should be outside our functional component -const debouncedGetRoute = debounce( - async ({ - amount, - currentChain, - cosmosAddress, - fromAmount, - setAbacusTransferInput, - setError, - setIsLoading, - setSquidRoute, - slippage, - sourceToken, - }) => { - if (!currentChain || !sourceToken || !fromAmount || !cosmosAddress) return; - setIsLoading(true); - setSquidRoute(undefined); - setError(undefined); - - const params = { - fromChain: currentChain.chainId, - fromToken: sourceToken.address, - fromAmount: parseUnits(amount, sourceToken.decimals).toString(), - toAddress: convertBech32Address({ address: cosmosAddress, bech32Prefix: 'osmo' }), // the recipient of the trade - slippage: slippage * 100, - ...squidRouter.SQUID_ROUTE_DEFAULTS, - }; - - try { - const { route } = await squidRouter.getRoute(params); - setSquidRoute(route); - setAbacusTransferInput(route.estimate.toAmountUSD); - } catch (error) { - log('DepositForm/getRoute', error); - setError(error); - } finally { - setIsLoading(false); - } - }, - 1000, - { trailing: true } -); - -const getDepositFormErrorMessage = ({ - error, - balance, - currentChain, - fromAmount, - sourceToken, - stringGetter, -}: { - error?: any; - balance?: string; - currentChain?: ChainData; - fromAmount: string; - sourceToken?: TokenData; - stringGetter: StringGetterFunction; -}): string | undefined => { - if (error) { - return error?.message - ? stringGetter({ - key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, - params: { ERROR_MESSAGE: error.message }, - }) - : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }); - } - - if (fromAmount) { - if (!currentChain) { - return stringGetter({ key: STRING_KEYS.MUST_SPECIFY_CHAIN }); - } else if (!sourceToken) { - return stringGetter({ key: STRING_KEYS.MUST_SPECIFY_ASSET }); - } - } - - if (MustBigNumber(fromAmount).gt(MustBigNumber(balance))) { - return stringGetter({ key: STRING_KEYS.DEPOSIT_MORE_THAN_BALANCE }); - } - - return undefined; -}; - -export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => { - const stringGetter = useStringGetter(); - - const { dydxAddress: cosmosAddress, signerWagmi, signerGraz } = useAccounts(); - - // const { networkConfig } = useDydxClient(); - const selectedNetwork = useSelector(getSelectedNetwork); - const evmChainId = Number(CLIENT_NETWORK_CONFIGS[selectedNetwork as DydxV4Network].ethereumChainId); - - const { chains, tokens } = useSquidData(signerGraz ? 'cosmos' : 'evm'); - - // User inputs - const defaultChain: ChainData | undefined = useMemo( - () => chains?.find(({ chainId }) => chainId === evmChainId) ?? undefined, - [chains] - ); - const [currentChain, setCurrentChain] = useState(defaultChain); - - const defaultToken: TokenData | undefined = useMemo( - () => tokens?.filter(({ chainId }) => chainId === currentChain?.chainId)?.[0] ?? undefined, - [currentChain] - ); - const [sourceToken, setSourceToken] = useState(defaultToken); - const [fromAmount, setFromAmount] = useState(''); - const [slippage, setSlippage] = useState(0.001); // 0.1% slippage - - // Async Data - const [squidRoute, setSquidRoute] = useState(); - const [transactionHash, setTransactionHash] = useState(); - - // Form states - const [error, setError] = useState(); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - setCurrentChain(defaultChain); - - return () => { - abacusStateManager.clearTransferInputValues(); - }; - }, [defaultChain]); - - useEffect(() => { - setSourceToken(defaultToken); - setSquidRoute(undefined); - setFromAmount(''); - }, [currentChain]); - - // @ts-ignore ibcDenom exists but is not typed properly on 0xSquid SDK - const addressOrDenom = sourceToken?.ibcDenom || sourceToken?.address; - const bech32AddrPrefix = (currentChain as CosmosChain)?.bech32Config?.bech32PrefixAccAddr; - - const { balance, isBalanceError, isBalanceLoading } = useAccountBalance({ - addressOrDenom, - assetSymbol: sourceToken?.symbol, - bech32AddrPrefix, - chainId: currentChain?.chainId, - decimals: sourceToken?.decimals, - rpc: currentChain?.rpc, - isCosmosChain: currentChain?.chainType === 'cosmos', - }); - - // BN - const fromAmountBN = MustBigNumber(fromAmount); - const balanceBN = MustBigNumber(balance); - - const requestDeposit = async () => { - if (!squidRoute) { - log('DepositForm/requestDeposit', new Error('No squid route')); - return; - } - - try { - setIsLoading(true); - setError(undefined); - setTransactionHash(undefined); - - const txReceipt = await squidRouter.executeRoute({ - route: squidRoute, - signer: signerGraz || signerWagmi, - }); - console.log('DepositForm/executeRoute', { txReceipt }); - setFromAmount(''); - setTransactionHash(txReceipt?.transactionHash); - } catch (error) { - log('DepositForm/executeRoute', error); - setError(error); - } finally { - setIsLoading(false); - } - }; - - const setAbacusTransferInput = (value: string | number) => { - abacusStateManager.setTransferValue({ value, field: TransferInputField.usdcSize }); - }; - - const getRoute = useCallback( - ({ newAmount, newSlippage }: { newAmount: string; newSlippage?: number }) => { - debouncedGetRoute.cancel(); - - if (MustBigNumber(newAmount).gt(0) && MustBigNumber(newAmount).lte(balanceBN)) { - debouncedGetRoute({ - amount: newAmount, - currentChain, - cosmosAddress, - fromAmount, - setAbacusTransferInput, - setError, - setIsLoading, - setSquidRoute, - slippage: newSlippage || slippage, - sourceToken, - }); - } else { - setSquidRoute(undefined); - setAbacusTransferInput(0); - } - }, - [currentChain, sourceToken, fromAmount, slippage] - ); - - const onChangeAmount = ({ value }: NumberFormatValues) => { - setFromAmount(value); - getRoute({ newAmount: value }); - }; - - const onSetSlippage = (newSlippage: number) => { - setSlippage(newSlippage); - - if (MustBigNumber(newSlippage).gt(0) && fromAmountBN.gt(0)) { - getRoute({ newAmount: fromAmount, newSlippage }); - } - }; - - const onClickMax = () => { - if (balance) { - setFromAmount(balanceBN.toString()); - } - }; - - const amountInputReceipt = [ - { - key: 'amount', - label: ( - - {stringGetter({ key: STRING_KEYS.AVAILABLE })}{' '} - {sourceToken && {sourceToken.symbol}} - - ), - value: ( - - ), - }, - ]; - - const errorMessage = getDepositFormErrorMessage({ - error, - balance, - currentChain, - fromAmount, - sourceToken, - stringGetter, - }); - - const isDisabled = - Boolean(errorMessage) || - !sourceToken || - !currentChain || - fromAmountBN.isNaN() || - fromAmountBN.isZero() || - !squidRoute; - - const alertMessage = { - type: errorMessage ? AlertType.Error : AlertType.Info, - message: - errorMessage || - (transactionHash && ( - - {stringGetter({ key: STRING_KEYS.VIEW_DETAILS })} - {' on '} - - Axelar - - . - - )), - }; - - return ( - { - e.preventDefault(); - requestDeposit(); - }} - > - - { - setSourceToken(token); - - if (fromAmount) { - debouncedGetRoute({ - amount: fromAmount, - currentChain, - cosmosAddress, - fromAmount, - setError, - setIsLoading, - setSquidRoute, - slippage, - sourceToken: token, - }); - } else { - setSquidRoute(undefined); - } - }} - /> - - - - {stringGetter({ key: STRING_KEYS.MAX })} - - } - /> - - - {alertMessage.message && ( - {alertMessage.message} - )} - - - - - ); -}; - -const Styled: Record = {}; - -Styled.Form = styled.form` - ${layoutMixins.flexColumn} - gap: 1.5rem; - - ${layoutMixins.stickyArea1} - min-height: calc(100% - var(--stickyArea0-bottomHeight)); -`; - -Styled.WithDetailsReceipt = styled(WithDetailsReceipt)` - --withReceipt-backgroundColor: var(--color-layer-2); -`; - -Styled.FormInput = styled(FormInput)` - --formInput-backgroundColor: var(--color-layer-4); - --formInput-input-height: 3.375rem; - border: 1px solid var(--color-layer-6); -`; - -Styled.Link = styled(Link)` - color: var(--color-accent); -`; - -Styled.AlertMessage = styled(AlertMessage)` - cursor: normal; - user-select: none; - margin: 0; -`; - -Styled.Footer = styled.footer` - ${layoutMixins.stickyFooter} - margin-top: auto; -`; diff --git a/src/views/forms/DepositForm/TokenSelectMenu.tsx b/src/views/forms/DepositForm/TokenSelectMenu.tsx deleted file mode 100644 index f93d2c5..0000000 --- a/src/views/forms/DepositForm/TokenSelectMenu.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import type { ChainData, TokenData } from '@0xsquid/sdk'; -import styled, { type AnyStyledComponent } from 'styled-components'; - -import { STRING_KEYS } from '@/constants/localization'; -import { useStringGetter } from '@/hooks'; - -import { SearchSelectMenu } from '@/components/SearchSelectMenu'; -import { Tag } from '@/components/Tag'; -import { WithLabel } from '@/components/WithLabel'; - -import { layoutMixins } from '@/styles/layoutMixins'; - -type ElementProps = { - chain?: ChainData; - tokens?: TokenData[]; - sourceToken?: TokenData; - setSourceToken: (token: TokenData) => void; -}; - -export const TokenSelectMenu = ({ - tokens = [], - chain, - sourceToken, - setSourceToken, -}: ElementProps) => { - const stringGetter = useStringGetter(); - - const tokenItems = Object.values(tokens) - .filter((token: TokenData) => token.chainId === chain?.chainId) - .map((token: TokenData) => ({ - value: token.address, - label: token.name, - onSelect: () => setSourceToken(token), - slotBefore: , - })); - - return ( - - {sourceToken?.symbol}USDC - - ), - tooltip: 'swap', - withTooltipIcon: true, - }, - ]} - > - - {sourceToken ? ( - <> - {sourceToken?.name}{' '} - {sourceToken?.symbol} - - ) : ( - stringGetter({ key: STRING_KEYS.SELECT_ASSET }) - )} - - - ); -}; - -const Styled: Record = {}; - -Styled.AssetRow = styled.div` - ${layoutMixins.row} - gap: 0.5rem; - color: var(--color-text-2); - font: var(--font-base-book); -`; - -Styled.Img = styled.img` - width: 1.25rem; - height: 1.25rem; - border-radius: 50%; -`; diff --git a/src/views/forms/DepositForm/useSquidData.ts b/src/views/forms/DepositForm/useSquidData.ts deleted file mode 100644 index 166b3c7..0000000 --- a/src/views/forms/DepositForm/useSquidData.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { ChainData, TokenData } from '@0xsquid/sdk'; - -import squidRouter from '@/lib/squidRouter'; -import { WAGMI_SUPPORTED_CHAINS } from '@/lib/wagmi'; - -/** - * @description hook to return chains and tokens supported by squid - * @note Limit to EVM chains | Cosmos chains can only be used as destination on 0xSquid as of 02/21/2023 - */ -export const useSquidData = (selectedChainType: 'cosmos' | 'evm') => { - const [, setHasInitialized] = useState(false); - - useEffect(() => { - (async () => { - await squidRouter.initializeClient(); - setHasInitialized(true); - })(); - }, []); - - const chainData: ChainData[] = squidRouter.getChains(); - const tokenData: TokenData[] = squidRouter.getTokens(); - const wagmiChainIds = Object.fromEntries( - WAGMI_SUPPORTED_CHAINS.map((supportedChain) => [supportedChain.id, supportedChain]) - ); - - return { - chains: chainData.filter(({ chainId, chainType }) => - selectedChainType === 'evm' ? wagmiChainIds[chainId] : chainType === 'cosmos' - ), - tokens: tokenData, - }; -};