diff --git a/package.json b/package.json index b9d68d4..355ff83 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@cosmjs/tendermint-rpc": "^0.31.0", "@dydxprotocol/v4-abacus": "^0.6.4", "@dydxprotocol/v4-client-js": "^0.36.1", - "@dydxprotocol/v4-localization": "^0.1.28", + "@dydxprotocol/v4-localization": "^0.1.30", "@ethersproject/providers": "^5.7.2", "@js-joda/core": "^5.5.3", "@radix-ui/react-collapsible": "^1.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a6906..3e01552 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + dependencies: '@0xsquid/sdk': specifier: ^1.10.0 @@ -29,8 +33,8 @@ dependencies: specifier: ^0.36.1 version: 0.36.1 '@dydxprotocol/v4-localization': - specifier: ^0.1.28 - version: 0.1.28 + specifier: ^0.1.30 + version: 0.1.30 '@ethersproject/providers': specifier: ^5.7.2 version: 5.7.2 @@ -1006,8 +1010,8 @@ packages: - utf-8-validate dev: false - /@dydxprotocol/v4-localization@0.1.28: - resolution: {integrity: sha512-g6LwjX7EIRm9HISfSX0H6yhktBCZby5opP/yI2aIUvn2OYyB25WcjLEuEvzW5Bh9XXiLijgLf3Tk91wPKWuFbA==} + /@dydxprotocol/v4-localization@0.1.30: + resolution: {integrity: sha512-xTlQuQmPz7wfGkjd07giYMAxRrolDbJE3DryJSIMB6R1MBq1wLzAcwp/iR/nELRwilRX9c44Tap0gGt+oAajiQ==} dev: false /@dydxprotocol/v4-proto@0.2.1: @@ -14095,7 +14099,3 @@ packages: /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: true - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/src/App.tsx b/src/App.tsx index b6acfea..35c6e86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { DialogAreaProvider, useDialogArea } from '@/hooks/useDialogArea'; import { LocaleProvider } from '@/hooks/useLocaleSeparators'; import { NotificationsProvider } from '@/hooks/useNotifications'; import { LocalNotificationsProvider } from '@/hooks/useLocalNotifications'; +import { RestrictionProvider } from '@/hooks/useRestrictions'; import { SubaccountProvider } from '@/hooks/useSubaccount'; import { SquidProvider } from '@/hooks/useSquid'; @@ -66,6 +67,7 @@ const Content = () => { } /> } /> + } /> } /> {isTablet && ( @@ -79,6 +81,7 @@ const Content = () => { }> } /> + } /> @@ -109,6 +112,7 @@ const providers = [ wrapProvider(GrazProvider), wrapProvider(WagmiConfig, { config }), wrapProvider(LocaleProvider), + wrapProvider(RestrictionProvider), wrapProvider(DydxProvider), wrapProvider(AccountsProvider), wrapProvider(SubaccountProvider), diff --git a/src/components/Input.tsx b/src/components/Input.tsx index 5184433..107e559 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -207,6 +207,16 @@ const InputStyle = css` color: var(--color-text-0); opacity: 1; } + + // Input autofill Styles + &:-webkit-autofill, + &:-webkit-autofill:focus { + transition: background-color 600000s 0s, color 600000s 0s; + } + + &[data-autocompleted] { + background-color: var(--input-backgroundColor) !important; + } `; Styled.NumericFormat = styled(NumericFormat)` diff --git a/src/constants/abacus.ts b/src/constants/abacus.ts index c75ab88..592cb33 100644 --- a/src/constants/abacus.ts +++ b/src/constants/abacus.ts @@ -78,8 +78,13 @@ export const Changes = Abacus.exchange.dydx.abacus.state.changes.Changes; export type PerpetualStateChanges = Abacus.exchange.dydx.abacus.state.changes.StateChanges; export const AsyncAbacusStateManager = Abacus.exchange.dydx.abacus.state.manager.AsyncAbacusStateManager; + +// ------ Parsing Errors ------ // export type ParsingError = Abacus.exchange.dydx.abacus.responses.ParsingError; export type ParsingErrors = kollections.List; +export const ParsingErrorType = Abacus.exchange.dydx.abacus.responses.ParsingErrorType; +const parsingErrorTypes = [...ParsingErrorType.values()] as const; +export type ParsingErrorTypes = (typeof parsingErrorTypes)[number]; // ------ Perpetuals/Markets ------ // export type PerpetualState = Abacus.exchange.dydx.abacus.output.PerpetualState; @@ -199,6 +204,12 @@ export type RiskLevels = (typeof riskLevels)[number]; // ------ Notifications ------ // export type AbacusNotification = Abacus.exchange.dydx.abacus.output.Notification; +// ------ Restrictions ------ // +export type UsageRestriction = Abacus.exchange.dydx.abacus.output.UsageRestriction; +export const RestrictionType = Abacus.exchange.dydx.abacus.output.Restriction; +const restrictionTypes = [...RestrictionType.values()] as const; +export type RestrictionTypes = (typeof restrictionTypes)[number]; + // ------ Enum Conversions ------ // type IfEquals = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? A diff --git a/src/constants/dialogs.ts b/src/constants/dialogs.ts index c14c0fb..ac5d5b5 100644 --- a/src/constants/dialogs.ts +++ b/src/constants/dialogs.ts @@ -10,7 +10,10 @@ export enum DialogTypes { MobileSignIn = 'MobileSignIn', Onboarding = 'Onboarding', OrderDetails = 'OrderDetails', + RateLimit = 'RateLimit', Receive = 'Receive', + RestrictedGeo = 'RestrictedGeo', + RestrictedWallet = 'RestrictedWallet', Trade = 'Trade', Transfer = 'Transfer', Withdraw = 'Withdraw', diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d6c228d..6908601 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -16,6 +16,7 @@ import { useLocalStorage } from './useLocalStorage'; import { useNow } from './useNow'; import { useOnClickOutside } from './useOnClickOutside'; import { usePageTitlePriceUpdates } from './usePageTitlePriceUpdates'; +import { useRestrictions } from './useRestrictions'; import { useShouldShowFooter } from './useShouldShowFooter'; import { useSelectedNetwork } from './useSelectedNetwork'; import { useStringGetter } from './useStringGetter'; @@ -40,6 +41,7 @@ export { useNow, useOnClickOutside, usePageTitlePriceUpdates, + useRestrictions, useShouldShowFooter, useSelectedNetwork, useStringGetter, diff --git a/src/hooks/useAccounts.tsx b/src/hooks/useAccounts.tsx index 5a6f494..34a6b03 100644 --- a/src/hooks/useAccounts.tsx +++ b/src/hooks/useAccounts.tsx @@ -5,18 +5,20 @@ import { AES, enc } from 'crypto-js'; import { LocalWallet, USDC_DENOM, type Subaccount } from '@dydxprotocol/v4-client-js'; import { OnboardingGuard, OnboardingState, type EvmDerivedAddresses } from '@/constants/account'; +import { DialogTypes } from '@/constants/dialogs'; import { LocalStorageKey, LOCAL_STORAGE_VERSIONS } from '@/constants/localStorage'; import { DydxAddress, EvmAddress, PrivateInformation } from '@/constants/wallets'; import { setOnboardingState, setOnboardingGuard } from '@/state/account'; +import { forceOpenDialog } from '@/state/dialogs'; import abacusStateManager from '@/lib/abacus'; import { log } from '@/lib/telemetry'; -import { useLocalStorage } from './useLocalStorage'; - -import { useWalletConnection } from './useWalletConnection'; import { useDydxClient } from './useDydxClient'; +import { useLocalStorage } from './useLocalStorage'; +import { useRestrictions } from './useRestrictions'; +import { useWalletConnection } from './useWalletConnection'; const AccountsContext = createContext | undefined>(undefined); @@ -57,7 +59,9 @@ const useAccountsContext = () => { forgetEvmSignature(previousEvmAddress); } - if (evmAddress) abacusStateManager.setTransfersSourceAddress(evmAddress); + if (evmAddress) { + abacusStateManager.setTransfersSourceAddress(evmAddress); + } setPreviousEvmAddress(evmAddress); }, [evmAddress]); @@ -217,7 +221,6 @@ const useAccountsContext = () => { }, [evmAddress, evmDerivedAddresses, signerWagmi, connectedDydxAddress, signerGraz]); // abacus - // TODO: useAbacus({ dydxAddress }) useEffect(() => { if (dydxAddress) abacusStateManager.setAccount(localDydxWallet); else abacusStateManager.attemptDisconnectAccount(); @@ -258,6 +261,21 @@ const useAccountsContext = () => { ); }, [dydxSubaccounts]); + // Restrictions + const { isBadActor, sanctionedAddresses } = useRestrictions(); + + useEffect(() => { + if ( + dydxAddress && + (isBadActor || + sanctionedAddresses.has(dydxAddress) || + (evmAddress && sanctionedAddresses.has(evmAddress))) + ) { + dispatch(forceOpenDialog({ type: DialogTypes.RestrictedWallet })); + disconnect(); + } + }, [isBadActor, evmAddress, dydxAddress, sanctionedAddresses]); + // Disconnect wallet / accounts const disconnectLocalDydxWallet = () => { setLocalDydxWallet(undefined); diff --git a/src/hooks/useDydxClient.tsx b/src/hooks/useDydxClient.tsx index 40176b8..b8202ff 100644 --- a/src/hooks/useDydxClient.tsx +++ b/src/hooks/useDydxClient.tsx @@ -17,8 +17,11 @@ import type { NetworkConfig, ConnectNetworkEvent } from '@/constants/abacus'; import { type Candle, RESOLUTION_MAP } from '@/constants/candles'; import { getSelectedNetwork } from '@/state/appSelectors'; + import { log } from '@/lib/telemetry'; +import { useRestrictions } from './useRestrictions'; + type DydxContextType = ReturnType; const DydxContext = createContext({} as DydxContextType); DydxContext.displayName = 'dYdXClient'; @@ -178,7 +181,27 @@ const useDydxClientContext = () => { [requestCandles] ); - // ------ Subacount Methods ------ // + const { updateSanctionedAddresses } = useRestrictions(); + + const screenAddresses = useCallback( + async ({ addresses }: { addresses: string[] }) => { + if (compositeClient) { + const promises = addresses.map((address) => + compositeClient.indexerClient.utility.screen(address) + ); + + const results = await Promise.all(promises); + + const screenedAddresses = Object.fromEntries( + addresses.map((address, index) => [address, results[index]?.restricted]) + ); + + updateSanctionedAddresses(screenedAddresses); + return screenedAddresses; + } + }, + [compositeClient] + ); return { // Client initialization @@ -192,5 +215,6 @@ const useDydxClientContext = () => { // Public Methods getCandlesForDatafeed, + screenAddresses, }; }; diff --git a/src/hooks/useInitializePage.ts b/src/hooks/useInitializePage.ts index 05568d8..9a61b97 100644 --- a/src/hooks/useInitializePage.ts +++ b/src/hooks/useInitializePage.ts @@ -10,6 +10,7 @@ import { useLocalStorage } from '@/hooks'; import { initializeLocalization } from '@/state/app'; import abacusStateManager from '@/lib/abacus'; +import { validateAgainstAvailableEnvironments } from '@/lib/network'; export const useInitializePage = () => { const dispatch = useDispatch(); @@ -18,6 +19,7 @@ export const useInitializePage = () => { const [localStorageNetwork] = useLocalStorage({ key: LocalStorageKey.SelectedNetwork, defaultValue: DEFAULT_APP_ENVIRONMENT, + validateFn: validateAgainstAvailableEnvironments, }); useEffect(() => { diff --git a/src/hooks/useRestrictions.tsx b/src/hooks/useRestrictions.tsx new file mode 100644 index 0000000..184a523 --- /dev/null +++ b/src/hooks/useRestrictions.tsx @@ -0,0 +1,53 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; + +import { RestrictionType } from '@/constants/abacus'; +import { DialogTypes } from '@/constants/dialogs'; + +import { getRestrictionType } from '@/state/accountSelectors'; +import { forceOpenDialog } from '@/state/dialogs'; +import { isTruthy } from '@/lib/isTruthy'; + +const useRestrictionContext = () => { + const dispatch = useDispatch(); + const restrictionType = useSelector(getRestrictionType, shallowEqual); + const [sanctionedAddresses, setSanctionedAddresses] = useState([]); + + useEffect(() => { + if (restrictionType === RestrictionType.GEO_RESTRICTED) { + dispatch( + forceOpenDialog({ type: DialogTypes.RestrictedGeo, dialogProps: { preventClose: true } }) + ); + } + }, [restrictionType, dispatch]); + + const updateSanctionedAddresses = useCallback( + (screenedAddresses: { [address: string]: boolean }) => { + const toAdd = Object.entries(screenedAddresses) + .filter(([, isSanctioned]) => isSanctioned) + .map(([address]) => address); + + if (toAdd.length) { + setSanctionedAddresses([...sanctionedAddresses, ...toAdd]); + } + }, + [sanctionedAddresses] + ); + + return { + isBadActor: restrictionType === RestrictionType.USER_RESTRICTED, + isGeoRestricted: restrictionType === RestrictionType.GEO_RESTRICTED, + updateSanctionedAddresses, + sanctionedAddresses: useMemo(() => new Set(sanctionedAddresses), [sanctionedAddresses]), + }; +}; + +type RestrictionContextType = ReturnType; +const RestrictionContext = createContext({} as RestrictionContextType); +RestrictionContext.displayName = 'Restriction'; + +export const RestrictionProvider = ({ ...props }) => ( + +); + +export const useRestrictions = () => useContext(RestrictionContext); diff --git a/src/hooks/useSelectedNetwork.ts b/src/hooks/useSelectedNetwork.ts index ca4cbb0..9b7a898 100644 --- a/src/hooks/useSelectedNetwork.ts +++ b/src/hooks/useSelectedNetwork.ts @@ -2,13 +2,15 @@ import { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { LocalStorageKey } from '@/constants/localStorage'; -import { DEFAULT_APP_ENVIRONMENT, DydxNetwork } from '@/constants/networks'; +import { DEFAULT_APP_ENVIRONMENT, DydxNetwork, ENVIRONMENT_CONFIG_MAP } from '@/constants/networks'; import { useAccounts, useLocalStorage } from '@/hooks'; import { setSelectedNetwork } from '@/state/app'; import { getSelectedNetwork } from '@/state/appSelectors'; +import { validateAgainstAvailableEnvironments } from '@/lib/network'; + export const useSelectedNetwork = (): { switchNetwork: (network: DydxNetwork) => void; selectedNetwork: DydxNetwork; @@ -20,6 +22,7 @@ export const useSelectedNetwork = (): { const [, setLocalStorageNetwork] = useLocalStorage({ key: LocalStorageKey.SelectedNetwork, defaultValue: DEFAULT_APP_ENVIRONMENT, + validateFn: validateAgainstAvailableEnvironments, }); const switchNetwork = useCallback( diff --git a/src/hooks/useSubaccount.tsx b/src/hooks/useSubaccount.tsx index 5ab0a73..042a599 100644 --- a/src/hooks/useSubaccount.tsx +++ b/src/hooks/useSubaccount.tsx @@ -36,7 +36,6 @@ import { log } from '@/lib/telemetry'; import { useAccounts } from './useAccounts'; import { useDydxClient } from './useDydxClient'; - type SubaccountContextType = ReturnType; const SubaccountContext = createContext({} as SubaccountContextType); SubaccountContext.displayName = 'Subaccount'; @@ -275,6 +274,7 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo await getFaucetFunds({ dydxAddress, subaccountNumber }); } catch (error) { log('useSubaccount/getFaucetFunds', error); + throw error; } }, [dydxAddress, getFaucetFunds, subaccountNumber]); diff --git a/src/layout/DialogManager.tsx b/src/layout/DialogManager.tsx index 4a7d0f0..0a653b5 100644 --- a/src/layout/DialogManager.tsx +++ b/src/layout/DialogManager.tsx @@ -15,9 +15,12 @@ import { ExternalNavKeplrDialog } from '@/views/dialogs/ExternalNavKeplrDialog'; import { MnemonicExportDialog } from '@/views/dialogs/MnemonicExportDialog'; import { MobileSignInDialog } from '@/views/dialogs/MobileSignInDialog'; import { OnboardingDialog } from '@/views/dialogs/OnboardingDialog'; +import { RateLimitDialog } from '@/views/dialogs/RateLimitDialog'; import { ReceiveDialog } from '@/views/dialogs/ReceiveDialog'; +import { RestrictedGeoDialog } from '@/views/dialogs/RestrictedGeoDialog'; import { TradeDialog } from '@/views/dialogs/TradeDialog'; import { TransferDialog } from '@/views/dialogs/TransferDialog'; +import { RestrictedWalletDialog } from '@/views/dialogs/RestrictedWalletDialog'; import { WithdrawDialog } from '@/views/dialogs/WithdrawDialog'; import { OrderDetailsDialog } from '@/views/dialogs/DetailsDialog/OrderDetailsDialog'; @@ -53,7 +56,10 @@ export const DialogManager = () => { [DialogTypes.MobileSignIn]: , [DialogTypes.Onboarding]: , [DialogTypes.OrderDetails]: , + [DialogTypes.RateLimit]: , [DialogTypes.Receive]: , + [DialogTypes.RestrictedGeo]: , + [DialogTypes.RestrictedWallet]: , [DialogTypes.Trade]: , [DialogTypes.Transfer]: , [DialogTypes.Withdraw]: , diff --git a/src/lib/abacus/rest.ts b/src/lib/abacus/rest.ts index 2c5ca00..52e5ca4 100644 --- a/src/lib/abacus/rest.ts +++ b/src/lib/abacus/rest.ts @@ -47,6 +47,11 @@ class AbacusRest implements AbacusRestProtocol { body, }; + if (!url) { + callback(null, 0); + return; + } + fetch(url, options) .then(async (response) => { const data = await response.text(); diff --git a/src/lib/abacus/stateNotification.ts b/src/lib/abacus/stateNotification.ts index fa2b9e9..de275da 100644 --- a/src/lib/abacus/stateNotification.ts +++ b/src/lib/abacus/stateNotification.ts @@ -25,6 +25,7 @@ import { setFundingPayments, setHistoricalPnl, setLatestOrder, + setRestrictionType, setSubaccount, setTransfers, setWallet, @@ -121,6 +122,10 @@ class AbacusStateNotifier implements AbacusStateNotificationProtocol { ); } + if (changes.has(Changes.restriction)) { + dispatch(setRestrictionType(updatedState.restriction)); + } + subaccountNumbers?.forEach((subaccountId: number) => { if (subaccountId !== null) { if (changes.has(Changes.subaccount)) { diff --git a/src/lib/network.ts b/src/lib/network.ts new file mode 100644 index 0000000..56131de --- /dev/null +++ b/src/lib/network.ts @@ -0,0 +1,4 @@ +import { type DydxNetwork, ENVIRONMENT_CONFIG_MAP } from '@/constants/networks'; + +export const validateAgainstAvailableEnvironments = (value: DydxNetwork) => + Object.keys(ENVIRONMENT_CONFIG_MAP).includes(value); diff --git a/src/pages/portfolio/AccountDetailsAndHistory.tsx b/src/pages/portfolio/AccountDetailsAndHistory.tsx index df77024..64954d1 100644 --- a/src/pages/portfolio/AccountDetailsAndHistory.tsx +++ b/src/pages/portfolio/AccountDetailsAndHistory.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import styled, { AnyStyledComponent, css } from 'styled-components'; import { shallowEqual, useSelector } from 'react-redux'; import { TooltipContextType } from '@visx/xychart'; +import BigNumber from 'bignumber.js'; import type { Nullable } from '@/constants/abacus'; import { OnboardingState } from '@/constants/account'; @@ -25,7 +26,6 @@ import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton import { isTruthy } from '@/lib/isTruthy'; import { MustBigNumber } from '@/lib/numbers'; - export const usePortfolioValues = ({ equity, visibleData, @@ -140,7 +140,12 @@ export const AccountDetailsAndHistory = () => { - + {pnlDiff && } diff --git a/src/state/_store.ts b/src/state/_store.ts index 28a0b4e..5d5588d 100644 --- a/src/state/_store.ts +++ b/src/state/_store.ts @@ -3,7 +3,6 @@ import { configureStore } from '@reduxjs/toolkit'; import abacusStateManager from '@/lib/abacus'; import appMiddleware from './appMiddleware'; import localizationMiddleware from './localizationMiddleware'; -import routerMiddleware from './routerMiddleware'; import { accountSlice } from './account'; import { appSlice } from './app'; @@ -13,7 +12,6 @@ import { dialogsSlice } from './dialogs'; import { inputsSlice } from './inputs'; import { layoutSlice } from './layout'; import { localizationSlice } from './localization'; -import { navigationSlice } from './navigation'; import { perpetualsSlice } from './perpetuals'; export const commandMenuSlices = [layoutSlice, localizationSlice]; @@ -28,7 +26,6 @@ export const store = configureStore({ inputs: inputsSlice.reducer, layout: layoutSlice.reducer, localization: localizationSlice.reducer, - navigation: navigationSlice.reducer, perpetuals: perpetualsSlice.reducer, }, @@ -38,7 +35,6 @@ export const store = configureStore({ }), appMiddleware, localizationMiddleware, - routerMiddleware, ], devTools: process.env.NODE_ENV !== 'production', diff --git a/src/state/account.ts b/src/state/account.ts index e3178d8..aae7b2d 100644 --- a/src/state/account.ts +++ b/src/state/account.ts @@ -12,6 +12,7 @@ import type { SubaccountTransfers, HistoricalPnlPeriods, SubAccountHistoricalPNLs, + UsageRestriction, } from '@/constants/abacus'; import { OnboardingGuard, OnboardingState } from '@/constants/account'; @@ -21,34 +22,46 @@ import { WalletType } from '@/constants/wallets'; import { getLocalStorage } from '@/lib/localStorage'; export type AccountState = { + balances?: Record; + stakingBalances?: Record; + wallet?: Nullable; + walletType?: WalletType; + + subaccount?: Nullable; fills?: SubaccountFills; fundingPayments?: SubaccountFundingPayments; transfers?: SubaccountTransfers; + historicalPnl?: SubAccountHistoricalPNLs; + + onboardingGuards: Record; + onboardingState: OnboardingState; + clearedOrderIds?: string[]; hasUnseenFillUpdates: boolean; hasUnseenOrderUpdates: boolean; - historicalPnl?: SubAccountHistoricalPNLs; latestOrder?: Nullable; - onboardingGuards: Record; - onboardingState: OnboardingState; - subaccount?: Nullable; - uncommittedOrderClientIds: number[]; - wallet?: Nullable; - walletType?: WalletType; historicalPnlPeriod?: HistoricalPnlPeriods; - balances?: Record; - stakingBalances?: Record; + uncommittedOrderClientIds: number[]; + + restriction?: Nullable; }; const initialState: AccountState = { + // Wallet + balances: undefined, + wallet: undefined, + walletType: getLocalStorage({ + key: LocalStorageKey.OnboardingSelectedWalletType, + }), + + // Subaccount + subaccount: undefined, fills: undefined, fundingPayments: undefined, transfers: undefined, - clearedOrderIds: undefined, - hasUnseenFillUpdates: false, - hasUnseenOrderUpdates: false, historicalPnl: undefined, - latestOrder: undefined, + + // Onboarding onboardingGuards: { [OnboardingGuard.hasAcknowledgedTerms]: Boolean( getLocalStorage({ @@ -59,12 +72,17 @@ const initialState: AccountState = { [OnboardingGuard.hasPreviousTransactions]: undefined, }, onboardingState: OnboardingState.Disconnected, - subaccount: undefined, + + // UI + clearedOrderIds: undefined, + hasUnseenFillUpdates: false, + hasUnseenOrderUpdates: false, + latestOrder: undefined, uncommittedOrderClientIds: [], - wallet: undefined, - walletType: getLocalStorage({ - key: LocalStorageKey.OnboardingSelectedWalletType, - }), + historicalPnlPeriod: undefined, + + // Restriction + restriction: undefined, }; export const accountSlice = createSlice({ @@ -124,6 +142,9 @@ export const accountSlice = createSlice({ state.historicalPnl = action.payload; } }, + setRestrictionType: (state, action: PayloadAction>) => { + state.restriction = action.payload; + }, setSubaccount: (state, action: PayloadAction>) => { const existingOrderIds = state.subaccount?.orders ? state.subaccount.orders.toArray().map((order) => order.id) @@ -178,6 +199,7 @@ export const { setOnboardingGuard, setOnboardingState, setHistoricalPnl, + setRestrictionType, setSubaccount, setWallet, viewedFills, diff --git a/src/state/accountSelectors.ts b/src/state/accountSelectors.ts index 4a81a5b..9594b46 100644 --- a/src/state/accountSelectors.ts +++ b/src/state/accountSelectors.ts @@ -342,3 +342,13 @@ export const getBalances = (state: RootState) => state.account?.balances; * @returns user wallet staking balances * */ export const getStakingBalances = (state: RootState) => state.account?.stakingBalances; + +/** + * @returns UsageRestriction of the current session + */ +export const getUsageRestriction = (state: RootState) => state.account.restriction; + +/** + * @returns RestrictionType from the current session + */ +export const getRestrictionType = (state: RootState) => state.account.restriction?.restriction; diff --git a/src/state/app.ts b/src/state/app.ts index 61e5d65..d29bfc6 100644 --- a/src/state/app.ts +++ b/src/state/app.ts @@ -2,10 +2,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { AbacusApiState } from '@/constants/abacus'; import { LocalStorageKey } from '@/constants/localStorage'; - import { DEFAULT_APP_ENVIRONMENT, type DydxNetwork } from '@/constants/networks'; import { getLocalStorage } from '@/lib/localStorage'; +import { validateAgainstAvailableEnvironments } from '@/lib/network'; export interface AppState { apiState: AbacusApiState | undefined; @@ -19,6 +19,7 @@ const initialState: AppState = { selectedNetwork: getLocalStorage({ key: LocalStorageKey.SelectedNetwork, defaultValue: DEFAULT_APP_ENVIRONMENT, + validateFn: validateAgainstAvailableEnvironments, }), }; diff --git a/src/state/dialogs.ts b/src/state/dialogs.ts index 8f2656f..01ca53e 100644 --- a/src/state/dialogs.ts +++ b/src/state/dialogs.ts @@ -23,39 +23,36 @@ export const dialogsSlice = createSlice({ name: 'Dialogs', initialState, reducers: { - addDialogToQueue: (state: DialogsState, action: PayloadAction>) => { - const dialogQueue = state.dialogQueue; - dialogQueue.push(action.payload); - - return { - ...state, - dialogQueue, - }; + openDialog: (state: DialogsState, action: PayloadAction>) => { + if (state.activeDialog) { + state.dialogQueue.push(action.payload); + } else { + state.activeDialog = action.payload; + } + }, + closeDialog: (state: DialogsState) => { + state.activeDialog = state.dialogQueue.shift(); + }, + forceOpenDialog: (state: DialogsState, action: PayloadAction>) => { + if (state.activeDialog) { + state.dialogQueue.unshift(state.activeDialog); + } + state.activeDialog = action.payload; + }, + closeDialogInTradeBox: (state: DialogsState) => { + state.activeTradeBoxDialog = undefined; }, - closeDialog: (state: DialogsState) => ({ - ...state, - activeDialog: state.dialogQueue.shift(), - }), - openDialog: (state: DialogsState, action: PayloadAction>) => ({ - ...state, - activeDialog: action.payload, - }), - closeDialogInTradeBox: (state: DialogsState) => ({ - ...state, - activeTradeBoxDialog: undefined, - }), openDialogInTradeBox: ( state: DialogsState, action: PayloadAction> - ) => ({ - ...state, - activeTradeBoxDialog: action.payload, - }), + ) => { + state.activeTradeBoxDialog = action.payload; + }, }, }); export const { - addDialogToQueue, + forceOpenDialog, closeDialog, closeDialogInTradeBox, openDialog, diff --git a/src/state/navigation.ts b/src/state/navigation.ts deleted file mode 100644 index 2cfc580..0000000 --- a/src/state/navigation.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; - -export interface NavigationState { - tradeLocation: { - hash: string; - key: string; - pathname: string; - search: string; - state: any; - }; -} - -export const CLEAR_TRADE_LOCATION = { - hash: '', - key: '', - pathname: '', - search: '', - state: null, -}; - -const initialState: NavigationState = { - tradeLocation: { - hash: '', - key: '', - pathname: '', - search: '', - state: null, - }, -}; - -export const navigationSlice = createSlice({ - name: 'Navigation', - initialState, - reducers: { - setTradeLocation: (state, { payload }) => ({ - ...state, - tradeLocation: payload, - }), - }, -}); - -export const { setTradeLocation } = navigationSlice.actions; diff --git a/src/state/routerMiddleware.ts b/src/state/routerMiddleware.ts deleted file mode 100644 index 06b2328..0000000 --- a/src/state/routerMiddleware.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { PayloadAction } from '@reduxjs/toolkit'; - -import { LocalStorageKey } from '@/constants/localStorage'; - -import { closeDialogInTradeBox } from '@/state/dialogs'; -import { setTradeLocation } from '@/state/navigation'; - -import abacusStateManager from '@/lib/abacus'; -import { setLocalStorage } from '@/lib/localStorage'; -import { getMarketIdFromLocation } from '@/lib/tradeData'; - -export default (store: any) => (next: any) => async (action: PayloadAction) => { - next(action); - - const { type, payload } = action; - - switch (type) { - case setTradeLocation.type: { - const marketId = getMarketIdFromLocation(payload); - - if (marketId) { - abacusStateManager.setMarket(marketId); - store.dispatch(closeDialogInTradeBox()); - setLocalStorage({ key: LocalStorageKey.LastViewedMarket, value: marketId }); - } - - break; - } - default: { - break; - } - } -}; diff --git a/src/views/charts/PnlChart.tsx b/src/views/charts/PnlChart.tsx index efa9bb0..1509277 100644 --- a/src/views/charts/PnlChart.tsx +++ b/src/views/charts/PnlChart.tsx @@ -25,7 +25,11 @@ import { TimeSeriesChart } from '@/components/visx/TimeSeriesChart'; import { AxisLabelOutput } from '@/components/visx/AxisLabelOutput'; // import { TooltipContent } from '@/components/visx/TooltipContent'; -import { getSubaccount, getSubaccountHistoricalPnl, getSubaccountId } from '@/state/accountSelectors'; +import { + getSubaccount, + getSubaccountHistoricalPnl, + getSubaccountId, +} from '@/state/accountSelectors'; import abacusStateManager from '@/lib/abacus'; import { formatRelativeTime } from '@/lib/dateTime'; diff --git a/src/views/dialogs/RateLimitDialog.tsx b/src/views/dialogs/RateLimitDialog.tsx new file mode 100644 index 0000000..c7dca6b --- /dev/null +++ b/src/views/dialogs/RateLimitDialog.tsx @@ -0,0 +1,42 @@ +import styled, { AnyStyledComponent } from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; +import { useStringGetter } from '@/hooks'; +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Dialog } from '@/components/Dialog'; +import { Icon, IconName } from '@/components/Icon'; + +type ElementProps = { + preventClose?: boolean; + setIsOpen?: (open: boolean) => void; +}; + +export const RateLimitDialog = ({ preventClose, setIsOpen }: ElementProps) => { + const stringGetter = useStringGetter(); + + return ( + } + > + + {stringGetter({ key: STRING_KEYS.RATE_LIMIT_REACHED_ERROR_MESSAGE })} + + + ); +}; + +const Styled: Record = {}; + +Styled.Icon = styled(Icon)` + color: var(--color-warning); +`; + +Styled.Content = styled.div` + ${layoutMixins.column} + gap: 1rem; +`; diff --git a/src/views/dialogs/RestrictedGeoDialog.tsx b/src/views/dialogs/RestrictedGeoDialog.tsx new file mode 100644 index 0000000..54b3dd5 --- /dev/null +++ b/src/views/dialogs/RestrictedGeoDialog.tsx @@ -0,0 +1,42 @@ +import styled, { AnyStyledComponent } from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; +import { useStringGetter } from '@/hooks'; +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Dialog } from '@/components/Dialog'; +import { Icon, IconName } from '@/components/Icon'; + +type ElementProps = { + preventClose?: boolean; + setIsOpen?: (open: boolean) => void; +}; + +export const RestrictedGeoDialog = ({ preventClose, setIsOpen }: ElementProps) => { + const stringGetter = useStringGetter(); + + return ( + } + > + + {stringGetter({ key: STRING_KEYS.REGION_NOT_PERMITTED_SUBTITLE })} + + + ); +}; + +const Styled: Record = {}; + +Styled.Icon = styled(Icon)` + color: var(--color-warning); +`; + +Styled.Content = styled.div` + ${layoutMixins.column} + gap: 1rem; +`; diff --git a/src/views/dialogs/RestrictedWalletDialog.tsx b/src/views/dialogs/RestrictedWalletDialog.tsx new file mode 100644 index 0000000..d3a06ac --- /dev/null +++ b/src/views/dialogs/RestrictedWalletDialog.tsx @@ -0,0 +1,42 @@ +import styled, { AnyStyledComponent } from 'styled-components'; + +import { STRING_KEYS } from '@/constants/localization'; +import { useStringGetter } from '@/hooks'; +import { layoutMixins } from '@/styles/layoutMixins'; + +import { Dialog } from '@/components/Dialog'; +import { Icon, IconName } from '@/components/Icon'; + +type ElementProps = { + preventClose?: boolean; + setIsOpen?: (open: boolean) => void; +}; + +export const RestrictedWalletDialog = ({ preventClose, setIsOpen }: ElementProps) => { + const stringGetter = useStringGetter(); + + return ( + } + > + + {stringGetter({ key: STRING_KEYS.REGION_NOT_PERMITTED_SUBTITLE })} + + + ); +}; + +const Styled: Record = {}; + +Styled.Icon = styled(Icon)` + color: var(--color-warning); +`; + +Styled.Content = styled.div` + ${layoutMixins.column} + gap: 1rem; +`; diff --git a/src/views/forms/AccountManagementForms/WithdrawForm.tsx b/src/views/forms/AccountManagementForms/WithdrawForm.tsx index f24b628..81cc8e9 100644 --- a/src/views/forms/AccountManagementForms/WithdrawForm.tsx +++ b/src/views/forms/AccountManagementForms/WithdrawForm.tsx @@ -10,9 +10,16 @@ import { TransferInputField, TransferInputTokenResource, TransferType } from '@/ import { AlertType } from '@/constants/alerts'; import { ButtonSize } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; -import { NumberSign, QUANTUM_MULTIPLIER } from '@/constants/numbers'; +import { NumberSign } from '@/constants/numbers'; -import { useDebounce, useStringGetter, useSubaccount } from '@/hooks'; +import { + useAccounts, + useDebounce, + useDydxClient, + useRestrictions, + useStringGetter, + useSubaccount, +} from '@/hooks'; import { useLocalNotifications } from '@/hooks/useLocalNotifications'; import { layoutMixins } from '@/styles/layoutMixins'; @@ -39,10 +46,11 @@ import { MustBigNumber } from '@/lib/numbers'; import { TokenSelectMenu } from './TokenSelectMenu'; import { WithdrawButtonAndReceipt } from './WithdrawForm/WithdrawButtonAndReceipt'; +import { join } from 'path'; export const WithdrawForm = () => { const stringGetter = useStringGetter(); - const [error, setError] = useState(null); + const [error, setError] = useState(); const [isLoading, setIsLoading] = useState(false); const { sendSquidWithdraw } = useSubaccount(); @@ -71,9 +79,11 @@ export const WithdrawForm = () => { const { addTransferNotification } = useLocalNotifications(); // Async Data - const debouncedAmountBN = MustBigNumber(debouncedAmount); - const withdrawAmountBN = MustBigNumber(withdrawAmount); - const freeCollateralBN = MustBigNumber(freeCollateral?.current); + const debouncedAmountBN = useMemo(() => MustBigNumber(debouncedAmount), [debouncedAmount]); + const freeCollateralBN = useMemo( + () => MustBigNumber(freeCollateral?.current), + [freeCollateral?.current] + ); useEffect(() => { abacusStateManager.setTransferValue({ @@ -105,50 +115,85 @@ export const WithdrawForm = () => { value: debouncedAmount, field: TransferInputField.usdcSize, }); - setError(null); + setError(undefined); } } catch (error) { - setError(error); + setError(error.message); } finally { setIsLoading(false); } }; setTransferValue(); - }, [debouncedAmountBN.toNumber()]); + }, [debouncedAmountBN]); + + const { screenAddresses } = useDydxClient(); + const { dydxAddress } = useAccounts(); const onSubmit = useCallback( async (e: FormEvent) => { try { e.preventDefault(); - if (!requestPayload?.data || !debouncedAmountBN.toNumber()) { + if (!requestPayload?.data || !debouncedAmountBN.toNumber() || !toAddress || !dydxAddress) { throw new Error('Invalid request payload'); } setIsLoading(true); - const txHash = await sendSquidWithdraw(debouncedAmountBN.toNumber(), requestPayload?.data); + setError(undefined); - if (txHash?.hash) { - const hash = `0x${Buffer.from(txHash.hash).toString('hex')}`; + const screenResults = await screenAddresses({ + addresses: [toAddress, dydxAddress], + }); - addTransferNotification({ - txHash: hash, - fromChainId: TESTNET_CHAIN_ID, - toChainId: chainIdStr || undefined, - toAmount: debouncedAmountBN.toNumber(), - triggeredAt: Date.now(), - }); - abacusStateManager.clearTransferInputValues(); - setWithdrawAmount(''); + if (screenResults?.[dydxAddress]) { + setError( + stringGetter({ + key: STRING_KEYS.WALLET_RESTRICTED_WITHDRAWAL_TRANSFER_ORIGINATION_ERROR_MESSAGE, + }) + ); + } else if (screenResults?.[toAddress]) { + setError( + stringGetter({ + key: STRING_KEYS.WALLET_RESTRICTED_WITHDRAWAL_TRANSFER_DESTINATION_ERROR_MESSAGE, + }) + ); + } else { + const txHash = await sendSquidWithdraw(debouncedAmountBN.toNumber(), requestPayload.data); + if (txHash?.hash) { + const hash = `0x${Buffer.from(txHash.hash).toString('hex')}`; + addTransferNotification({ + txHash: hash, + fromChainId: TESTNET_CHAIN_ID, + toChainId: chainIdStr || undefined, + toAmount: debouncedAmountBN.toNumber(), + triggeredAt: Date.now(), + }); + abacusStateManager.clearTransferInputValues(); + setWithdrawAmount(''); + } } } catch (error) { - setError(error); + if (error?.code === 429) { + setError(stringGetter({ key: STRING_KEYS.RATE_LIMIT_REACHED_ERROR_MESSAGE })); + } else { + setError( + error.message + ? stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { + ERROR_MESSAGE: + error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }), + }, + }) + : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }) + ); + } } finally { setIsLoading(false); } }, - [requestPayload, debouncedAmountBN, chainIdStr] + [requestPayload, debouncedAmountBN, chainIdStr, toAddress, screenAddresses, stringGetter] ); const onChangeAddress = useCallback((e: ChangeEvent) => { @@ -179,7 +224,7 @@ export const WithdrawForm = () => { const onClickMax = useCallback(() => { setWithdrawAmount(freeCollateralBN.toString()); - }, [freeCollateral?.current, setWithdrawAmount]); + }, [freeCollateralBN, setWithdrawAmount]); const onSelectChain = useCallback((chain: string) => { if (chain) { @@ -215,7 +260,7 @@ export const WithdrawForm = () => { value={freeCollateral?.current} newValue={freeCollateral?.postOrder} sign={NumberSign.Negative} - hasInvalidNewValue={withdrawAmountBN.minus(freeCollateralBN).isNegative()} + hasInvalidNewValue={MustBigNumber(withdrawAmount).minus(freeCollateralBN).isNegative()} withDiff={ Boolean(withdrawAmount) && !debouncedAmountBN.isNaN() && !debouncedAmountBN.isZero() } @@ -224,18 +269,23 @@ export const WithdrawForm = () => { }, ]; + const { sanctionedAddresses } = useRestrictions(); + 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 }); + return stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { ERROR_MESSAGE: error }, + }); } if (!toAddress) return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_ADDRESS }); + if (sanctionedAddresses.has(toAddress)) + return stringGetter({ + key: STRING_KEYS.TRANSFER_INVALID_DYDX_ADDRESS, + }); + if (debouncedAmountBN) { if (!chainIdStr) { return stringGetter({ key: STRING_KEYS.WITHDRAW_MUST_SPECIFY_CHAIN }); @@ -249,7 +299,15 @@ export const WithdrawForm = () => { } return undefined; - }, [error, freeCollateralBN, chainIdStr, debouncedAmountBN, toToken]); + }, [ + error, + freeCollateralBN, + chainIdStr, + debouncedAmountBN, + toToken, + toAddress, + sanctionedAddresses, + ]); const isDisabled = !!errorMessage || @@ -257,7 +315,8 @@ export const WithdrawForm = () => { !chainIdStr || !toAddress || debouncedAmountBN.isNaN() || - debouncedAmountBN.isZero(); + debouncedAmountBN.isZero() || + isLoading; return ( diff --git a/src/views/forms/TransferForm.tsx b/src/views/forms/TransferForm.tsx index 1b19036..1af5694 100644 --- a/src/views/forms/TransferForm.tsx +++ b/src/views/forms/TransferForm.tsx @@ -3,8 +3,6 @@ import styled, { type AnyStyledComponent } from 'styled-components'; import { type NumberFormatValues } from 'react-number-format'; import { shallowEqual, useSelector } from 'react-redux'; import type { SyntheticInputEvent } from 'react-number-format/types/types'; -import { debounce } from 'lodash'; -import { StdFee } from '@cosmjs/stargate'; import { validation } from '@dydxprotocol/v4-client-js'; import { TransferInputField, TransferType } from '@/constants/abacus'; @@ -12,7 +10,7 @@ import { AlertType } from '@/constants/alerts'; import { ButtonShape, ButtonSize } from '@/constants/buttons'; import { STRING_KEYS } from '@/constants/localization'; import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks'; -import { NumberSign, QUANTUM_MULTIPLIER } from '@/constants/numbers'; +import { NumberSign } from '@/constants/numbers'; import { DYDX_CHAIN_ASSET_COIN_DENOM, DYDX_CHAIN_ASSET_TAGS, @@ -22,6 +20,8 @@ import { import { useAccountBalance, useAccounts, + useDydxClient, + useRestrictions, useSelectedNetwork, useStringGetter, useSubaccount, @@ -77,7 +77,7 @@ export const TransferForm = ({ const [asset, setAsset] = useState(selectedAsset); // Form states - const [error, setError] = useState(); + const [error, setError] = useState(); const [isLoading, setIsLoading] = useState(false); const balance = asset === DydxChainAsset.USDC ? freeCollateral?.current : nativeTokenBalance; @@ -96,16 +96,6 @@ export const TransferForm = ({ const balanceBN = MustBigNumber(balance); const newBalanceBN = MustBigNumber(newBalance); - const isAddressValid = useMemo( - () => - recipientAddress && - dydxAddress !== recipientAddress && - validation.isValidAddress(recipientAddress), - [recipientAddress] - ); - - const isAmountValid = balance && amount && amountBN.gt(0) && newBalanceBN.gte(0); - useEffect(() => { abacusStateManager.setTransferValue({ value: TransferType.transferOut.rawValue, @@ -125,6 +115,21 @@ export const TransferForm = ({ }); }, [asset]); + const { sanctionedAddresses } = useRestrictions(); + + const isAddressValid = useMemo( + () => + recipientAddress && + dydxAddress !== recipientAddress && + validation.isValidAddress(recipientAddress) && + !sanctionedAddresses.has(recipientAddress), + [recipientAddress, sanctionedAddresses, dydxAddress] + ); + + const isAmountValid = balance && amount && amountBN.gt(0) && newBalanceBN.gte(0); + + const { screenAddresses } = useDydxClient(); + const onTransfer = async () => { if (!isAmountValid || !isAddressValid || !fee) return; setIsLoading(true); @@ -136,20 +141,51 @@ export const TransferForm = ({ asset === DydxChainAsset.DYDX ? amountBN.minus(fee) : amountBN ).toNumber(); - const txResponse = await transfer( - amountToTransfer, - recipientAddress as string, - DYDX_CHAIN_ASSET_COIN_DENOM[asset] - ); + const screenResults = await screenAddresses({ + addresses: [recipientAddress!, dydxAddress!], + }); - if (txResponse?.code === 0) { - console.log('TransferForm > txReceipt > ', txResponse?.hash); - onDone?.(); + if (screenResults?.[dydxAddress!]) { + setError( + stringGetter({ + key: STRING_KEYS.WALLET_RESTRICTED_WITHDRAWAL_TRANSFER_ORIGINATION_ERROR_MESSAGE, + }) + ); + } else if (screenResults?.[recipientAddress!]) { + setError( + stringGetter({ + key: STRING_KEYS.WALLET_RESTRICTED_WITHDRAWAL_TRANSFER_DESTINATION_ERROR_MESSAGE, + }) + ); } else { - throw new Error(txResponse?.rawLog ?? 'Transaction did not commit.'); + const txResponse = await transfer( + amountToTransfer, + recipientAddress as string, + DYDX_CHAIN_ASSET_COIN_DENOM[asset] + ); + + if (txResponse?.code === 0) { + console.log('TransferForm > txReceipt > ', txResponse?.hash); + onDone?.(); + } else { + throw new Error(txResponse?.rawLog ?? 'Transaction did not commit.'); + } } } catch (error) { - setError(error); + if (error?.code === 429) { + setError(stringGetter({ key: STRING_KEYS.RATE_LIMIT_REACHED_ERROR_MESSAGE })); + } else { + setError( + error.message + ? stringGetter({ + key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE, + params: { + ERROR_MESSAGE: error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }), + }, + }) + : stringGetter({ key: STRING_KEYS.SOMETHING_WENT_WRONG }) + ); + } log('TransferForm/onTransfer', error); } finally { setIsLoading(false); @@ -231,13 +267,6 @@ export const TransferForm = ({ }, ]; - const addressValidationErrorMessage = stringGetter({ - key: - dydxAddress === recipientAddress - ? STRING_KEYS.TRANSFER_TO_YOURSELF - : STRING_KEYS.TRANSFER_INVALID_DYDX_ADDRESS, - }); - const renderFormInputButton = ({ label, isInputEmpty, @@ -302,7 +331,12 @@ export const TransferForm = ({ {recipientAddress && !isAddressValid && ( - {addressValidationErrorMessage} + {stringGetter({ + key: + dydxAddress === recipientAddress + ? STRING_KEYS.TRANSFER_TO_YOURSELF + : STRING_KEYS.TRANSFER_INVALID_DYDX_ADDRESS, + })} )} @@ -342,13 +376,13 @@ export const TransferForm = ({ )} - {error && {error.message}} + {error && {error}}