New Market Widget (#234)
* 🚧 New Market Form * use dev-5 as default * Additional UI work * Add mock data * 💄 More UI items * 💄 add preview step * 💄 Disable proposal button if not enough native tokens * ✏️ Add disclaimer * ✏️ fix combobox search * 🚧 clean up components * Add filters, modify, button * ✨ feat: Add details to New Market Dialog * add assetName * add helper method - spagetti code * Update NewMarketMessageDetailsDialog, attempt to hook up client call * 🚨 fix mobile safari overflow * update init deposit to 10_001 whole token * reduce delay block to 5 * Update mock data * 🚧 SO FRIGGIN CLOSE * 💄 style/ux nits * add gov to registry * PLS * IT FUCKING WORKS * Add assets * FIX TICKER * ADD NEW ASSETICON * button width * change default env to dev * Remove mention of Impersonation dialog * Market Search entry point * uncomment feature * Clean up NewMarketStep components * Restore env.json * Add space T.T * useGlobalCommands fix types * 🚧 feat: useNextClobPairId hook WIP * Add potentialMarkets hook to parse CSV and hide new market entrypoints * Use updated stringKeys * Update localization, import nits * bump v4-client * add gov vars * new useGovernanceVariables * Add validator client calls: proposal fetch/submission * Update token usage, utilize gov vars * remove console log * import nits * NewMarketMessageDetailsDialog: Fix initial_deposit_amount * NewMarketAgreement Dialog * confirm flow * Remove initialDepositAmount from mainnet env * NewMarket: Add stringParams to step3 * Update csv * update env.json add localization changes * cleanup initialDepositAmountBN and decimals * ^ * use undefined in place of 0 for DiffOutput * remove hardcoded string * Remove potentialMarket from csv * Ensure user is out of liquidity tier modification * bump localization, add additional details to receipts * feedback addressed * Add margin instead of space * margin/padding nits, shorten filter method, remove ?. chaining * additional feedback --------- Co-authored-by: Taehoon Lee <19664986+ttl33@users.noreply.github.com>
This commit is contained in:
+8
-2
@@ -5,7 +5,7 @@ import { WagmiConfig } from 'wagmi';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { GrazProvider } from 'graz';
|
||||
|
||||
import { AppRoute, DEFAULT_TRADE_ROUTE } from '@/constants/routes';
|
||||
import { AppRoute, DEFAULT_TRADE_ROUTE, MarketsRoute } from '@/constants/routes';
|
||||
|
||||
import {
|
||||
useBreakpoints,
|
||||
@@ -21,6 +21,7 @@ import { DialogAreaProvider, useDialogArea } from '@/hooks/useDialogArea';
|
||||
import { LocaleProvider } from '@/hooks/useLocaleSeparators';
|
||||
import { NotificationsProvider } from '@/hooks/useNotifications';
|
||||
import { LocalNotificationsProvider } from '@/hooks/useLocalNotifications';
|
||||
import { PotentialMarketsProvider } from '@/hooks/usePotentialMarkets';
|
||||
import { RestrictionProvider } from '@/hooks/useRestrictions';
|
||||
import { SubaccountProvider } from '@/hooks/useSubaccount';
|
||||
|
||||
@@ -44,6 +45,7 @@ import '@/styles/constants.css';
|
||||
import '@/styles/fonts.css';
|
||||
import '@/styles/web3modal.css';
|
||||
|
||||
const NewMarket = lazy(() => import('@/pages/markets/NewMarket'));
|
||||
const MarketsPage = lazy(() => import('@/pages/markets/Markets'));
|
||||
const PortfolioPage = lazy(() => import('@/pages/portfolio/Portfolio'));
|
||||
const AlertsPage = lazy(() => import('@/pages/AlertsPage'));
|
||||
@@ -81,7 +83,10 @@ const Content = () => {
|
||||
<Route path={AppRoute.Trade} element={<TradePage />} />
|
||||
</Route>
|
||||
|
||||
<Route path={AppRoute.Markets} element={<MarketsPage />} />
|
||||
<Route path={AppRoute.Markets}>
|
||||
<Route path={MarketsRoute.New} element={<NewMarket />} />
|
||||
<Route path={AppRoute.Markets} element={<MarketsPage />} />
|
||||
</Route>
|
||||
<Route path={`/${chainTokenLabel}`} element={<RewardsPage />} />
|
||||
{isTablet && (
|
||||
<>
|
||||
@@ -136,6 +141,7 @@ const providers = [
|
||||
wrapProvider(LocalNotificationsProvider),
|
||||
wrapProvider(NotificationsProvider),
|
||||
wrapProvider(DialogAreaProvider),
|
||||
wrapProvider(PotentialMarketsProvider),
|
||||
wrapProvider(AppThemeProvider),
|
||||
];
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const assetIcons = {
|
||||
AVAX: '/currencies/avax.png',
|
||||
BCH: '/currencies/bch.png',
|
||||
BLUR: '/currencies/blur.png',
|
||||
BONK: '/currencies/bonk.png',
|
||||
BTC: '/currencies/btc.png',
|
||||
CELO: '/currencies/celo.png',
|
||||
COMP: '/currencies/comp.png',
|
||||
|
||||
@@ -51,8 +51,9 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
|
||||
// value={highlightedCommand}
|
||||
// onValueChange={setHighlightedCommand}
|
||||
filter={(value: string, search: string) => {
|
||||
if (value.replace(/ /g, '').includes(search.replace(/ /g, ''))) return 1;
|
||||
return 0;
|
||||
value.replace(/ /g, '').toLowerCase().includes(search.replace(/ /g, '').toLowerCase())
|
||||
? 1
|
||||
: 0;
|
||||
}}
|
||||
className={className}
|
||||
$withStickyLayout={withStickyLayout}
|
||||
|
||||
@@ -32,7 +32,7 @@ export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
|
||||
isValidationAttached={validationConfig?.attached}
|
||||
>
|
||||
<Styled.InputContainer hasSlotRight={!!slotRight}>
|
||||
<Styled.WithLabel label={label} inputID={id}>
|
||||
<Styled.WithLabel label={label} inputID={id} disabled={otherProps?.disabled}>
|
||||
<Input ref={ref} id={id} {...otherProps} />
|
||||
</Styled.WithLabel>
|
||||
{slotRight}
|
||||
@@ -85,11 +85,11 @@ Styled.InputContainer = styled.div<{ hasSlotRight?: boolean }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
Styled.WithLabel = styled(WithLabel)`
|
||||
Styled.WithLabel = styled(WithLabel)<{ disabled?: boolean }>`
|
||||
${formMixins.inputLabel}
|
||||
|
||||
label {
|
||||
cursor: text;
|
||||
${({ disabled }) => !disabled && 'cursor: text;'}
|
||||
padding: var(--form-input-paddingY) var(--form-input-paddingX) 0;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -11,6 +11,7 @@ export const LoadingSpinner: React.FC<{
|
||||
return (
|
||||
<Styled.Spinner className={className}>
|
||||
<Styled.LoadingSpinnerSvg
|
||||
id={id}
|
||||
width="38"
|
||||
height="38"
|
||||
viewBox="0 0 38 38"
|
||||
|
||||
@@ -116,6 +116,7 @@ export const Output = ({
|
||||
{value?.toString() ?? null}
|
||||
|
||||
{tag && <Tag>{tag}</Tag>}
|
||||
{slotRight}
|
||||
</Styled.Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,5 +40,5 @@ Styled.Details = styled(Details)`
|
||||
|
||||
padding: 0.375rem 0.75rem 0.25rem;
|
||||
|
||||
font-size: 0.8125em;
|
||||
font-size: var(--details-item-fontSize, 0.8125em);
|
||||
`;
|
||||
|
||||
@@ -19,6 +19,8 @@ export enum DialogTypes {
|
||||
Transfer = 'Transfer',
|
||||
Withdraw = 'Withdraw',
|
||||
ManageFunds = 'ManageFunds',
|
||||
NewMarketMessageDetails = 'NewMarketMessageDetails',
|
||||
NewMarketAgreement = 'NewMarketAgreement',
|
||||
}
|
||||
|
||||
export enum TradeBoxDialogTypes {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Temporary Indexer types
|
||||
* remove when Indexer type lib is available through @dydxprotocol/v4-client-js
|
||||
*/
|
||||
export type PerpetualMarketResponse = {
|
||||
clobPairId: string;
|
||||
ticker: string;
|
||||
status: string;
|
||||
oraclePrice: string;
|
||||
priceChange24H: string;
|
||||
volume24H: string;
|
||||
trades24H: number;
|
||||
nextFundingRate: string;
|
||||
initialMarginFraction: string;
|
||||
maintenanceMarginFraction: string;
|
||||
openInterest: string;
|
||||
atomicResolution: number;
|
||||
quantumConversionExponent: number;
|
||||
tickSize: string;
|
||||
stepSize: string;
|
||||
stepBaseQuantums: number;
|
||||
subticksPerTick: number;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
export type ExchangeConfigParsedCsv = Array<{
|
||||
base_asset: string;
|
||||
exchange: string;
|
||||
pair: string;
|
||||
|
||||
adjust_by_market: string;
|
||||
min_2_depth: string;
|
||||
avg_30d_vol: string;
|
||||
reference_price: string;
|
||||
risk_assessment: string;
|
||||
num_oracles: string;
|
||||
liquidity_tier: string;
|
||||
asset_name: string;
|
||||
}>;
|
||||
|
||||
export type ExchangeConfigItem = {
|
||||
exchangeName: string;
|
||||
ticker: string;
|
||||
adjustByMarket?: string;
|
||||
};
|
||||
|
||||
export type PotentialMarketParsedCsv = Array<{
|
||||
base_asset: string;
|
||||
reference_price: string;
|
||||
num_oracles: string;
|
||||
liquidity_tier: string;
|
||||
asset_name: string;
|
||||
p: string;
|
||||
atomic_resolution: string;
|
||||
min_exchanges: string;
|
||||
min_price_change_ppm: string;
|
||||
price_exponent: string;
|
||||
step_base_quantum: string;
|
||||
ticksize_exponent: string;
|
||||
subticks_per_tick: string;
|
||||
min_order_size: string;
|
||||
quantum_conversion_exponent: string;
|
||||
}>;
|
||||
|
||||
export type PotentialMarketItem = {
|
||||
baseAsset: string;
|
||||
referencePrice: string;
|
||||
numOracles: number;
|
||||
liquidityTier: number;
|
||||
assetName: string;
|
||||
p: number;
|
||||
atomicResolution: number;
|
||||
minExchanges: number;
|
||||
minPriceChangePpm: number;
|
||||
priceExponent: number;
|
||||
stepBaseQuantum: number;
|
||||
ticksizeExponent: number;
|
||||
subticksPerTick: number;
|
||||
minOrderSize: number;
|
||||
quantumConversionExponent: number;
|
||||
};
|
||||
|
||||
export const NUM_ORACLES_TO_QUALIFY_AS_SAFE = 6;
|
||||
|
||||
export const LIQUIDITY_TIERS = {
|
||||
0: {
|
||||
label: 'Large-cap',
|
||||
initialMarginFraction: 0.05,
|
||||
maintenanceMarginFraction: 0.03,
|
||||
impactNotional: 10_000,
|
||||
},
|
||||
1: {
|
||||
label: 'Mid-cap',
|
||||
initialMarginFraction: 0.1,
|
||||
maintenanceMarginFraction: 0.05,
|
||||
impactNotional: 5_000,
|
||||
},
|
||||
2: {
|
||||
label: 'Long-tail',
|
||||
initialMarginFraction: 0.2,
|
||||
maintenanceMarginFraction: 0.1,
|
||||
impactNotional: 2_500,
|
||||
},
|
||||
3: {
|
||||
label: 'Safety',
|
||||
initialMarginFraction: 1,
|
||||
maintenanceMarginFraction: 0.2,
|
||||
impactNotional: 2_500,
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,10 @@ export enum AppRoute {
|
||||
Privacy = '/privacy',
|
||||
}
|
||||
|
||||
export enum MarketsRoute {
|
||||
New = 'new',
|
||||
}
|
||||
|
||||
export enum PortfolioRoute {
|
||||
Fees = 'fees',
|
||||
History = 'history',
|
||||
|
||||
@@ -57,10 +57,9 @@ export const tradeTooltips: TooltipStrings = {
|
||||
title: stringGetter({ key: TOOLTIP_STRING_KEYS.INDEX_PRICE_TITLE }),
|
||||
body: stringGetter({ key: TOOLTIP_STRING_KEYS.INDEX_PRICE_BODY }),
|
||||
}),
|
||||
'initial-margin-fraction': ({ stringGetter, urlConfigs }) => ({
|
||||
'initial-margin-fraction': ({ stringGetter }) => ({
|
||||
title: stringGetter({ key: TOOLTIP_STRING_KEYS.INITIAL_MARGIN_FRACTION_TITLE }),
|
||||
body: stringGetter({ key: TOOLTIP_STRING_KEYS.INITIAL_MARGIN_FRACTION_BODY }),
|
||||
learnMoreLink: urlConfigs?.initialMarginFractionLearnMore,
|
||||
}),
|
||||
'initial-stop': ({ stringGetter }) => ({
|
||||
title: stringGetter({ key: TOOLTIP_STRING_KEYS.INITIAL_STOP_TITLE }),
|
||||
@@ -163,6 +162,10 @@ export const tradeTooltips: TooltipStrings = {
|
||||
title: stringGetter({ key: TOOLTIP_STRING_KEYS.REDUCE_ONLY_TIMEINFORCE_IOC_FOK_TITLE }),
|
||||
body: stringGetter({ key: TOOLTIP_STRING_KEYS.REDUCE_ONLY_TIMEINFORCE_IOC_FOK_BODY }),
|
||||
}),
|
||||
'reference-price': ({ stringGetter }) => ({
|
||||
title: stringGetter({ key: TOOLTIP_STRING_KEYS.REFERENCE_PRICE_TITLE }),
|
||||
body: stringGetter({ key: TOOLTIP_STRING_KEYS.REFERENCE_PRICE_BODY }),
|
||||
}),
|
||||
spread: () => ({
|
||||
title: 'Spread',
|
||||
body: 'The difference in price between the highest bid (the price a buyer is willing to buy for) and lowest ask (the price a seller is willing to sell for) an asset.',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useDebounce } from './useDebounce';
|
||||
import { useInterval } from './useInterval';
|
||||
import { useDocumentTitle } from './useDocumentTitle';
|
||||
import { useDydxClient } from './useDydxClient';
|
||||
import { useGovernanceVariables } from './useGovernanceVariables';
|
||||
import { useAccountBalance } from './useAccountBalance';
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
@@ -14,6 +15,7 @@ import { useInitializePage } from './useInitializePage';
|
||||
import { useIsFirstRender } from './useIsFirstRender';
|
||||
import { useLocaleSeparators } from './useLocaleSeparators';
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
import { useNextClobPairId } from './useNextClobPairId';
|
||||
import { useNow } from './useNow';
|
||||
import { useOnClickOutside } from './useOnClickOutside';
|
||||
import { usePageTitlePriceUpdates } from './usePageTitlePriceUpdates';
|
||||
@@ -34,6 +36,7 @@ export {
|
||||
useDebounce,
|
||||
useDocumentTitle,
|
||||
useDydxClient,
|
||||
useGovernanceVariables,
|
||||
useAccountBalance,
|
||||
useAccounts,
|
||||
useAnalytics,
|
||||
@@ -42,6 +45,7 @@ export {
|
||||
useIsFirstRender,
|
||||
useLocaleSeparators,
|
||||
useLocalStorage,
|
||||
useNextClobPairId,
|
||||
useNow,
|
||||
useOnClickOutside,
|
||||
usePageTitlePriceUpdates,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
BECH32_PREFIX,
|
||||
CompositeClient,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
onboarding,
|
||||
Network,
|
||||
ValidatorConfig,
|
||||
type ProposalStatus,
|
||||
} from '@dydxprotocol/v4-client-js';
|
||||
|
||||
import type { ResolutionString } from 'public/tradingview/charting_library';
|
||||
@@ -68,17 +70,21 @@ const useDydxClientContext = () => {
|
||||
new Network(
|
||||
selectedNetwork,
|
||||
new IndexerConfig(networkConfig.indexerUrl, networkConfig.websocketUrl),
|
||||
new ValidatorConfig(networkConfig.validatorUrl, networkConfig.chainId,
|
||||
new ValidatorConfig(
|
||||
networkConfig.validatorUrl,
|
||||
networkConfig.chainId,
|
||||
{
|
||||
USDC_DENOM: tokensConfigs[DydxChainAsset.USDC].denom,
|
||||
USDC_DECIMALS: tokensConfigs[DydxChainAsset.USDC].decimals,
|
||||
USDC_GAS_DENOM: tokensConfigs[DydxChainAsset.USDC].gasDenom,
|
||||
CHAINTOKEN_DENOM: tokensConfigs[DydxChainAsset.CHAINTOKEN].denom,
|
||||
CHAINTOKEN_DECIMALS: tokensConfigs[DydxChainAsset.CHAINTOKEN].decimals,
|
||||
}, {
|
||||
broadcastPollIntervalMs: 3_000,
|
||||
broadcastTimeoutMs: 60_000,
|
||||
})
|
||||
},
|
||||
{
|
||||
broadcastPollIntervalMs: 3_000,
|
||||
broadcastTimeoutMs: 60_000,
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
setCompositeClient(initializedClient);
|
||||
@@ -111,6 +117,36 @@ const useDydxClientContext = () => {
|
||||
};
|
||||
|
||||
// ------ Public Methods ------ //
|
||||
const requestAllPerpetualMarkets = useCallback(async () => {
|
||||
try {
|
||||
const { markets } =
|
||||
(await compositeClient?.indexerClient.markets.getPerpetualMarkets()) || {};
|
||||
return markets || [];
|
||||
} catch (error) {
|
||||
log('useDydxClient/getPerpetualMarkets', error);
|
||||
return [];
|
||||
}
|
||||
}, [compositeClient]);
|
||||
|
||||
/**
|
||||
* @param proposalStatus - Optional filter for proposal status. If not provided, all proposals in ProposalStatus.VotingPeriod will be returned.
|
||||
*/
|
||||
const requestAllGovernanceProposals = useCallback(
|
||||
async (proposalStatus?: ProposalStatus) => {
|
||||
try {
|
||||
const allGovProposals = await compositeClient?.validatorClient.get.getAllGovProposals(
|
||||
proposalStatus
|
||||
);
|
||||
|
||||
return allGovProposals;
|
||||
} catch (error) {
|
||||
log('useDydxClient/getProposals', error);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[compositeClient]
|
||||
);
|
||||
|
||||
const requestCandles = useCallback(
|
||||
async ({
|
||||
marketId,
|
||||
@@ -225,6 +261,8 @@ const useDydxClientContext = () => {
|
||||
getWalletFromEvmSignature,
|
||||
|
||||
// Public Methods
|
||||
requestAllPerpetualMarkets,
|
||||
requestAllGovernanceProposals,
|
||||
getCandlesForDatafeed,
|
||||
screenAddresses,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
|
||||
import { useSelectedNetwork } from '@/hooks';
|
||||
|
||||
export interface GovernanceVariables {
|
||||
newMarketProposal: {
|
||||
initialDepositAmount: number;
|
||||
delayBlocks: number;
|
||||
newMarketsMethodology: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const useGovernanceVariables = (): GovernanceVariables => {
|
||||
const { selectedNetwork } = useSelectedNetwork();
|
||||
const governanceVars = ENVIRONMENT_CONFIG_MAP[selectedNetwork].governance as GovernanceVariables;
|
||||
return governanceVars;
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
|
||||
import {
|
||||
MsgCreateClobPair,
|
||||
MsgCreateOracleMarket,
|
||||
MsgCreatePerpetual,
|
||||
MsgDelayMessage,
|
||||
MsgUpdateClobPair,
|
||||
TYPE_URL_MSG_CREATE_CLOB_PAIR,
|
||||
TYPE_URL_MSG_CREATE_ORACLE_MARKET,
|
||||
TYPE_URL_MSG_CREATE_PERPETUAL,
|
||||
TYPE_URL_MSG_DELAY_MESSAGE,
|
||||
TYPE_URL_MSG_UPDATE_CLOB_PAIR,
|
||||
} from '@dydxprotocol/v4-client-js';
|
||||
|
||||
import type { PerpetualMarketResponse } from '@/constants/indexer';
|
||||
import { useDydxClient } from '@/hooks/useDydxClient';
|
||||
|
||||
export const useNextClobPairId = () => {
|
||||
const { isConnected, requestAllPerpetualMarkets, requestAllGovernanceProposals } =
|
||||
useDydxClient();
|
||||
|
||||
const { data: perpetualMarkets, status: perpetualMarketsStatus } = useQuery({
|
||||
enabled: isConnected,
|
||||
queryKey: 'requestAllPerpetualMarkets',
|
||||
queryFn: requestAllPerpetualMarkets,
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: allGovProposals, status: allGovProposalsStatus } = useQuery({
|
||||
enabled: isConnected,
|
||||
queryKey: 'requestAllActiveGovernanceProposals',
|
||||
queryFn: () => requestAllGovernanceProposals(),
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
/**
|
||||
*
|
||||
* @param message from proposal. Each message is wrapped in a type any (on purpose).
|
||||
* @param callback method used to compile all clobPairIds, perpetualIds, marketIds, etc.
|
||||
*/
|
||||
const decodeMsgForClobPairId = (message: any, callback: (id?: number) => void): any => {
|
||||
const { typeUrl, value } = message;
|
||||
|
||||
switch (typeUrl) {
|
||||
case TYPE_URL_MSG_CREATE_ORACLE_MARKET: {
|
||||
const decodedValue = MsgCreateOracleMarket.decode(value);
|
||||
callback(decodedValue.params?.id);
|
||||
break;
|
||||
}
|
||||
case TYPE_URL_MSG_CREATE_PERPETUAL: {
|
||||
const decodedValue = MsgCreatePerpetual.decode(value);
|
||||
callback(decodedValue.params?.id);
|
||||
callback(decodedValue.params?.marketId);
|
||||
break;
|
||||
}
|
||||
case TYPE_URL_MSG_CREATE_CLOB_PAIR: {
|
||||
const decodedValue = MsgCreateClobPair.decode(value);
|
||||
callback(decodedValue.clobPair?.id);
|
||||
callback(decodedValue.clobPair?.perpetualClobMetadata?.perpetualId);
|
||||
break;
|
||||
}
|
||||
case TYPE_URL_MSG_UPDATE_CLOB_PAIR: {
|
||||
const decodedValue = MsgUpdateClobPair.decode(value);
|
||||
callback(decodedValue.clobPair?.id);
|
||||
callback(decodedValue.clobPair?.perpetualClobMetadata?.perpetualId);
|
||||
break;
|
||||
}
|
||||
case TYPE_URL_MSG_DELAY_MESSAGE: {
|
||||
const decodedValue = MsgDelayMessage.decode(value);
|
||||
decodeMsgForClobPairId(decodedValue.msg, callback);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const nextAvailableClobPairId = useMemo(() => {
|
||||
const idsFromProposals: number[] = [];
|
||||
|
||||
if (allGovProposals && Object.values(allGovProposals.proposals).length > 0) {
|
||||
const proposals = allGovProposals.proposals;
|
||||
proposals.forEach((proposal) => {
|
||||
if (proposal.messages) {
|
||||
proposal.messages.map((message) => {
|
||||
decodeMsgForClobPairId(message, (id?: number) => {
|
||||
if (id) {
|
||||
idsFromProposals.push(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (perpetualMarkets && Object.values(perpetualMarkets).length > 0) {
|
||||
const clobPairIds = Object.values(perpetualMarkets)?.map((perpetualMarket) =>
|
||||
Number((perpetualMarket as PerpetualMarketResponse).clobPairId)
|
||||
);
|
||||
|
||||
const nextAvailableClobPairId = Math.max(...[...clobPairIds, ...idsFromProposals]) + 1;
|
||||
return nextAvailableClobPairId;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [perpetualMarkets, allGovProposals]);
|
||||
|
||||
return {
|
||||
allGovProposalsStatus,
|
||||
perpetualMarketsStatus,
|
||||
nextAvailableClobPairId,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
ExchangeConfigItem,
|
||||
ExchangeConfigParsedCsv,
|
||||
PotentialMarketItem,
|
||||
PotentialMarketParsedCsv,
|
||||
} from '@/constants/potentialMarkets';
|
||||
|
||||
import csvToArray from '@/lib/csvToArray';
|
||||
import { log } from '@/lib/telemetry';
|
||||
|
||||
const PotentialMarketsContext = createContext<ReturnType<typeof usePotentialMarketsContext>>({
|
||||
potentialMarkets: undefined,
|
||||
exchangeConfigs: undefined,
|
||||
hasPotentialMarketsData: false,
|
||||
});
|
||||
|
||||
PotentialMarketsContext.displayName = 'PotentialMarkets';
|
||||
|
||||
export const PotentialMarketsProvider = ({ ...props }) => (
|
||||
<PotentialMarketsContext.Provider value={usePotentialMarketsContext()} {...props} />
|
||||
);
|
||||
|
||||
export const usePotentialMarkets = () => useContext(PotentialMarketsContext);
|
||||
|
||||
const EXCHANGE_CONFIG_FILE_PATH = '/configs/potentialMarketExchangeConfig.csv';
|
||||
const POTENTIAL_MARKETS_FILE_PATH = '/configs/potentialMarketParameters.csv';
|
||||
|
||||
export const usePotentialMarketsContext = () => {
|
||||
const [potentialMarkets, setPotentialMarkets] = useState<PotentialMarketItem[]>();
|
||||
const [exchangeConfigs, setExchangeConfigs] = useState<Record<string, ExchangeConfigItem[]>>();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
fetch(POTENTIAL_MARKETS_FILE_PATH)
|
||||
.then((response) => response.text())
|
||||
.then((data) => {
|
||||
const parsedData = csvToArray<PotentialMarketParsedCsv>({
|
||||
stringVal: data,
|
||||
splitter: ',',
|
||||
});
|
||||
const parsedPotentialMarkets = parsedData.map(
|
||||
({
|
||||
base_asset,
|
||||
reference_price,
|
||||
num_oracles,
|
||||
liquidity_tier,
|
||||
asset_name,
|
||||
p,
|
||||
atomic_resolution,
|
||||
min_exchanges,
|
||||
min_price_change_ppm,
|
||||
price_exponent,
|
||||
step_base_quantum,
|
||||
ticksize_exponent,
|
||||
subticks_per_tick,
|
||||
min_order_size,
|
||||
quantum_conversion_exponent,
|
||||
}) => ({
|
||||
// convert to camelCase
|
||||
baseAsset: base_asset,
|
||||
referencePrice: reference_price,
|
||||
numOracles: Number(num_oracles),
|
||||
liquidityTier: Number(liquidity_tier),
|
||||
assetName: asset_name,
|
||||
p: Number(p),
|
||||
atomicResolution: Number(atomic_resolution),
|
||||
minExchanges: Number(min_exchanges),
|
||||
minPriceChangePpm: Number(min_price_change_ppm),
|
||||
priceExponent: Number(price_exponent),
|
||||
stepBaseQuantum: Number(step_base_quantum),
|
||||
ticksizeExponent: Number(ticksize_exponent),
|
||||
subticksPerTick: Number(subticks_per_tick),
|
||||
minOrderSize: Number(min_order_size),
|
||||
quantumConversionExponent: Number(quantum_conversion_exponent),
|
||||
})
|
||||
);
|
||||
|
||||
setPotentialMarkets(parsedPotentialMarkets);
|
||||
});
|
||||
} catch (error) {
|
||||
log('usePotentialMarkets/potentialMarkets', error);
|
||||
setPotentialMarkets(undefined);
|
||||
}
|
||||
|
||||
try {
|
||||
fetch(EXCHANGE_CONFIG_FILE_PATH)
|
||||
.then((response) => response.text())
|
||||
.then((data) => {
|
||||
const parsedData = csvToArray<ExchangeConfigParsedCsv>({
|
||||
stringVal: data,
|
||||
splitter: ',',
|
||||
});
|
||||
// create an object with the base_asset as the key and the value as an array of exchanges
|
||||
const exchangeConfigMap = parsedData.reduce(
|
||||
(acc: Record<string, ExchangeConfigItem[]>, curr) => {
|
||||
const { base_asset, exchange, pair, adjust_by_market } = curr;
|
||||
if (!acc[base_asset]) {
|
||||
acc[base_asset] = [];
|
||||
}
|
||||
|
||||
const exchangeItem: {
|
||||
exchangeName: string;
|
||||
ticker: string;
|
||||
adjustByMarket?: string;
|
||||
} = {
|
||||
exchangeName: exchange,
|
||||
ticker: pair,
|
||||
};
|
||||
|
||||
if (adjust_by_market) {
|
||||
exchangeItem.adjustByMarket = adjust_by_market;
|
||||
}
|
||||
|
||||
acc[base_asset].push(exchangeItem);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
setExchangeConfigs(exchangeConfigMap);
|
||||
});
|
||||
} catch (error) {
|
||||
log('usePotentialMarkets/exchangeConfigs', error);
|
||||
setExchangeConfigs(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
potentialMarkets,
|
||||
exchangeConfigs,
|
||||
hasPotentialMarketsData: Boolean(potentialMarkets && exchangeConfigs),
|
||||
};
|
||||
};
|
||||
+43
-12
@@ -2,11 +2,16 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
|
||||
import { shallowEqual, useSelector, useDispatch } from 'react-redux';
|
||||
import type { Nullable } from '@dydxprotocol/v4-abacus';
|
||||
import Long from 'long';
|
||||
import type { IndexedTx } from '@cosmjs/stargate';
|
||||
import { type IndexedTx } from '@cosmjs/stargate';
|
||||
import type { EncodeObject } from '@cosmjs/proto-signing';
|
||||
import { Method } from '@cosmjs/tendermint-rpc';
|
||||
|
||||
import { type LocalWallet, SubaccountClient } from '@dydxprotocol/v4-client-js';
|
||||
import {
|
||||
type LocalWallet,
|
||||
SubaccountClient,
|
||||
type GovAddNewMarketParams,
|
||||
utils,
|
||||
} from '@dydxprotocol/v4-client-js';
|
||||
|
||||
import type {
|
||||
AccountBalance,
|
||||
@@ -16,7 +21,6 @@ import type {
|
||||
} from '@/constants/abacus';
|
||||
|
||||
import { AMOUNT_RESERVED_FOR_GAS_USDC } from '@/constants/account';
|
||||
import { AnalyticsEvent } from '@/constants/analytics';
|
||||
import { QUANTUM_MULTIPLIER } from '@/constants/numbers';
|
||||
import { DydxAddress } from '@/constants/wallets';
|
||||
|
||||
@@ -24,14 +28,13 @@ import { setSubaccount, setHistoricalPnl, removeUncommittedOrderClientId } from
|
||||
import { getBalances } from '@/state/accountSelectors';
|
||||
|
||||
import abacusStateManager from '@/lib/abacus';
|
||||
import { track } from '@/lib/analytics';
|
||||
import { MustBigNumber } from '@/lib/numbers';
|
||||
import { hashFromTx } from '@/lib/hashfromTx';
|
||||
import { log } from '@/lib/telemetry';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { useTokenConfigs } from './useTokenConfigs';
|
||||
import { useDydxClient } from './useDydxClient';
|
||||
import { hashFromTx } from '@/lib/hashfromTx';
|
||||
import { useGovernanceVariables } from './useGovernanceVariables';
|
||||
|
||||
type SubaccountContextType = ReturnType<typeof useSubaccountContext>;
|
||||
const SubaccountContext = createContext<SubaccountContextType>({} as SubaccountContextType);
|
||||
@@ -201,8 +204,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
|
||||
value: {
|
||||
...transaction.msg,
|
||||
timeoutTimestamp: transaction.msg.timeoutTimestamp
|
||||
// Squid returns timeoutTimestamp as Long, but the signer expects BigInt
|
||||
? BigInt(Long.fromValue(transaction.msg.timeoutTimestamp).toString())
|
||||
? // Squid returns timeoutTimestamp as Long, but the signer expects BigInt
|
||||
BigInt(Long.fromValue(transaction.msg.timeoutTimestamp).toString())
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
@@ -301,9 +304,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
|
||||
|
||||
const sendSquidWithdraw = useCallback(
|
||||
async (amount: number, payload: string, isCctp?: boolean) => {
|
||||
|
||||
const cctpWithdraw = () => {
|
||||
return new Promise<string>((resolve, reject) =>
|
||||
return new Promise<string>((resolve, reject) =>
|
||||
abacusStateManager.cctpWithdraw((success, error, data) => {
|
||||
const parsedData = JSON.parse(data);
|
||||
if (success && parsedData?.code == 0) {
|
||||
@@ -312,8 +314,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
|
||||
reject(error);
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
if (isCctp) {
|
||||
return await cctpWithdraw();
|
||||
}
|
||||
@@ -413,6 +415,32 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
|
||||
[subaccountClient]
|
||||
);
|
||||
|
||||
const { newMarketProposal } = useGovernanceVariables();
|
||||
|
||||
// ------ Governance Methods ------ //
|
||||
const submitNewMarketProposal = useCallback(
|
||||
async (params: GovAddNewMarketParams) => {
|
||||
if (!compositeClient) {
|
||||
throw new Error('client not initialized');
|
||||
} else if (!localDydxWallet) {
|
||||
throw new Error('wallet not initialized');
|
||||
} else if (!newMarketProposal) {
|
||||
throw new Error('governance variables not initialized');
|
||||
}
|
||||
|
||||
const response = await compositeClient.submitGovAddNewMarketProposal(
|
||||
localDydxWallet,
|
||||
params,
|
||||
utils.getGovAddNewMarketTitle(params.ticker),
|
||||
utils.getGovAddNewMarketSummary(params.ticker, newMarketProposal.delayBlocks),
|
||||
newMarketProposal.initialDepositAmount
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
[compositeClient, localDydxWallet]
|
||||
);
|
||||
|
||||
return {
|
||||
// Deposit/Withdraw/Faucet Methods
|
||||
deposit,
|
||||
@@ -427,5 +455,8 @@ export const useSubaccountContext = ({ localDydxWallet }: { localDydxWallet?: Lo
|
||||
placeOrder,
|
||||
closePosition,
|
||||
cancelOrder,
|
||||
|
||||
// Governance Methods
|
||||
submitNewMarketProposal,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,6 +27,8 @@ import { ManageFundsDialog } from '@/views/dialogs/ManageFundsDialog';
|
||||
|
||||
import { OrderDetailsDialog } from '@/views/dialogs/DetailsDialog/OrderDetailsDialog';
|
||||
import { FillDetailsDialog } from '@/views/dialogs/DetailsDialog/FillDetailsDialog';
|
||||
import { NewMarketMessageDetailsDialog } from '@/views/dialogs/NewMarketMessageDetailsDialog';
|
||||
import { NewMarketAgreementDialog } from '@/views/dialogs/NewMarketAgreementDialog';
|
||||
|
||||
export const DialogManager = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -67,5 +69,7 @@ export const DialogManager = () => {
|
||||
[DialogTypes.Transfer]: <TransferDialog {...modalProps} />,
|
||||
[DialogTypes.Withdraw]: <WithdrawDialog {...modalProps} />,
|
||||
[DialogTypes.ManageFunds]: <ManageFundsDialog {...modalProps} />,
|
||||
[DialogTypes.NewMarketMessageDetails]: <NewMarketMessageDetailsDialog {...modalProps} />,
|
||||
[DialogTypes.NewMarketAgreement]: <NewMarketAgreementDialog {...modalProps} />,
|
||||
}[type];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const csvToArray = <T>({ stringVal, splitter }: { stringVal: string; splitter: string }) => {
|
||||
const [keys, ...rest] = stringVal
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((item) => item.split(splitter));
|
||||
|
||||
const formedArr = rest.map((item) => {
|
||||
const object: Record<string, string> = {};
|
||||
keys.forEach((key, index) => (object[key] = item[index]));
|
||||
return object;
|
||||
});
|
||||
|
||||
return formedArr as T;
|
||||
};
|
||||
|
||||
export default csvToArray;
|
||||
@@ -1,21 +1,25 @@
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { breakpoints } from '@/styles';
|
||||
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
|
||||
import { AppRoute, MarketsRoute } from '@/constants/routes';
|
||||
import { useBreakpoints, useDocumentTitle, useStringGetter } from '@/hooks';
|
||||
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
|
||||
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { Button } from '@/components/Button';
|
||||
import { ContentSectionHeader } from '@/components/ContentSectionHeader';
|
||||
|
||||
import { ExchangeBillboards } from '@/views/ExchangeBillboards';
|
||||
import { MarketsTable } from '@/views/tables/MarketsTable';
|
||||
|
||||
const Markets = () => {
|
||||
const stringGetter = useStringGetter();
|
||||
const { isNotTablet } = useBreakpoints();
|
||||
const navigate = useNavigate();
|
||||
const { hasPotentialMarketsData } = usePotentialMarkets();
|
||||
|
||||
useDocumentTitle(stringGetter({ key: STRING_KEYS.MARKETS }));
|
||||
|
||||
@@ -25,6 +29,13 @@ const Markets = () => {
|
||||
<Styled.ContentSectionHeader
|
||||
title={stringGetter({ key: STRING_KEYS.MARKETS })}
|
||||
subtitle={isNotTablet && stringGetter({ key: STRING_KEYS.DISCOVER_NEW_ASSETS })}
|
||||
slotRight={
|
||||
hasPotentialMarketsData && (
|
||||
<Button onClick={() => navigate(`${AppRoute.Markets}/${MarketsRoute.New}`)}>
|
||||
{stringGetter({ key: STRING_KEYS.ADD_A_MARKET })}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Styled.ExchangeBillboards isSearching={false} searchQuery="" />
|
||||
</Styled.HeaderSection>
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { isMainnet } from '@/constants/networks';
|
||||
import { AppRoute } from '@/constants/routes';
|
||||
|
||||
import {
|
||||
useBreakpoints,
|
||||
useDocumentTitle,
|
||||
useGovernanceVariables,
|
||||
useStringGetter,
|
||||
useTokenConfigs,
|
||||
} from '@/hooks';
|
||||
|
||||
import { breakpoints } from '@/styles';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { Button } from '@/components/Button';
|
||||
import { ContentSectionHeader } from '@/components/ContentSectionHeader';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { Icon, IconName } from '@/components/Icon';
|
||||
import { Link } from '@/components/Link';
|
||||
import { NewMarketForm } from '@/views/forms/NewMarketForm';
|
||||
|
||||
import { MustBigNumber } from '@/lib/numbers';
|
||||
|
||||
const StepItem = ({ step, subtitle, title }: { step: number; subtitle: string; title: string }) => (
|
||||
<Styled.StepItem>
|
||||
<Styled.StepNumber>{step}</Styled.StepNumber>
|
||||
<Styled.Column>
|
||||
<Styled.Title>{title}</Styled.Title>
|
||||
<Styled.Subtitle>{subtitle}</Styled.Subtitle>
|
||||
</Styled.Column>
|
||||
</Styled.StepItem>
|
||||
);
|
||||
|
||||
const NewMarket = () => {
|
||||
const { isNotTablet } = useBreakpoints();
|
||||
const { newMarketProposal } = useGovernanceVariables();
|
||||
const navigate = useNavigate();
|
||||
const [displaySteps, setDisplaySteps] = useState(true);
|
||||
const stringGetter = useStringGetter();
|
||||
const { chainTokenLabel, chainTokenDecimals } = useTokenConfigs();
|
||||
|
||||
useDocumentTitle(stringGetter({ key: STRING_KEYS.ADD_A_MARKET }));
|
||||
|
||||
const steps = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
step: 1,
|
||||
title: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_1_TITLE }),
|
||||
subtitle: stringGetter({
|
||||
key: STRING_KEYS.ADD_MARKET_STEP_1_DESCRIPTION,
|
||||
params: {
|
||||
HERE: (
|
||||
<Styled.Link href={newMarketProposal.newMarketsMethodology}>
|
||||
{stringGetter({ key: STRING_KEYS.HERE })}
|
||||
</Styled.Link>
|
||||
),
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_2_TITLE }),
|
||||
subtitle: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_2_DESCRIPTION }),
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: stringGetter({ key: STRING_KEYS.ADD_MARKET_STEP_3_TITLE }),
|
||||
subtitle: stringGetter({
|
||||
key: STRING_KEYS.ADD_MARKET_STEP_3_DESCRIPTION,
|
||||
params: {
|
||||
REQUIRED_NUM_TOKENS: MustBigNumber(newMarketProposal?.initialDepositAmount)
|
||||
.div(Number(`1e${chainTokenDecimals}`))
|
||||
.toFixed(isMainnet ? 0 : chainTokenDecimals),
|
||||
NATIVE_TOKEN_DENOM: chainTokenLabel,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
}, [stringGetter, newMarketProposal, chainTokenLabel]);
|
||||
|
||||
return (
|
||||
<Styled.Page>
|
||||
<Styled.HeaderSection>
|
||||
<Styled.ContentSectionHeader
|
||||
title={stringGetter({ key: STRING_KEYS.SUGGEST_NEW_MARKET })}
|
||||
slotRight={
|
||||
<IconButton iconName={IconName.Close} onClick={() => navigate(AppRoute.Markets)} />
|
||||
}
|
||||
subtitle={isNotTablet && stringGetter({ key: STRING_KEYS.ADD_DETAILS_TO_LAUNCH_MARKET })}
|
||||
/>
|
||||
</Styled.HeaderSection>
|
||||
<Styled.Content>
|
||||
<div>
|
||||
<Button
|
||||
slotLeft={<Styled.Icon iconName={displaySteps ? IconName.Hide : IconName.HelpCircle} />}
|
||||
onClick={() => setDisplaySteps(!displaySteps)}
|
||||
>
|
||||
{displaySteps
|
||||
? stringGetter({ key: STRING_KEYS.HIDE_STEPS })
|
||||
: stringGetter({ key: STRING_KEYS.SHOW_STEPS })}
|
||||
</Button>
|
||||
{displaySteps && (
|
||||
<>
|
||||
<Styled.StepsTitle>
|
||||
{stringGetter({ key: STRING_KEYS.STEPS_TO_CREATE })}
|
||||
</Styled.StepsTitle>
|
||||
{steps.map((item) => (
|
||||
<StepItem
|
||||
key={item.step}
|
||||
step={item.step}
|
||||
title={item.title}
|
||||
subtitle={item.subtitle}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Styled.FormContainer>
|
||||
<NewMarketForm />
|
||||
</Styled.FormContainer>
|
||||
</Styled.Content>
|
||||
</Styled.Page>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Page = styled.div`
|
||||
${layoutMixins.contentContainerPage}
|
||||
gap: 1.5rem;
|
||||
|
||||
> * {
|
||||
--content-max-width: 80rem;
|
||||
max-width: min(calc(100vw - 4rem), var(--content-max-width));
|
||||
}
|
||||
|
||||
@media ${breakpoints.tablet} {
|
||||
--stickyArea-topHeight: var(--page-header-height-mobile);
|
||||
padding: 0 1rem 1rem;
|
||||
|
||||
> * {
|
||||
max-width: calc(100vw - 2rem);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.ContentSectionHeader = styled(ContentSectionHeader)`
|
||||
@media ${breakpoints.notTablet} {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
@media ${breakpoints.tablet} {
|
||||
padding: 1.25rem 0;
|
||||
|
||||
h3 {
|
||||
font: var(--font-extra-medium);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.HeaderSection = styled.section`
|
||||
${layoutMixins.contentSectionDetached}
|
||||
|
||||
@media ${breakpoints.tablet} {
|
||||
${layoutMixins.flexColumn}
|
||||
gap: 1rem;
|
||||
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Content = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2rem;
|
||||
margin: 0 auto;
|
||||
|
||||
@media ${breakpoints.tablet} {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.StepsTitle = styled.h2`
|
||||
font: var(--font-large-medium);
|
||||
color: var(--color-text-2);
|
||||
margin: 1rem;
|
||||
|
||||
@media ${breakpoints.tablet} {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Icon = styled(Icon)`
|
||||
margin-right: 0.5ch;
|
||||
`;
|
||||
|
||||
Styled.StepItem = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
|
||||
Styled.StepNumber = styled.div`
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-layer-5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-2);
|
||||
`;
|
||||
|
||||
Styled.Column = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
Styled.Title = styled.span`
|
||||
color: var(--color-text-2);
|
||||
font: var(--font-medium-book);
|
||||
`;
|
||||
|
||||
Styled.Subtitle = styled.span`
|
||||
color: var(--color-text-0);
|
||||
`;
|
||||
|
||||
Styled.Link = styled(Link)`
|
||||
--link-color: var(--color-accent);
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
Styled.FormContainer = styled.div`
|
||||
min-width: 31.25rem;
|
||||
height: fit-content;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--color-layer-3);
|
||||
padding: 1rem;
|
||||
|
||||
@media ${breakpoints.tablet} {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
`;
|
||||
|
||||
export default NewMarket;
|
||||
@@ -0,0 +1,103 @@
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ButtonAction, ButtonSize } from '@/constants/buttons';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { isMainnet } from '@/constants/networks';
|
||||
import { AppRoute, MarketsRoute } from '@/constants/routes';
|
||||
|
||||
import { useStringGetter, useTokenConfigs } from '@/hooks';
|
||||
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
|
||||
import { useGovernanceVariables } from '@/hooks/useGovernanceVariables';
|
||||
|
||||
import { Panel } from '@/components/Panel';
|
||||
import { IconName } from '@/components/Icon';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { Tag } from '@/components/Tag';
|
||||
|
||||
import { MustBigNumber } from '@/lib/numbers';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
export const NewMarketsPanel = () => {
|
||||
const stringGetter = useStringGetter();
|
||||
const navigate = useNavigate();
|
||||
const { hasPotentialMarketsData } = usePotentialMarkets();
|
||||
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
|
||||
const { newMarketProposal } = useGovernanceVariables();
|
||||
const initialDepositAmountBN = MustBigNumber(newMarketProposal.initialDepositAmount).div(
|
||||
Number(`1e${chainTokenDecimals}`)
|
||||
);
|
||||
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
|
||||
|
||||
if (!hasPotentialMarketsData) return null;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
slotHeaderContent={
|
||||
<Styled.Title>
|
||||
{stringGetter({ key: STRING_KEYS.ADD_A_MARKET })}
|
||||
<Styled.NewTag>{stringGetter({ key: STRING_KEYS.NEW })}</Styled.NewTag>
|
||||
</Styled.Title>
|
||||
}
|
||||
slotRight={
|
||||
<Styled.Arrow>
|
||||
<Styled.IconButton
|
||||
action={ButtonAction.Base}
|
||||
iconName={IconName.Arrow}
|
||||
size={ButtonSize.Small}
|
||||
/>
|
||||
</Styled.Arrow>
|
||||
}
|
||||
onClick={() => navigate(`${AppRoute.Markets}/${MarketsRoute.New}`)}
|
||||
>
|
||||
<Styled.Description>
|
||||
{stringGetter({
|
||||
key: STRING_KEYS.NEW_MARKET_REWARDS_ENTRY_DESCRIPTION,
|
||||
params: {
|
||||
REQUIRED_NUM_TOKENS: (
|
||||
<Styled.Output
|
||||
useGrouping
|
||||
type={OutputType.Number}
|
||||
value={initialDepositAmountBN}
|
||||
fractionDigits={initialDepositAmountDecimals}
|
||||
/>
|
||||
),
|
||||
NATIVE_TOKEN_DENOM: chainTokenLabel,
|
||||
},
|
||||
})}
|
||||
</Styled.Description>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Description = styled.div`
|
||||
color: var(--color-text-0);
|
||||
`;
|
||||
|
||||
Styled.IconButton = styled(IconButton)`
|
||||
color: var(--color-text-0);
|
||||
--color-border: var(--color-layer-6);
|
||||
`;
|
||||
|
||||
Styled.Arrow = styled.div`
|
||||
padding-right: 1.5rem;
|
||||
`;
|
||||
|
||||
Styled.Title = styled.h3`
|
||||
font: var(--font-medium-book);
|
||||
color: var(--color-text-2);
|
||||
margin-bottom: -1rem;
|
||||
${layoutMixins.inlineRow}
|
||||
`;
|
||||
|
||||
Styled.Output = styled(Output)`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
Styled.NewTag = styled(Tag)`
|
||||
color: var(--color-accent);
|
||||
background-color: var(--color-accent-faded);
|
||||
`;
|
||||
@@ -14,11 +14,12 @@ import { BackButton } from '@/components/BackButton';
|
||||
import { Panel } from '@/components/Panel';
|
||||
|
||||
import { DYDXBalancePanel } from './DYDXBalancePanel';
|
||||
import { MigratePanel } from './MigratePanel';
|
||||
import { LaunchIncentivesPanel } from './LaunchIncentivesPanel';
|
||||
import { MigratePanel } from './MigratePanel';
|
||||
import { RewardsHelpPanel } from './RewardsHelpPanel';
|
||||
import { GovernancePanel } from './GovernancePanel';
|
||||
import { StakingPanel } from './StakingPanel';
|
||||
import { NewMarketsPanel } from './NewMarketsPanel';
|
||||
|
||||
const RewardsPage = () => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -44,9 +45,9 @@ const RewardsPage = () => {
|
||||
<DYDXBalancePanel />
|
||||
</Styled.PanelRowIncentivesAndBalance>
|
||||
)}
|
||||
|
||||
{isNotTablet && (
|
||||
<Styled.PanelRow>
|
||||
<NewMarketsPanel />
|
||||
<GovernancePanel />
|
||||
<StakingPanel />
|
||||
</Styled.PanelRow>
|
||||
|
||||
@@ -3,23 +3,26 @@ import { useNavigate } from 'react-router-dom';
|
||||
import styled, { type AnyStyledComponent, css, keyframes } from 'styled-components';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { ButtonSize } from '@/constants/buttons';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { MarketFilters, type MarketData } from '@/constants/markets';
|
||||
import { AppRoute } from '@/constants/routes';
|
||||
|
||||
import { AppRoute, MarketsRoute } from '@/constants/routes';
|
||||
import { useStringGetter } from '@/hooks';
|
||||
import { useMarketsData } from '@/hooks/useMarketsData';
|
||||
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
|
||||
|
||||
import { popoverMixins } from '@/styles/popoverMixins';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { AssetIcon } from '@/components/AssetIcon';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Icon, IconName } from '@/components/Icon';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { Popover, TriggerType } from '@/components/Popover';
|
||||
import { ColumnDef, Table } from '@/components/Table';
|
||||
import { Tag } from '@/components/Tag';
|
||||
import { Toolbar } from '@/components/Toolbar';
|
||||
import { ColumnDef, Table } from '@/components/Table';
|
||||
|
||||
import { useMarketsData } from '@/hooks/useMarketsData';
|
||||
import { getSelectedLocale } from '@/state/localizationSelectors';
|
||||
|
||||
import { MustBigNumber } from '@/lib/numbers';
|
||||
@@ -32,6 +35,8 @@ const MarketsDropdownContent = ({ onRowAction }: { onRowAction?: (market: string
|
||||
const selectedLocale = useSelector(getSelectedLocale);
|
||||
const [searchFilter, setSearchFilter] = useState<string>();
|
||||
const { filteredMarkets, marketFilters } = useMarketsData(filter, searchFilter);
|
||||
const navigate = useNavigate();
|
||||
const { hasPotentialMarketsData } = usePotentialMarkets();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -134,16 +139,16 @@ const MarketsDropdownContent = ({ onRowAction }: { onRowAction?: (market: string
|
||||
})}
|
||||
</h2>
|
||||
<p>{stringGetter({ key: STRING_KEYS.MARKET_SEARCH_DOES_NOT_EXIST_YET })}</p>
|
||||
{/* TODO TRCL-1693 - uncomment when feedback modal is finalized
|
||||
<div>
|
||||
<Button
|
||||
// TODO: uncomment when feedback modal is finalized
|
||||
// onClick={() => dispatch(openModal({ modalType: MODALS.FEEDBACK }))}
|
||||
size={ButtonSize.Small}
|
||||
>
|
||||
{stringGetter({ key: STRING_KEYS.GIVE_FEEDBACK })}
|
||||
</Button>
|
||||
</div> */}
|
||||
{hasPotentialMarketsData && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => navigate(`${AppRoute.Markets}/${MarketsRoute.New}`)}
|
||||
size={ButtonSize.Small}
|
||||
>
|
||||
{stringGetter({ key: STRING_KEYS.PROPOSE_NEW_MARKET })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Styled.MarketNotFound>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
|
||||
import { ButtonAction } from '@/constants/buttons';
|
||||
import { AppRoute } from '@/constants/routes';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { useStringGetter } from '@/hooks';
|
||||
import breakpoints from '@/styles/breakpoints';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { Button } from '@/components/Button';
|
||||
import { Checkbox } from '@/components/Checkbox';
|
||||
import { Dialog } from '@/components/Dialog';
|
||||
import { Link } from '@/components/Link';
|
||||
|
||||
type ElementProps = {
|
||||
acceptTerms: () => void;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const NewMarketAgreementDialog = ({ acceptTerms, setIsOpen }: ElementProps) => {
|
||||
const [hasAcknowledged, setHasAcknowledged] = useState(false);
|
||||
|
||||
const stringGetter = useStringGetter();
|
||||
|
||||
return (
|
||||
<Styled.Dialog
|
||||
isOpen
|
||||
setIsOpen={setIsOpen}
|
||||
title={stringGetter({ key: STRING_KEYS.ACKNOWLEDGEMENT })}
|
||||
>
|
||||
<Styled.Content>
|
||||
<p>
|
||||
{stringGetter({
|
||||
key: STRING_KEYS.NEW_MARKET_PROPOSAL_AGREEMENT,
|
||||
params: {
|
||||
DOCUMENTATION_LINK: (
|
||||
<Styled.Link href="https://docs.dydx.community/dydx-governance/voting-and-governance/governance-process">
|
||||
{stringGetter({ key: STRING_KEYS.WEBSITE }).toLowerCase()}
|
||||
</Styled.Link>
|
||||
),
|
||||
TERMS_OF_USE: (
|
||||
<Styled.Link href={`/#${AppRoute.Terms}`}>
|
||||
{stringGetter({ key: STRING_KEYS.TERMS_OF_USE })}
|
||||
</Styled.Link>
|
||||
),
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
|
||||
<Checkbox
|
||||
checked={hasAcknowledged}
|
||||
onCheckedChange={setHasAcknowledged}
|
||||
id="acknowledgement-checkbox"
|
||||
label={stringGetter({ key: STRING_KEYS.I_HAVE_READ_AND_AGREE })}
|
||||
/>
|
||||
<Styled.ButtonRow>
|
||||
<Button action={ButtonAction.Base} onClick={() => setIsOpen(false)}>
|
||||
{stringGetter({ key: STRING_KEYS.CANCEL })}
|
||||
</Button>
|
||||
<Button
|
||||
action={ButtonAction.Primary}
|
||||
onClick={() => {
|
||||
acceptTerms();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
state={{ isDisabled: !hasAcknowledged }}
|
||||
>
|
||||
{stringGetter({ key: STRING_KEYS.CONTINUE })}
|
||||
</Button>
|
||||
</Styled.ButtonRow>
|
||||
</Styled.Content>
|
||||
</Styled.Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Dialog = styled(Dialog)`
|
||||
@media ${breakpoints.notMobile} {
|
||||
--dialog-width: 30rem;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Content = styled.div`
|
||||
${layoutMixins.column}
|
||||
gap: 1rem;
|
||||
|
||||
p {
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-layer-1);
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Link = styled(Link)`
|
||||
--link-color: var(--color-accent);
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
Styled.ButtonRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 1rem;
|
||||
`;
|
||||
@@ -0,0 +1,361 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { utils } from '@dydxprotocol/v4-client-js';
|
||||
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { isMainnet } from '@/constants/networks';
|
||||
import { PotentialMarketItem } from '@/constants/potentialMarkets';
|
||||
import { useGovernanceVariables, useStringGetter, useTokenConfigs } from '@/hooks';
|
||||
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { Details } from '@/components/Details';
|
||||
import { Dialog } from '@/components/Dialog';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { Tag, TagType } from '@/components/Tag';
|
||||
import { ToggleGroup } from '@/components/ToggleGroup';
|
||||
|
||||
import { MustBigNumber } from '@/lib/numbers';
|
||||
|
||||
type ElementProps = {
|
||||
preventClose?: boolean;
|
||||
setIsOpen?: (open: boolean) => void;
|
||||
assetData?: PotentialMarketItem;
|
||||
clobPairId?: number;
|
||||
liquidityTier?: string;
|
||||
};
|
||||
|
||||
export enum CodeToggleGroup {
|
||||
CREATE_ORACLE = 'CREATE_ORACLE',
|
||||
MSG_CREATE_PERPETUAL = 'MSG_CREATE_PERPETUAL',
|
||||
MSG_CREATE_CLOB_PAIR = 'MSG_CREATE_CLOB_PAIR',
|
||||
MSG_DELAY_MESSAGE = 'MSG_DELAY_MESSAGE',
|
||||
MSG_SUBMIT_PROPOSAL = 'MSG_SUBMIT_PROPOSAL',
|
||||
}
|
||||
|
||||
export const NewMarketMessageDetailsDialog = ({
|
||||
assetData,
|
||||
clobPairId,
|
||||
liquidityTier,
|
||||
preventClose,
|
||||
setIsOpen,
|
||||
}: ElementProps) => {
|
||||
const [codeToggleGroup, setCodeToggleGroup] = useState(CodeToggleGroup.CREATE_ORACLE);
|
||||
const { exchangeConfigs } = usePotentialMarkets();
|
||||
const { baseAsset } = assetData ?? {};
|
||||
const { newMarketProposal } = useGovernanceVariables();
|
||||
const stringGetter = useStringGetter();
|
||||
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
|
||||
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
|
||||
|
||||
const exchangeConfig = useMemo(() => {
|
||||
return baseAsset ? exchangeConfigs?.[baseAsset] : undefined;
|
||||
}, [baseAsset]);
|
||||
|
||||
const ticker = useMemo(() => `${baseAsset}-USD`, [baseAsset]);
|
||||
|
||||
const toggleGroupItems: Parameters<typeof ToggleGroup>[0]['items'] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: CodeToggleGroup.CREATE_ORACLE,
|
||||
label: 'Create oracle market',
|
||||
},
|
||||
{
|
||||
value: CodeToggleGroup.MSG_CREATE_PERPETUAL,
|
||||
label: 'Msg create perpetual',
|
||||
},
|
||||
{
|
||||
value: CodeToggleGroup.MSG_CREATE_CLOB_PAIR,
|
||||
label: 'Msg create clobPair',
|
||||
},
|
||||
{
|
||||
value: CodeToggleGroup.MSG_DELAY_MESSAGE,
|
||||
label: 'Msg delay message',
|
||||
},
|
||||
{
|
||||
value: CodeToggleGroup.MSG_SUBMIT_PROPOSAL,
|
||||
label: 'Msg submit proposal',
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
preventClose={preventClose}
|
||||
setIsOpen={setIsOpen}
|
||||
title={stringGetter({ key: STRING_KEYS.MESSAGE_DETAILS })}
|
||||
>
|
||||
<Styled.ProposedMessageDetails>
|
||||
<Styled.Tabs
|
||||
items={toggleGroupItems}
|
||||
value={codeToggleGroup}
|
||||
onValueChange={setCodeToggleGroup}
|
||||
/>
|
||||
{
|
||||
{
|
||||
[CodeToggleGroup.CREATE_ORACLE]: (
|
||||
<Styled.Code>
|
||||
<Styled.Details
|
||||
layout="column"
|
||||
items={[
|
||||
{
|
||||
key: 'id',
|
||||
label: 'market_id',
|
||||
value: `${clobPairId}`,
|
||||
},
|
||||
{
|
||||
key: 'pair',
|
||||
label: 'pair',
|
||||
value: ticker,
|
||||
},
|
||||
{
|
||||
key: 'min-exchanges',
|
||||
label: 'min_exchanges',
|
||||
value: `${assetData?.minExchanges}`,
|
||||
},
|
||||
{
|
||||
key: 'min-price-change-ppm',
|
||||
label: 'min_price_change_ppm',
|
||||
value: `${assetData?.minPriceChangePpm}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Styled.Text0>
|
||||
exchange_config_json{' '}
|
||||
{exchangeConfig && <Tag type={TagType.Number}>{exchangeConfig.length}</Tag>}
|
||||
</Styled.Text0>
|
||||
{'['}
|
||||
{exchangeConfig?.map((exchange) => {
|
||||
return (
|
||||
<Styled.Code
|
||||
key={exchange.exchangeName}
|
||||
style={{ padding: 0, margin: 0, paddingLeft: '0.5rem' }}
|
||||
>
|
||||
{'{'}
|
||||
{Object.keys(exchange).map((key) => (
|
||||
<Styled.Line key={key}>
|
||||
{key}: <span>{exchange[key as keyof typeof exchange]}</span>
|
||||
</Styled.Line>
|
||||
))}
|
||||
{'},'}
|
||||
</Styled.Code>
|
||||
);
|
||||
})}
|
||||
{']'}
|
||||
</Styled.Code>
|
||||
),
|
||||
[CodeToggleGroup.MSG_CREATE_PERPETUAL]: (
|
||||
<Styled.Code>
|
||||
<Styled.Details
|
||||
layout="column"
|
||||
items={[
|
||||
{
|
||||
key: 'perpetual_id',
|
||||
label: 'perpetual_id',
|
||||
value: `${clobPairId}`,
|
||||
},
|
||||
{
|
||||
key: 'market_id',
|
||||
label: 'market_id',
|
||||
value: `${clobPairId}`,
|
||||
},
|
||||
{
|
||||
key: 'ticker',
|
||||
label: 'ticker',
|
||||
value: ticker,
|
||||
},
|
||||
{
|
||||
key: 'atomic_resolution',
|
||||
label: 'atomic_resolution',
|
||||
value: `${assetData?.atomicResolution}`,
|
||||
},
|
||||
{
|
||||
key: 'default_funding_ppm',
|
||||
label: 'default_funding_ppm',
|
||||
value: '0',
|
||||
},
|
||||
{
|
||||
key: 'liquidity_tier',
|
||||
label: 'liquidity_tier',
|
||||
value: liquidityTier,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Styled.Code>
|
||||
),
|
||||
[CodeToggleGroup.MSG_CREATE_CLOB_PAIR]: (
|
||||
<Styled.Code>
|
||||
<Styled.Details
|
||||
layout="column"
|
||||
items={[
|
||||
{
|
||||
key: 'clob_id',
|
||||
label: 'clob_id',
|
||||
value: `${clobPairId}`,
|
||||
},
|
||||
{
|
||||
key: 'perpetual_id',
|
||||
label: 'perpetual_id',
|
||||
value: `${clobPairId}`,
|
||||
},
|
||||
{
|
||||
key: 'quantum_conversion_exponent',
|
||||
label: 'quantum_conversion_exponent',
|
||||
value: `${assetData?.quantumConversionExponent}`,
|
||||
},
|
||||
{
|
||||
key: 'step_base_quantums',
|
||||
label: 'step_base_quantums',
|
||||
value: `${assetData?.stepBaseQuantum}`,
|
||||
},
|
||||
{
|
||||
key: 'subticks_per_tick',
|
||||
label: 'subticks_per_tick',
|
||||
value: `${assetData?.subticksPerTick}`,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'status',
|
||||
value: 'INITIALIZING',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Styled.Code>
|
||||
),
|
||||
[CodeToggleGroup.MSG_DELAY_MESSAGE]: (
|
||||
<Styled.Code>
|
||||
<Styled.Details
|
||||
layout="column"
|
||||
items={[
|
||||
{
|
||||
key: 'delay_blocks',
|
||||
label: 'delay_blocks',
|
||||
value: (
|
||||
<Output
|
||||
type={OutputType.Asset}
|
||||
value={newMarketProposal.delayBlocks}
|
||||
fractionDigits={0}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div style={{ marginTop: '1rem' }}>MSG_UPDATE_CLOB_PAIR</div>
|
||||
<Styled.Details
|
||||
layout="column"
|
||||
items={[
|
||||
{
|
||||
key: 'clob_id',
|
||||
label: 'clob_id',
|
||||
value: `${clobPairId}`,
|
||||
},
|
||||
{
|
||||
key: 'perpetual_id',
|
||||
label: 'perpetual_id',
|
||||
value: `${clobPairId}`,
|
||||
},
|
||||
{
|
||||
key: 'quantum_conversion_exponent',
|
||||
label: 'quantum_conversion_exponent',
|
||||
value: `${assetData?.quantumConversionExponent}`,
|
||||
},
|
||||
{
|
||||
key: 'step_base_quantums',
|
||||
label: 'step_base_quantums',
|
||||
value: `${assetData?.stepBaseQuantum}`,
|
||||
},
|
||||
{
|
||||
key: 'subticks_per_tick',
|
||||
label: 'subticks_per_tick',
|
||||
value: `${assetData?.subticksPerTick}`,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'status',
|
||||
value: 'ACTIVE',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Styled.Code>
|
||||
),
|
||||
[CodeToggleGroup.MSG_SUBMIT_PROPOSAL]: (
|
||||
<Styled.Code>
|
||||
<Styled.Text0>title: </Styled.Text0>
|
||||
<Styled.Description>{utils.getGovAddNewMarketTitle(ticker)}</Styled.Description>
|
||||
|
||||
<Styled.Text0>initial_deposit_amount:</Styled.Text0>
|
||||
<Styled.Description>
|
||||
{
|
||||
<Output
|
||||
type={OutputType.Asset}
|
||||
value={MustBigNumber(newMarketProposal.initialDepositAmount).div(
|
||||
Number(`1e${chainTokenDecimals}`)
|
||||
)}
|
||||
fractionDigits={initialDepositAmountDecimals}
|
||||
tag={chainTokenLabel}
|
||||
/>
|
||||
}
|
||||
</Styled.Description>
|
||||
|
||||
<Styled.Text0>summary: </Styled.Text0>
|
||||
<Styled.Description>
|
||||
{utils.getGovAddNewMarketSummary(ticker, newMarketProposal.delayBlocks)}
|
||||
</Styled.Description>
|
||||
</Styled.Code>
|
||||
),
|
||||
}[codeToggleGroup]
|
||||
}
|
||||
</Styled.ProposedMessageDetails>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Content = styled.div`
|
||||
${layoutMixins.column}
|
||||
gap: 0;
|
||||
`;
|
||||
|
||||
Styled.ProposedMessageDetails = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
background-color: var(--color-layer-3);
|
||||
margin-top: 1rem;
|
||||
border-radius: 10px;
|
||||
`;
|
||||
|
||||
Styled.Tabs = styled(ToggleGroup)`
|
||||
overflow-x: auto;
|
||||
`;
|
||||
|
||||
Styled.Text0 = styled.span`
|
||||
color: var(--color-text-0);
|
||||
`;
|
||||
|
||||
Styled.Code = styled.div`
|
||||
background-color: var(--color-layer-1);
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
font: var(--font-mini-book);
|
||||
font-family: var(--fontFamily-monospace);
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0rem;
|
||||
`;
|
||||
|
||||
Styled.Details = styled(Details)`
|
||||
--details-item-height: 1.5rem;
|
||||
`;
|
||||
|
||||
Styled.Line = styled.pre`
|
||||
margin-left: 1rem;
|
||||
`;
|
||||
|
||||
Styled.Description = styled.p`
|
||||
margin-bottom: 1rem;
|
||||
`;
|
||||
@@ -0,0 +1,396 @@
|
||||
import { FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Long from 'long';
|
||||
import { encodeJson } from '@dydxprotocol/v4-client-js';
|
||||
import type { IndexedTx } from '@cosmjs/stargate';
|
||||
|
||||
import { AlertType } from '@/constants/alerts';
|
||||
import { ButtonAction, ButtonSize, ButtonType } from '@/constants/buttons';
|
||||
import { DialogTypes } from '@/constants/dialogs';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { isMainnet } from '@/constants/networks';
|
||||
import { NumberSign, TOKEN_DECIMALS } from '@/constants/numbers';
|
||||
import { LIQUIDITY_TIERS, type PotentialMarketItem } from '@/constants/potentialMarkets';
|
||||
|
||||
import {
|
||||
useAccountBalance,
|
||||
useGovernanceVariables,
|
||||
useStringGetter,
|
||||
useSubaccount,
|
||||
useTokenConfigs,
|
||||
} from '@/hooks';
|
||||
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
|
||||
|
||||
import { formMixins } from '@/styles/formMixins';
|
||||
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 { Icon, IconName } from '@/components/Icon';
|
||||
import { InputType } from '@/components/Input';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { Tag } from '@/components/Tag';
|
||||
import { WithDetailsReceipt } from '@/components/WithDetailsReceipt';
|
||||
|
||||
import { openDialog } from '@/state/dialogs';
|
||||
|
||||
import { MustBigNumber } from '@/lib/numbers';
|
||||
import { log } from '@/lib/telemetry';
|
||||
|
||||
type NewMarketPreviewStepProps = {
|
||||
assetData: PotentialMarketItem;
|
||||
clobPairId: number;
|
||||
liquidityTier: number;
|
||||
onBack: () => void;
|
||||
onSuccess: (hash: string) => void;
|
||||
tickSizeDecimals: number;
|
||||
};
|
||||
|
||||
export const NewMarketPreviewStep = ({
|
||||
assetData,
|
||||
clobPairId,
|
||||
liquidityTier,
|
||||
onBack,
|
||||
onSuccess,
|
||||
tickSizeDecimals,
|
||||
}: NewMarketPreviewStepProps) => {
|
||||
const { nativeTokenBalance } = useAccountBalance();
|
||||
const dispatch = useDispatch();
|
||||
const stringGetter = useStringGetter();
|
||||
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
|
||||
const [errorMessage, setErrorMessage] = useState();
|
||||
const { exchangeConfigs } = usePotentialMarkets();
|
||||
const { submitNewMarketProposal } = useSubaccount();
|
||||
const { newMarketProposal } = useGovernanceVariables();
|
||||
const initialDepositAmountBN = MustBigNumber(newMarketProposal.initialDepositAmount).div(
|
||||
Number(`1e${chainTokenDecimals}`)
|
||||
);
|
||||
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
|
||||
const initialDepositAmount = initialDepositAmountBN.toFixed(initialDepositAmountDecimals);
|
||||
const [hasAcceptedTerms, setHasAcceptedTerms] = useState(false);
|
||||
|
||||
const { label, initialMarginFraction, maintenanceMarginFraction, impactNotional } =
|
||||
LIQUIDITY_TIERS[liquidityTier as unknown as keyof typeof LIQUIDITY_TIERS];
|
||||
|
||||
const ticker = `${assetData.baseAsset}-USD`;
|
||||
|
||||
const alertMessage = useMemo(() => {
|
||||
if (errorMessage) {
|
||||
return {
|
||||
type: AlertType.Error,
|
||||
message: errorMessage,
|
||||
};
|
||||
}
|
||||
if (nativeTokenBalance.lt(initialDepositAmountBN)) {
|
||||
return {
|
||||
type: AlertType.Error,
|
||||
message: stringGetter({
|
||||
key: STRING_KEYS.NOT_ENOUGH_BALANCE,
|
||||
params: {
|
||||
NUM_TOKENS_REQUIRED: initialDepositAmount,
|
||||
NATIVE_TOKEN_DENOM: chainTokenLabel,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [nativeTokenBalance, errorMessage]);
|
||||
|
||||
const isDisabled = alertMessage !== null;
|
||||
|
||||
return (
|
||||
<Styled.Form
|
||||
onSubmit={async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!hasAcceptedTerms) {
|
||||
dispatch(
|
||||
openDialog({
|
||||
type: DialogTypes.NewMarketAgreement,
|
||||
dialogProps: {
|
||||
acceptTerms: () => setHasAcceptedTerms(true),
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setErrorMessage(undefined);
|
||||
|
||||
try {
|
||||
const tx = await submitNewMarketProposal({
|
||||
id: clobPairId,
|
||||
ticker,
|
||||
priceExponent: assetData.priceExponent,
|
||||
minPriceChange: assetData.minPriceChangePpm,
|
||||
minExchanges: assetData.minExchanges,
|
||||
exchangeConfigJson: JSON.stringify({
|
||||
exchanges: exchangeConfigs?.[assetData.baseAsset],
|
||||
}),
|
||||
atomicResolution: assetData.atomicResolution,
|
||||
liquidityTier: liquidityTier,
|
||||
quantumConversionExponent: assetData.quantumConversionExponent,
|
||||
stepBaseQuantums: Long.fromNumber(assetData.stepBaseQuantum),
|
||||
subticksPerTick: assetData.subticksPerTick,
|
||||
delayBlocks: newMarketProposal.delayBlocks,
|
||||
});
|
||||
|
||||
if ((tx as IndexedTx)?.code === 0) {
|
||||
const encodedTx = encodeJson(tx);
|
||||
const parsedTx = JSON.parse(encodedTx);
|
||||
const hash = parsedTx.hash.toUpperCase();
|
||||
|
||||
if (!hash) {
|
||||
throw new Error('Invalid transaction hash');
|
||||
}
|
||||
|
||||
onSuccess(hash);
|
||||
} else {
|
||||
throw new Error('Transaction failed to commit.');
|
||||
}
|
||||
} catch (error) {
|
||||
log('NewMarketPreviewForm/submitNewMarketProposal', error);
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h2>
|
||||
{stringGetter({ key: STRING_KEYS.CONFIRM_NEW_MARKET_PROPOSAL })}
|
||||
<Styled.Balance>
|
||||
{stringGetter({ key: STRING_KEYS.BALANCE })}:{' '}
|
||||
<Output
|
||||
type={OutputType.Number}
|
||||
value={nativeTokenBalance}
|
||||
fractionDigits={TOKEN_DECIMALS}
|
||||
slotRight={<Styled.Tag>{chainTokenLabel}</Styled.Tag>}
|
||||
/>
|
||||
</Styled.Balance>
|
||||
</h2>
|
||||
<Styled.FormInput
|
||||
disabled
|
||||
label={stringGetter({ key: STRING_KEYS.MARKET })}
|
||||
type={InputType.Text}
|
||||
value={ticker}
|
||||
/>
|
||||
<Styled.WithDetailsReceipt
|
||||
side="bottom"
|
||||
detailItems={[
|
||||
{
|
||||
key: 'imf',
|
||||
label: 'IMF',
|
||||
tooltip: 'initial-margin-fraction',
|
||||
value: (
|
||||
<Output fractionDigits={2} type={OutputType.Number} value={initialMarginFraction} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mmf',
|
||||
label: 'MMF',
|
||||
tooltip: 'maintenance-margin-fraction',
|
||||
value: (
|
||||
<Output
|
||||
fractionDigits={2}
|
||||
type={OutputType.Number}
|
||||
value={maintenanceMarginFraction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'impact-notional',
|
||||
label: stringGetter({ key: STRING_KEYS.IMPACT_NOTIONAL }),
|
||||
value: <Output type={OutputType.Fiat} value={impactNotional} />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Styled.FormInput
|
||||
disabled
|
||||
label={stringGetter({ key: STRING_KEYS.LIQUIDITY_TIER })}
|
||||
type={InputType.Text}
|
||||
value={label}
|
||||
/>
|
||||
</Styled.WithDetailsReceipt>
|
||||
|
||||
<Styled.WithDetailsReceipt
|
||||
detailItems={[
|
||||
{
|
||||
key: 'reference-price',
|
||||
label: stringGetter({ key: STRING_KEYS.REFERENCE_PRICE }),
|
||||
tooltip: 'reference-price',
|
||||
value: (
|
||||
<Output
|
||||
type={OutputType.Fiat}
|
||||
value={assetData.referencePrice}
|
||||
fractionDigits={tickSizeDecimals}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'message-details',
|
||||
label: stringGetter({ key: STRING_KEYS.MESSAGE_DETAILS }),
|
||||
value: (
|
||||
<Styled.Button
|
||||
action={ButtonAction.Navigation}
|
||||
size={ButtonSize.Small}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
openDialog({
|
||||
type: DialogTypes.NewMarketMessageDetails,
|
||||
dialogProps: { assetData, clobPairId, liquidityTier },
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{stringGetter({ key: STRING_KEYS.VIEW_DETAILS })} →
|
||||
</Styled.Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'required-balance',
|
||||
label: (
|
||||
<span>
|
||||
{stringGetter({ key: STRING_KEYS.REQUIRED_BALANCE })} <Tag>{chainTokenLabel}</Tag>
|
||||
</span>
|
||||
),
|
||||
value: (
|
||||
<Output
|
||||
type={OutputType.Number}
|
||||
value={initialDepositAmount}
|
||||
fractionDigits={initialDepositAmountDecimals}
|
||||
slotRight={
|
||||
<>
|
||||
{'+ '}
|
||||
<Styled.Icon
|
||||
$hasError={nativeTokenBalance?.lt(initialDepositAmountBN)}
|
||||
iconName={
|
||||
nativeTokenBalance?.gt(initialDepositAmountBN)
|
||||
? IconName.CheckCircle
|
||||
: IconName.CautionCircle
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'wallet-balance',
|
||||
label: (
|
||||
<span>
|
||||
{stringGetter({ key: STRING_KEYS.WALLET_BALANCE })} <Tag>{chainTokenLabel}</Tag>
|
||||
</span>
|
||||
),
|
||||
value: (
|
||||
<DiffOutput
|
||||
withDiff
|
||||
hasInvalidNewValue={isDisabled}
|
||||
sign={NumberSign.Negative}
|
||||
fractionDigits={TOKEN_DECIMALS}
|
||||
type={OutputType.Number}
|
||||
value={nativeTokenBalance.isZero() ? undefined : nativeTokenBalance}
|
||||
newValue={nativeTokenBalance.minus(initialDepositAmountBN)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div />
|
||||
</Styled.WithDetailsReceipt>
|
||||
{alertMessage && (
|
||||
<AlertMessage type={alertMessage.type}>{alertMessage.message} </AlertMessage>
|
||||
)}
|
||||
<Styled.ButtonRow>
|
||||
<Button onClick={onBack}>{stringGetter({ key: STRING_KEYS.BACK })}</Button>
|
||||
<Button type={ButtonType.Submit} action={ButtonAction.Primary} state={{ isDisabled }}>
|
||||
{hasAcceptedTerms
|
||||
? stringGetter({ key: STRING_KEYS.PROPOSE_NEW_MARKET })
|
||||
: stringGetter({ key: STRING_KEYS.ACKNOWLEDGE_TERMS })}
|
||||
</Button>
|
||||
</Styled.ButtonRow>
|
||||
<Styled.Disclaimer>
|
||||
{stringGetter({
|
||||
key: STRING_KEYS.PROPOSAL_DISCLAIMER,
|
||||
params: {
|
||||
NUM_TOKENS_REQUIRED: initialDepositAmount,
|
||||
NATIVE_TOKEN_DENOM: chainTokenLabel,
|
||||
},
|
||||
})}
|
||||
</Styled.Disclaimer>
|
||||
</Styled.Form>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Form = styled.form`
|
||||
${formMixins.transfersForm}
|
||||
${layoutMixins.stickyArea0}
|
||||
--stickyArea0-background: transparent;
|
||||
|
||||
h2 {
|
||||
${layoutMixins.row}
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
font: var(--font-large-medium);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Balance = styled.span`
|
||||
${layoutMixins.inlineRow}
|
||||
font: var(--font-small-book);
|
||||
margin-top: 0.125rem;
|
||||
|
||||
output {
|
||||
margin-left: 0.5ch;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Tag = styled(Tag)`
|
||||
margin-left: 0.5ch;
|
||||
`;
|
||||
|
||||
Styled.FormInput = styled(FormInput)`
|
||||
input {
|
||||
font-size: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Icon = styled(Icon)<{ $hasError?: boolean }>`
|
||||
margin-left: 0.5ch;
|
||||
|
||||
${({ $hasError }) => ($hasError ? 'color: var(--color-error);' : 'color: var(--color-success);')}
|
||||
`;
|
||||
|
||||
Styled.WithDetailsReceipt = styled(WithDetailsReceipt)`
|
||||
--details-item-fontSize: 1rem;
|
||||
`;
|
||||
|
||||
Styled.CheckboxContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 1rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
Styled.Disclaimer = styled.div<{ textAlign?: string }>`
|
||||
font: var(--font-small);
|
||||
color: var(--color-text-0);
|
||||
text-align: center;
|
||||
margin-left: 0.5ch;
|
||||
|
||||
${({ textAlign }) => textAlign && `text-align: ${textAlign};`}
|
||||
`;
|
||||
|
||||
Styled.ButtonRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
Styled.Button = styled(Button)`
|
||||
--button-padding: 0;
|
||||
`;
|
||||
@@ -0,0 +1,466 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
import { Root, Item } from '@radix-ui/react-radio-group';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { OnboardingState } from '@/constants/account';
|
||||
import { AlertType } from '@/constants/alerts';
|
||||
import { ButtonAction, ButtonShape, ButtonSize, ButtonType } from '@/constants/buttons';
|
||||
import { DialogTypes } from '@/constants/dialogs';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { isMainnet } from '@/constants/networks';
|
||||
import { TOKEN_DECIMALS } from '@/constants/numbers';
|
||||
|
||||
import {
|
||||
LIQUIDITY_TIERS,
|
||||
NUM_ORACLES_TO_QUALIFY_AS_SAFE,
|
||||
type PotentialMarketItem,
|
||||
} from '@/constants/potentialMarkets';
|
||||
|
||||
import {
|
||||
useAccountBalance,
|
||||
useBreakpoints,
|
||||
useGovernanceVariables,
|
||||
useStringGetter,
|
||||
useTokenConfigs,
|
||||
} from '@/hooks';
|
||||
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
|
||||
|
||||
import { breakpoints } from '@/styles';
|
||||
import { formMixins } from '@/styles/formMixins';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { AlertMessage } from '@/components/AlertMessage';
|
||||
import { Button } from '@/components/Button';
|
||||
import { Details } from '@/components/Details';
|
||||
import { Icon, IconName } from '@/components/Icon';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { Tag } from '@/components/Tag';
|
||||
import { SearchSelectMenu } from '@/components/SearchSelectMenu';
|
||||
import { WithReceipt } from '@/components/WithReceipt';
|
||||
|
||||
import { OnboardingTriggerButton } from '@/views/dialogs/OnboardingTriggerButton';
|
||||
|
||||
import { getOnboardingState } from '@/state/accountSelectors';
|
||||
import { openDialog } from '@/state/dialogs';
|
||||
import { getMarketIds } from '@/state/perpetualsSelectors';
|
||||
|
||||
import { isTruthy } from '@/lib/isTruthy';
|
||||
import { MustBigNumber } from '@/lib/numbers';
|
||||
|
||||
type NewMarketSelectionStepProps = {
|
||||
assetToAdd?: PotentialMarketItem;
|
||||
clobPairId?: number;
|
||||
setAssetToAdd: (assetToAdd?: PotentialMarketItem) => void;
|
||||
onConfirmMarket: () => void;
|
||||
liquidityTier?: number;
|
||||
setLiquidityTier: (liquidityTier?: number) => void;
|
||||
tickSizeDecimals: number;
|
||||
};
|
||||
|
||||
export const NewMarketSelectionStep = ({
|
||||
assetToAdd,
|
||||
clobPairId,
|
||||
setAssetToAdd,
|
||||
onConfirmMarket,
|
||||
liquidityTier,
|
||||
setLiquidityTier,
|
||||
tickSizeDecimals,
|
||||
}: NewMarketSelectionStepProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const { nativeTokenBalance } = useAccountBalance();
|
||||
const onboardingState = useSelector(getOnboardingState);
|
||||
const isDisconnected = onboardingState === OnboardingState.Disconnected;
|
||||
const { isMobile } = useBreakpoints();
|
||||
const marketIds = useSelector(getMarketIds, shallowEqual);
|
||||
const { chainTokenDecimals, chainTokenLabel } = useTokenConfigs();
|
||||
const { potentialMarkets, exchangeConfigs } = usePotentialMarkets();
|
||||
const stringGetter = useStringGetter();
|
||||
const { newMarketProposal } = useGovernanceVariables();
|
||||
const initialDepositAmountBN = MustBigNumber(newMarketProposal.initialDepositAmount).div(
|
||||
Number(`1e${chainTokenDecimals}`)
|
||||
);
|
||||
const initialDepositAmountDecimals = isMainnet ? 0 : chainTokenDecimals;
|
||||
const initialDepositAmount = initialDepositAmountBN.toFixed(initialDepositAmountDecimals);
|
||||
|
||||
const [tempLiquidityTier, setTempLiquidityTier] = useState<number>();
|
||||
const [canModifyLiqTier, setCanModifyLiqTier] = useState(false);
|
||||
|
||||
const alertMessage = useMemo(() => {
|
||||
if (nativeTokenBalance.lt(initialDepositAmountBN)) {
|
||||
return {
|
||||
type: AlertType.Warning,
|
||||
message: stringGetter({
|
||||
key: STRING_KEYS.NOT_ENOUGH_BALANCE,
|
||||
params: {
|
||||
NUM_TOKENS_REQUIRED: initialDepositAmount,
|
||||
NATIVE_TOKEN_DENOM: chainTokenLabel,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [nativeTokenBalance, stringGetter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (assetToAdd) {
|
||||
setTempLiquidityTier(assetToAdd.liquidityTier);
|
||||
setLiquidityTier(assetToAdd.liquidityTier);
|
||||
}
|
||||
}, [assetToAdd]);
|
||||
|
||||
const filteredPotentialMarkets = useMemo(() => {
|
||||
return potentialMarkets?.filter(
|
||||
({ baseAsset, numOracles }) =>
|
||||
exchangeConfigs?.[baseAsset] !== undefined &&
|
||||
Number(numOracles) >= NUM_ORACLES_TO_QUALIFY_AS_SAFE &&
|
||||
!marketIds.includes(`${baseAsset}-USD`)
|
||||
);
|
||||
}, [exchangeConfigs, potentialMarkets, marketIds]);
|
||||
|
||||
return (
|
||||
<Styled.Form
|
||||
onSubmit={(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (canModifyLiqTier) {
|
||||
setLiquidityTier(tempLiquidityTier);
|
||||
setCanModifyLiqTier(false);
|
||||
} else {
|
||||
onConfirmMarket();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h2>
|
||||
{stringGetter({ key: STRING_KEYS.ADD_A_MARKET })}
|
||||
<Styled.Balance>
|
||||
{stringGetter({ key: STRING_KEYS.BALANCE })}:{' '}
|
||||
<Output
|
||||
type={OutputType.Number}
|
||||
value={nativeTokenBalance}
|
||||
fractionDigits={TOKEN_DECIMALS}
|
||||
slotRight={<Styled.Tag>{chainTokenLabel}</Styled.Tag>}
|
||||
/>
|
||||
</Styled.Balance>
|
||||
</h2>
|
||||
<SearchSelectMenu
|
||||
items={[
|
||||
{
|
||||
group: 'markets',
|
||||
groupLabel: stringGetter({ key: STRING_KEYS.MARKETS }),
|
||||
items:
|
||||
filteredPotentialMarkets?.map((potentialMarket: PotentialMarketItem) => ({
|
||||
value: potentialMarket.baseAsset,
|
||||
label: potentialMarket?.assetName ?? potentialMarket.baseAsset,
|
||||
tag: `${potentialMarket.baseAsset}-USD`,
|
||||
onSelect: () => {
|
||||
setAssetToAdd(potentialMarket);
|
||||
},
|
||||
})) ?? [],
|
||||
},
|
||||
]}
|
||||
label={stringGetter({ key: STRING_KEYS.MARKETS })}
|
||||
>
|
||||
{assetToAdd ? (
|
||||
<Styled.SelectedAsset>
|
||||
{assetToAdd?.assetName ?? assetToAdd.baseAsset} <Tag>{assetToAdd?.baseAsset}-USD</Tag>
|
||||
</Styled.SelectedAsset>
|
||||
) : (
|
||||
'e.g. "BTC-USD"'
|
||||
)}
|
||||
</SearchSelectMenu>
|
||||
{assetToAdd && (
|
||||
<>
|
||||
<div>{stringGetter({ key: STRING_KEYS.POPULATED_DETAILS })}</div>
|
||||
<div>
|
||||
<Styled.Root value={tempLiquidityTier} onValueChange={setTempLiquidityTier}>
|
||||
<Styled.Header>
|
||||
{stringGetter({ key: STRING_KEYS.LIQUIDITY_TIER })}
|
||||
<Styled.ButtonRow>
|
||||
<Button
|
||||
shape={ButtonShape.Pill}
|
||||
onClick={() => {
|
||||
if (canModifyLiqTier) {
|
||||
setTempLiquidityTier(liquidityTier);
|
||||
}
|
||||
setCanModifyLiqTier(!canModifyLiqTier);
|
||||
}}
|
||||
>
|
||||
{canModifyLiqTier ? (
|
||||
stringGetter({ key: STRING_KEYS.CANCEL })
|
||||
) : (
|
||||
<>
|
||||
{stringGetter({ key: STRING_KEYS.MODIFY })}{' '}
|
||||
<Icon iconName={IconName.Pencil} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{canModifyLiqTier && (
|
||||
<Button
|
||||
shape={ButtonShape.Pill}
|
||||
action={ButtonAction.Primary}
|
||||
onClick={() => {
|
||||
setLiquidityTier(tempLiquidityTier);
|
||||
setCanModifyLiqTier(false);
|
||||
}}
|
||||
>
|
||||
{stringGetter({ key: STRING_KEYS.SAVE })}
|
||||
</Button>
|
||||
)}
|
||||
</Styled.ButtonRow>
|
||||
</Styled.Header>
|
||||
|
||||
{Object.keys(LIQUIDITY_TIERS).map((tier) => {
|
||||
const { maintenanceMarginFraction, impactNotional, label, initialMarginFraction } =
|
||||
LIQUIDITY_TIERS[tier as unknown as keyof typeof LIQUIDITY_TIERS];
|
||||
return (
|
||||
<Styled.LiquidityTierRadioButton
|
||||
key={tier}
|
||||
value={Number(tier)}
|
||||
selected={Number(tier) === tempLiquidityTier}
|
||||
disabled={!canModifyLiqTier}
|
||||
>
|
||||
<Styled.Header style={{ marginLeft: '1rem' }}>
|
||||
{label}
|
||||
{Number(tier) === assetToAdd?.liquidityTier && (
|
||||
<Tag style={{ marginLeft: '0.5ch' }}>
|
||||
✨ {stringGetter({ key: STRING_KEYS.RECOMMENDED })}
|
||||
</Tag>
|
||||
)}
|
||||
</Styled.Header>
|
||||
<Styled.Details
|
||||
layout={isMobile ? 'grid' : 'rowColumns'}
|
||||
withSeparators={!isMobile}
|
||||
items={[
|
||||
{
|
||||
key: 'imf',
|
||||
label: 'IMF',
|
||||
tooltip: 'initial-margin-fraction',
|
||||
value: (
|
||||
<Output
|
||||
fractionDigits={2}
|
||||
type={OutputType.Number}
|
||||
value={initialMarginFraction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mmf',
|
||||
label: 'MMF',
|
||||
tooltip: 'maintenance-margin-fraction',
|
||||
value: (
|
||||
<Output
|
||||
fractionDigits={2}
|
||||
type={OutputType.Number}
|
||||
value={maintenanceMarginFraction}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'impact-notional',
|
||||
label: stringGetter({ key: STRING_KEYS.IMPACT_NOTIONAL }),
|
||||
value: <Output type={OutputType.Fiat} value={impactNotional} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Styled.LiquidityTierRadioButton>
|
||||
);
|
||||
})}
|
||||
</Styled.Root>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{alertMessage && (
|
||||
<AlertMessage type={alertMessage.type}>{alertMessage.message} </AlertMessage>
|
||||
)}
|
||||
<WithReceipt
|
||||
slotReceipt={
|
||||
<Styled.ReceiptDetails
|
||||
items={[
|
||||
assetToAdd && {
|
||||
key: 'reference-price',
|
||||
label: stringGetter({ key: STRING_KEYS.REFERENCE_PRICE }),
|
||||
tooltip: 'reference-price',
|
||||
value: (
|
||||
<Output
|
||||
type={OutputType.Fiat}
|
||||
value={assetToAdd.referencePrice}
|
||||
fractionDigits={tickSizeDecimals}
|
||||
/>
|
||||
),
|
||||
},
|
||||
assetToAdd && {
|
||||
key: 'message-details',
|
||||
label: stringGetter({ key: STRING_KEYS.MESSAGE_DETAILS }),
|
||||
value: (
|
||||
<Styled.Button
|
||||
action={ButtonAction.Navigation}
|
||||
size={ButtonSize.Small}
|
||||
onClick={() =>
|
||||
dispatch(
|
||||
openDialog({
|
||||
type: DialogTypes.NewMarketMessageDetails,
|
||||
dialogProps: { assetData: assetToAdd, clobPairId, liquidityTier },
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{stringGetter({ key: STRING_KEYS.VIEW_DETAILS })} →
|
||||
</Styled.Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dydx-required',
|
||||
label: (
|
||||
<span>
|
||||
{stringGetter({ key: STRING_KEYS.REQUIRED_BALANCE })}{' '}
|
||||
<Tag>{chainTokenLabel}</Tag>
|
||||
</span>
|
||||
),
|
||||
value: (
|
||||
<Styled.Disclaimer>
|
||||
{stringGetter({
|
||||
key: STRING_KEYS.OR_MORE,
|
||||
params: {
|
||||
NUMBER: (
|
||||
<Styled.Output
|
||||
useGrouping
|
||||
type={OutputType.Number}
|
||||
value={initialDepositAmountBN}
|
||||
fractionDigits={initialDepositAmountDecimals}
|
||||
/>
|
||||
),
|
||||
},
|
||||
})}
|
||||
</Styled.Disclaimer>
|
||||
),
|
||||
},
|
||||
].filter(isTruthy)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isDisconnected ? (
|
||||
<OnboardingTriggerButton />
|
||||
) : (
|
||||
<Button
|
||||
type={ButtonType.Submit}
|
||||
state={{ isDisabled: !assetToAdd || !liquidityTier === undefined || !clobPairId }}
|
||||
action={ButtonAction.Primary}
|
||||
>
|
||||
{canModifyLiqTier
|
||||
? stringGetter({ key: STRING_KEYS.SAVE })
|
||||
: stringGetter({ key: STRING_KEYS.PREVIEW_MARKET_PROPOSAL })}
|
||||
</Button>
|
||||
)}
|
||||
</WithReceipt>
|
||||
</Styled.Form>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Form = styled.form`
|
||||
${formMixins.transfersForm}
|
||||
${layoutMixins.stickyArea0}
|
||||
--stickyArea0-background: transparent;
|
||||
|
||||
h2 {
|
||||
${layoutMixins.row}
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
font: var(--font-large-medium);
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Balance = styled.span`
|
||||
${layoutMixins.inlineRow}
|
||||
font: var(--font-small-book);
|
||||
margin-top: 0.125rem;
|
||||
|
||||
output {
|
||||
margin-left: 0.5ch;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Tag = styled(Tag)`
|
||||
margin-left: 0.5ch;
|
||||
`;
|
||||
|
||||
Styled.SelectedAsset = styled.span`
|
||||
color: var(--color-text-2);
|
||||
`;
|
||||
|
||||
Styled.Disclaimer = styled.div`
|
||||
color: var(--color-text-0);
|
||||
margin-left: 0.5ch;
|
||||
`;
|
||||
|
||||
Styled.Header = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
color: var(--color-text-2);
|
||||
font: var(--font-base-medium);
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
Styled.ButtonRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
|
||||
button {
|
||||
min-width: 80px;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Root = styled(Root)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--color-layer-6);
|
||||
background-color: var(--color-layer-4);
|
||||
`;
|
||||
|
||||
Styled.LiquidityTierRadioButton = styled(Item)<{ selected?: boolean }>`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.625rem;
|
||||
border: 1px solid var(--color-layer-6);
|
||||
padding: 1rem 0;
|
||||
font: var(--font-mini-book);
|
||||
|
||||
${({ selected }) => selected && 'background-color: var(--color-layer-2)'}
|
||||
`;
|
||||
|
||||
Styled.Details = styled(Details)`
|
||||
margin-top: 0.5rem;
|
||||
padding: 0;
|
||||
|
||||
dt {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media ${breakpoints.mobile} {
|
||||
padding: 0 1rem;
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.ReceiptDetails = styled(Details)`
|
||||
padding: 0.375rem 0.75rem 0.25rem;
|
||||
`;
|
||||
|
||||
Styled.Output = styled(Output)`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
Styled.Button = styled(Button)`
|
||||
--button-padding: 0;
|
||||
`;
|
||||
@@ -0,0 +1,81 @@
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
|
||||
import { ButtonAction, ButtonType } from '@/constants/buttons';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { useStringGetter } from '@/hooks';
|
||||
import { LinkOutIcon } from '@/icons';
|
||||
|
||||
import { Button } from '@/components/Button';
|
||||
import { Icon, IconName } from '@/components/Icon';
|
||||
|
||||
type NewMarketSuccessStepProps = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
export const NewMarketSuccessStep = ({ href }: NewMarketSuccessStepProps) => {
|
||||
const stringGetter = useStringGetter();
|
||||
|
||||
return (
|
||||
<Styled.ProposalSent>
|
||||
<Styled.OuterCircle>
|
||||
<Styled.InnerCircle>
|
||||
<Icon iconName={IconName.Check} />
|
||||
</Styled.InnerCircle>
|
||||
</Styled.OuterCircle>
|
||||
<h2>{stringGetter({ key: STRING_KEYS.SUBMITTED_PROPOSAL })}</h2>
|
||||
<span>{stringGetter({ key: STRING_KEYS.PROPOSAL_SUBMISSION_SUCCESSFUL })}</span>
|
||||
<Button type={ButtonType.Link} href={href} action={ButtonAction.Primary}>
|
||||
{stringGetter({ key: STRING_KEYS.VIEW_PROPOSAL })}
|
||||
<LinkOutIcon />
|
||||
</Button>
|
||||
</Styled.ProposalSent>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.ProposalSent = styled.div`
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
|
||||
&& {
|
||||
h2 {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.OuterCircle = styled.div`
|
||||
width: 5.25rem;
|
||||
height: 5.25rem;
|
||||
min-width: 5.25rem;
|
||||
height: 5.25rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-gradient-positive);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
Styled.InnerCircle = styled.div`
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-success);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
color: var(--color-layer-2);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
|
||||
import { TOKEN_DECIMALS } from '@/constants/numbers';
|
||||
import { type PotentialMarketItem } from '@/constants/potentialMarkets';
|
||||
import { useNextClobPairId, useURLConfigs } from '@/hooks';
|
||||
import { usePotentialMarkets } from '@/hooks/usePotentialMarkets';
|
||||
|
||||
import { LoadingSpace } from '@/components/Loading/LoadingSpinner';
|
||||
|
||||
import { NewMarketSelectionStep } from './NewMarketSelectionStep';
|
||||
import { NewMarketPreviewStep } from './NewMarketPreviewStep';
|
||||
import { NewMarketSuccessStep } from './NewMarketSuccessStep';
|
||||
|
||||
enum NewMarketFormStep {
|
||||
SELECTION,
|
||||
PREVIEW,
|
||||
SUCCESS,
|
||||
}
|
||||
|
||||
export const NewMarketForm = () => {
|
||||
const [step, setStep] = useState(NewMarketFormStep.SELECTION);
|
||||
const [assetToAdd, setAssetToAdd] = useState<PotentialMarketItem>();
|
||||
const [liquidityTier, setLiquidityTier] = useState<number>();
|
||||
const [proposalTxHash, setProposalTxHash] = useState<string>();
|
||||
const { mintscan: mintscanTxUrl } = useURLConfigs();
|
||||
|
||||
const { nextAvailableClobPairId } = useNextClobPairId();
|
||||
const { hasPotentialMarketsData } = usePotentialMarkets();
|
||||
|
||||
const tickSizeDecimals = useMemo(() => {
|
||||
if (!assetToAdd) return TOKEN_DECIMALS;
|
||||
const p = Math.floor(Math.log(Number(assetToAdd.referencePrice)));
|
||||
return Math.abs(p - 3);
|
||||
}, [assetToAdd]);
|
||||
|
||||
if (!hasPotentialMarketsData || !nextAvailableClobPairId) {
|
||||
return <Styled.LoadingSpace id="new-market-form" />;
|
||||
}
|
||||
|
||||
if (NewMarketFormStep.SUCCESS === step && proposalTxHash) {
|
||||
return <NewMarketSuccessStep href={mintscanTxUrl.replace('{tx_hash}', proposalTxHash)} />;
|
||||
}
|
||||
|
||||
if (NewMarketFormStep.PREVIEW === step) {
|
||||
if (assetToAdd && liquidityTier && nextAvailableClobPairId) {
|
||||
return (
|
||||
<NewMarketPreviewStep
|
||||
assetData={assetToAdd}
|
||||
clobPairId={nextAvailableClobPairId}
|
||||
liquidityTier={liquidityTier}
|
||||
onBack={() => setStep(NewMarketFormStep.SELECTION)}
|
||||
onSuccess={(hash: string) => {
|
||||
setProposalTxHash(hash);
|
||||
setStep(NewMarketFormStep.SUCCESS);
|
||||
}}
|
||||
tickSizeDecimals={tickSizeDecimals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NewMarketSelectionStep
|
||||
onConfirmMarket={() => setStep(NewMarketFormStep.PREVIEW)}
|
||||
assetToAdd={assetToAdd}
|
||||
clobPairId={nextAvailableClobPairId}
|
||||
setAssetToAdd={setAssetToAdd}
|
||||
liquidityTier={liquidityTier}
|
||||
setLiquidityTier={setLiquidityTier}
|
||||
tickSizeDecimals={tickSizeDecimals}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.LoadingSpace = styled(LoadingSpace)`
|
||||
min-height: 18.75rem;
|
||||
`;
|
||||
@@ -11,6 +11,7 @@ import { setSelectedTradeLayout } from '@/state/layout';
|
||||
|
||||
import { getAssets } from '@/state/assetsSelectors';
|
||||
import { getPerpetualMarkets } from '@/state/perpetualsSelectors';
|
||||
import { Asset, PerpetualMarket } from '@/constants/abacus';
|
||||
|
||||
enum ThemeItems {
|
||||
SetClassicTheme = 'SetDefaultTheme',
|
||||
@@ -44,7 +45,7 @@ export const useGlobalCommands = (): MenuConfig<string, string> => {
|
||||
const joinedPerpetualMarketsAndAssets = Object.values(allPerpetualMarkets).map((market) => ({
|
||||
...market,
|
||||
...allAssets[market?.assetId],
|
||||
}));
|
||||
})) as Array<PerpetualMarket & Asset>;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -129,10 +130,10 @@ export const useGlobalCommands = (): MenuConfig<string, string> => {
|
||||
{
|
||||
value: NavItems.NavigateToMarket,
|
||||
label: 'Navigate to Market',
|
||||
subitems: joinedPerpetualMarketsAndAssets.map(({ market = '', name = '', id = '' }) => ({
|
||||
value: market,
|
||||
subitems: joinedPerpetualMarketsAndAssets.map(({ market, name, id }) => ({
|
||||
value: market ?? '',
|
||||
slotBefore: <AssetIcon symbol={id} />,
|
||||
label: name,
|
||||
label: name ?? '',
|
||||
tag: id,
|
||||
onSelect: () => navigate(`/trade/${market}`),
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user